Skip to main content

fur_cli/commands/
xclone.rs

1use crate::helpers::cloning::{
2    build_id_remap, load_conversation_metadata, make_new_conversation_header,
3};
4use serde_json::{json, Value};
5use std::fs;
6use std::path::Path;
7
8/// Ensure target project has a minimal .fur/ structure + chats/
9fn ensure_target_project(dst_root: &Path) {
10    let threads = dst_root.join("threads");
11    let messages = dst_root.join("messages");
12    let chats = dst_root.parent().unwrap().join("chats"); // <-- FIX HERE
13
14    fs::create_dir_all(&threads).unwrap();
15    fs::create_dir_all(&messages).unwrap();
16    fs::create_dir_all(&chats).unwrap();
17}
18
19/// Clone all messages from source → destination .fur/messages + chats/
20fn clone_messages_into_target(
21    src_msgs_dir: &Path,
22    dst_msgs_dir: &Path,
23    id_map: &std::collections::HashMap<String, String>,
24    old_messages: &[String],
25) {
26    for old_id in old_messages {
27        let src_path = src_msgs_dir.join(format!("{}.json", old_id));
28
29        let old_msg: Value = serde_json::from_str(&fs::read_to_string(&src_path).unwrap()).unwrap();
30
31        let new_id = id_map.get(old_id).unwrap();
32        let mut new_msg = old_msg.clone();
33
34        // Remap ID
35        new_msg["id"] = json!(new_id);
36
37        // Remap structural pointers
38        if let Some(p) = old_msg["parent"].as_str() {
39            new_msg["parent"] = id_map.get(p).map(|s| json!(s)).unwrap_or(json!(null));
40        } else {
41            new_msg["parent"] = json!(null);
42        }
43
44        if let Some(children) = old_msg["children"].as_array() {
45            new_msg["children"] = json!(children
46                .iter()
47                .filter_map(|v| v.as_str())
48                .filter_map(|id| id_map.get(id))
49                .collect::<Vec<_>>());
50        }
51
52        if let Some(branches) = old_msg["branches"].as_array() {
53            new_msg["branches"] = json!(branches
54                .iter()
55                .filter_map(|v| v.as_str())
56                .filter_map(|id| id_map.get(id))
57                .collect::<Vec<_>>());
58        }
59
60        // --- MARKDOWN FIX HERE ---
61        if let Some(md_rel) = old_msg["markdown"].as_str() {
62            let src_md = src_msgs_dir
63                .parent()
64                .unwrap()
65                .parent()
66                .unwrap()
67                .join(md_rel);
68            let dst_md = dst_msgs_dir
69                .parent()
70                .unwrap()
71                .parent()
72                .unwrap() // <target> root
73                .join(md_rel); // always "chats/...md"
74
75            if src_md.exists() {
76                fs::create_dir_all(dst_md.parent().unwrap()).unwrap();
77                fs::copy(&src_md, &dst_md).expect("❌ Failed to copy markdown attachment");
78            }
79
80            new_msg["markdown"] = json!(md_rel);
81        } else {
82            new_msg["markdown"] = json!(null);
83        }
84
85        // Write new message JSON
86        let dst_path = dst_msgs_dir.join(format!("{}.json", new_id));
87        fs::write(dst_path, serde_json::to_string_pretty(&new_msg).unwrap()).unwrap();
88    }
89}
90
91/// Write the new conversation header into <target>/.fur/threads/
92fn write_new_convo_into_target(
93    dst_root: &Path,
94    new_tid: &str,
95    new_title: &str,
96    timestamp: &str,
97    id_map: &std::collections::HashMap<String, String>,
98    old_messages: &[String],
99) {
100    let threads_dir = dst_root.join("threads");
101
102    let new_messages: Vec<String> = old_messages
103        .iter()
104        .map(|old| id_map.get(old).unwrap().clone())
105        .collect();
106
107    let convo = json!({
108        "id": new_tid,
109        "title": new_title,
110        "created_at": timestamp,
111        "messages": new_messages,
112        "tags": []
113    });
114
115    let new_path = threads_dir.join(format!("{}.json", new_tid));
116    fs::write(new_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
117}
118
119/// Append the cloned TID into <target>/.fur/index.json
120fn update_target_index(dst_root: &Path, new_tid: &str) {
121    let index_path = dst_root.join("index.json");
122
123    let mut index: Value = if index_path.exists() {
124        serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap()
125    } else {
126        // If no index.json exists, create a minimal one
127        json!({
128            "threads": [],
129            "active_thread": null,
130            "current_message": null,
131            "created_at": chrono::Utc::now().to_rfc3339()
132        })
133    };
134
135    index["threads"]
136        .as_array_mut()
137        .unwrap()
138        .push(json!(new_tid));
139    index["active_thread"] = json!(new_tid);
140    index["current_message"] = json!(null);
141
142    fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
143}
144
145/// Main entry for the xclone command
146pub fn run_xclone(to: &str, tid: &str, title: Option<String>) {
147    let src_root = Path::new(".fur");
148    let dst_root = Path::new(to).join(".fur");
149
150    ensure_target_project(&dst_root);
151
152    let src_convo_path = src_root.join("threads").join(format!("{}.json", tid));
153    if !src_convo_path.exists() {
154        eprintln!("❌ Conversation {} not found.", tid);
155        return;
156    }
157
158    let (old_title, old_messages) = load_conversation_metadata(&src_convo_path);
159
160    let (new_tid, new_title, timestamp) = make_new_conversation_header(&old_title, title);
161
162    let id_map = build_id_remap(&old_messages);
163
164    clone_messages_into_target(
165        &src_root.join("messages"),
166        &dst_root.join("messages"),
167        &id_map,
168        &old_messages,
169    );
170
171    write_new_convo_into_target(
172        &dst_root,
173        &new_tid,
174        &new_title,
175        &timestamp,
176        &id_map,
177        &old_messages,
178    );
179
180    update_target_index(&dst_root, &new_tid);
181
182    println!(
183        "🌀 Deep-cloned conversation \"{}\" → {} into {}",
184        old_title,
185        &new_tid[..8],
186        dst_root.display()
187    );
188}