fur_cli/commands/
xclone.rs1use 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
8fn 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"); fs::create_dir_all(&threads).unwrap();
15 fs::create_dir_all(&messages).unwrap();
16 fs::create_dir_all(&chats).unwrap();
17}
18
19fn 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 new_msg["id"] = json!(new_id);
36
37 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 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() .join(md_rel); 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 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
91fn 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
119fn 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 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
145pub 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 ×tamp,
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}