Skip to main content

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