fur_cli/helpers/
cloning.rs1use 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 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 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 let filename = old_md_path.file_name()?.to_string_lossy();
106
107 let stem = filename.trim_end_matches(".md");
109 let (base, existing_c_suffix) = split_clone_suffix(stem);
110
111 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
126fn split_clone_suffix(stem: &str) -> (String, String) {
128 let c_count = stem.chars().rev().take_while(|&ch| ch == 'c').count();
130
131 if c_count == 0 {
132 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}