Skip to main content

fur_cli/commands/
xclone.rs

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