fur_cli/helpers/
cloning.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use serde_json::{Value, json};
4use uuid::Uuid;
5use chrono::Utc;
6use std::collections::HashMap;
7
8pub fn load_conversation_metadata(path: &Path) -> (String, Vec<String>) {
9    let convo: Value =
10        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
11
12    let title = convo["title"].as_str().unwrap_or("Untitled").to_string();
13
14    let messages = convo["messages"]
15        .as_array()
16        .unwrap_or(&vec![])
17        .iter()
18        .filter_map(|v| v.as_str().map(|s| s.to_string()))
19        .collect::<Vec<_>>();
20
21    (title, messages)
22}
23
24pub fn make_new_conversation_header(
25    old_title: &str,
26    custom_title: Option<String>,
27) -> (String, String, String) {
28    let new_id = Uuid::new_v4().to_string();
29    let timestamp = Utc::now().to_rfc3339();
30    let new_title = custom_title.unwrap_or_else(|| format!("Clone of {}", old_title));
31    (new_id, new_title, timestamp)
32}
33
34pub fn build_id_remap(old_messages: &[String]) -> HashMap<String, String> {
35    let mut map = HashMap::new();
36    for old in old_messages {
37        map.insert(old.clone(), Uuid::new_v4().to_string());
38    }
39    map
40}
41
42pub fn clone_all_messages(id_map: &HashMap<String, String>, old_messages: &[String]) {
43    let messages_dir = Path::new(".fur/messages");
44
45    for old_id in old_messages {
46        let old_msg_path = messages_dir.join(format!("{}.json", old_id));
47
48        let old_msg: Value =
49            serde_json::from_str(&fs::read_to_string(&old_msg_path).unwrap()).unwrap();
50
51        let new_id = id_map.get(old_id).unwrap();
52
53        let new_parent = remap_optional(&old_msg["parent"], id_map);
54        let new_children = remap_vec(&old_msg["children"], id_map);
55        let new_branches = remap_vec(&old_msg["branches"], id_map);
56
57        // Copy markdown if exists
58        let new_markdown = clone_markdown_if_any(&old_msg);
59
60        let mut new_msg = old_msg.clone();
61        new_msg["id"] = json!(new_id);
62        new_msg["timestamp"] = json!(Utc::now().to_rfc3339());
63        new_msg["parent"] = new_parent;
64        new_msg["children"] = json!(new_children);
65        new_msg["branches"] = json!(new_branches);
66        new_msg["markdown"] = match new_markdown {
67            Some(path) => json!(path),
68            None => Value::Null,
69        };
70
71        // Write new JSON file
72        let new_path = messages_dir.join(format!("{}.json", new_id));
73        fs::write(new_path, serde_json::to_string_pretty(&new_msg).unwrap()).unwrap();
74    }
75}
76
77pub fn remap_optional(val: &Value, map: &HashMap<String, String>) -> Value {
78    match val.as_str().and_then(|v| map.get(v)) {
79        Some(new) => json!(new),
80        None => Value::Null,
81    }
82}
83
84pub fn remap_vec(val: &Value, map: &HashMap<String, String>) -> Vec<Value> {
85    val.as_array()
86        .unwrap_or(&vec![])
87        .iter()
88        .filter_map(|v| v.as_str())
89        .filter_map(|old| map.get(old))
90        .map(|new| json!(new))
91        .collect()
92}
93
94pub fn clone_markdown_if_any(old_msg: &Value) -> Option<String> {
95    if let Some(md_raw) = old_msg["markdown"].as_str() {
96        let old_md_path = PathBuf::from(md_raw);
97
98        if old_md_path.exists() {
99            // Extract the base filename
100            let filename = old_md_path.file_name()?.to_string_lossy();
101
102            // Detect clone-depth (count trailing 'c')
103            let stem = filename.trim_end_matches(".md");
104            let (base, existing_c_suffix) = split_clone_suffix(stem);
105
106            // Generate the new suffix by appending one more 'c'
107            let new_suffix = format!("{}c", existing_c_suffix);
108
109            let new_filename = format!("{}{}.md", base, new_suffix);
110            let new_path = format!("chats/{}", new_filename);
111
112            fs::copy(&old_md_path, &new_path)
113                .expect("❌ Failed to copy markdown file");
114
115            return Some(new_path);
116        }
117    }
118    None
119}
120
121/// Splits "CHAT-timestamp-ccc" → ("CHAT-timestamp-", "ccc")
122fn split_clone_suffix(stem: &str) -> (String, String) {
123    // Find where the trailing c's start
124    let c_count = stem.chars().rev().take_while(|&ch| ch == 'c').count();
125
126    if c_count == 0 {
127        // No suffix at all
128        return (stem.to_string(), String::new());
129    }
130
131    let base_len = stem.len() - c_count;
132    let base = stem[..base_len].to_string();
133    let suffix = stem[base_len..].to_string();
134
135    (base, suffix)
136}
137
138
139pub fn write_new_conversation(
140    new_id: &str,
141    new_title: &str,
142    timestamp: &str,
143    id_map: &HashMap<String, String>,
144    old_messages: &[String],
145) {
146    let threads_dir = Path::new(".fur/threads");
147
148    let new_messages: Vec<String> = old_messages
149        .iter()
150        .map(|old| id_map.get(old).unwrap().clone())
151        .collect();
152
153    let convo = json!({
154        "id": new_id,
155        "title": new_title,
156        "created_at": timestamp,
157        "messages": new_messages,
158        "tags": []
159    });
160
161    let new_path = threads_dir.join(format!("{}.json", new_id));
162    fs::write(new_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
163}
164
165pub fn update_index(new_id: &str) {
166    let index_path = Path::new(".fur/index.json");
167    let mut index: Value =
168        serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
169
170    index["threads"]
171        .as_array_mut()
172        .unwrap()
173        .push(json!(new_id));
174
175    index["active_thread"] = json!(new_id);
176    index["current_message"] = Value::Null;
177
178    fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
179}