Skip to main content

fur_cli/frs/
persist.rs

1use uuid::Uuid;
2use chrono::Utc;
3use serde_json::{json, Value};
4use std::fs;
5use std::path::Path;
6use std::io::{self, Write};
7
8use crate::frs::ast::{Thread, Message};
9use crate::frs::ast::ScriptItem;
10
11/// Persist a parsed Thread into .fur/threads + .fur/messages
12pub fn persist_frs(conversation: &Thread) -> String {
13    let fur_dir = Path::new(".fur");
14    if !fur_dir.exists() {
15        panic!("🚨 .fur directory not initialized. Run `fur new` at least once.");
16    }
17
18    // --- Check if a conversation with the same title already exists ---
19    let index_path = fur_dir.join("index.json");
20    let mut index_data: Value =
21        serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
22
23    let mut overwrite = false;
24    let mut old_conversation_id: Option<String> = None;
25
26    if let Some(threads) = index_data["threads"].as_array() {
27        for tid in threads {
28            if let Some(tid_str) = tid.as_str() {
29                let tpath = fur_dir.join("threads").join(format!("{}.json", tid_str));
30                if let Ok(txt) = fs::read_to_string(&tpath) {
31                    if let Ok(tjson) = serde_json::from_str::<Value>(&txt) {
32                        if tjson["title"].as_str() == Some(&conversation.title) {
33                            // Found duplicate title
34                            println!("⚠️ Thread with title \"{}\" already exists.", conversation.title);
35                            print!("Overwrite? [Y/n]: ");
36                            io::stdout().flush().unwrap();
37
38                            let mut input = String::new();
39                            io::stdin().read_line(&mut input).unwrap();
40                            let response = input.trim().to_lowercase();
41
42                            if response.is_empty() || response == "y" || response == "yes" {
43                                overwrite = true;
44                                old_conversation_id = Some(tid_str.to_string());
45                            } else {
46                                println!("🚫 Skipped importing conversation \"{}\".", conversation.title);
47                                return tid_str.to_string();
48                            }
49                        }
50                    }
51                }
52            }
53        }
54    }
55
56    // --- If overwrite, delete old conversation + messages ---
57    if overwrite {
58        if let Some(tid) = &old_conversation_id {
59            delete_old_conversation(tid);
60            if let Some(arr) = index_data["threads"].as_array_mut() {
61                arr.retain(|v| v.as_str() != Some(tid));
62            }
63        }
64    }
65
66    // --- Now persist fresh conversation ---
67    let conversation_id = Uuid::new_v4().to_string();
68    let timestamp = Utc::now().to_rfc3339();
69
70    // Persist only the *root* jots; recursion handles nested branches
71    let root_ids = persist_level(
72        &conversation.items.iter().filter_map(|item| {
73            if let ScriptItem::Message(m) = item { Some(m) } else { None }
74        }).cloned().collect::<Vec<_>>(),
75        None
76    );
77
78
79    let conversation_json = json!({
80        "id": conversation_id,
81        "created_at": timestamp,
82        "title": conversation.title,
83        "tags": conversation.tags,
84        "messages": root_ids, // only roots here
85    });
86
87    let convo_path = fur_dir.join("threads").join(format!("{}.json", conversation_id));
88    fs::write(&convo_path, serde_json::to_string_pretty(&conversation_json).unwrap())
89        .expect("❌ Could not write conversation file");
90
91    // Update index.json
92    index_data["threads"].as_array_mut().unwrap().push(conversation_id.clone().into());
93    index_data["active_thread"] = conversation_id.clone().into();
94    index_data["current_message"] = Value::Null;
95    if index_data["schema_version"].as_str() == Some("0.1") {
96        index_data["schema_version"] = Value::String("0.2".to_string());
97    }
98
99    fs::write(&index_path, serde_json::to_string_pretty(&index_data).unwrap()).unwrap();
100
101    println!("🌱 Imported conversation into .fur: {} — \"{}\"", &conversation_id[..8], conversation.title);
102    conversation_id
103}
104
105
106/// Ephemeral persist: writes a conversation into `.fur/tmp/` for previews.
107/// Returns ephemeral conversation_id.
108pub fn persist_ephemeral(conversation: &Thread) -> String {
109    let fur_dir = Path::new(".fur/tmp");
110    if !fur_dir.exists() {
111        fs::create_dir_all(fur_dir).expect("❌ Could not create .fur/tmp/");
112    }
113
114    let conversation_id = format!("ephemeral-{}", Uuid::new_v4().to_string());
115    let timestamp = Utc::now().to_rfc3339();
116
117    let root_ids = persist_level(
118        &conversation.items.iter().filter_map(|item| {
119            if let ScriptItem::Message(m) = item { Some(m) } else { None }
120        }).cloned().collect::<Vec<_>>(),
121        None
122    );
123
124    let conversation_json = json!({
125        "id": conversation_id,
126        "created_at": timestamp,
127        "title": conversation.title,
128        "tags": conversation.tags,
129        "messages": root_ids,
130    });
131
132    let convo_path = fur_dir.join(format!("{}.json", conversation_id));
133    fs::write(&convo_path, serde_json::to_string_pretty(&conversation_json).unwrap())
134        .expect("❌ Could not write ephemeral conversation file");
135
136    conversation_id
137}
138
139/// Clean up ephemeral conversation + messages
140pub fn cleanup_ephemeral(conversation_id: &str) {
141    let fur_dir = Path::new(".fur/tmp");
142    let convo_path = fur_dir.join(format!("{}.json", conversation_id));
143    let _ = fs::remove_file(convo_path);
144    // NOTE: if we want to also clean messages, we can follow `delete_message_recursive`.
145}
146
147
148
149/// Delete an old conversation and all its message files.
150fn delete_old_conversation(conversation_id: &str) {
151    let fur_dir = Path::new(".fur");
152    let convo_path = fur_dir.join("threads").join(format!("{}.json", conversation_id));
153
154    if let Ok(content) = fs::read_to_string(&convo_path) {
155        if let Ok(conversation_json) = serde_json::from_str::<Value>(&content) {
156            if let Some(msgs) = conversation_json["messages"].as_array() {
157                for m in msgs {
158                    if let Some(mid) = m.as_str() {
159                        delete_message_recursive(mid, fur_dir);
160                    }
161                }
162            }
163        }
164    }
165
166    let _ = fs::remove_file(convo_path);
167}
168
169/// Recursively delete a message and its children/branches.
170fn delete_message_recursive(msg_id: &str, fur_dir: &Path) {
171    let msg_path = fur_dir.join("messages").join(format!("{}.json", msg_id));
172    if let Ok(content) = fs::read_to_string(&msg_path) {
173        if let Ok(msg_json) = serde_json::from_str::<Value>(&content) {
174            // delete children
175            if let Some(children) = msg_json["children"].as_array() {
176                for c in children {
177                    if let Some(cid) = c.as_str() {
178                        delete_message_recursive(cid, fur_dir);
179                    }
180                }
181            }
182            // delete branches
183            if let Some(branches) = msg_json["branches"].as_array() {
184                for block in branches {
185                    if let Some(arr) = block.as_array() {
186                        for c in arr {
187                            if let Some(cid) = c.as_str() {
188                                delete_message_recursive(cid, fur_dir);
189                            }
190                        }
191                    }
192                }
193            }
194        }
195    }
196    let _ = fs::remove_file(msg_path);
197}
198
199/// Persist a list of messages that share the same parent.
200/// Returns the IDs of **these** messages (not descendants).
201fn persist_level(msgs: &[Message], parent: Option<String>) -> Vec<String> {
202    let mut ids_at_this_level: Vec<String> = Vec::new();
203
204    for m in msgs {
205        let msg_id = Uuid::new_v4().to_string();
206
207        let mut branch_groups_ids: Vec<Vec<String>> = Vec::new();
208        let mut direct_children_ids: Vec<String> = Vec::new();
209
210        for branch_block in &m.branches {
211            let group_ids = persist_level(branch_block, Some(msg_id.clone()));
212            if !group_ids.is_empty() {
213                direct_children_ids.extend(group_ids.clone());
214                branch_groups_ids.push(group_ids);
215            }
216        }
217
218        let msg_json = json!({
219            "id": msg_id,
220            "avatar": m.avatar,
221            "name": m.avatar,
222            "text": m.text,
223            "markdown": m.file,
224            "attachment": m.attachment,
225            "parent": parent,
226            "children": direct_children_ids,
227            "branches": branch_groups_ids,
228            "timestamp": Utc::now().to_rfc3339(),
229        });
230
231        let path = Path::new(".fur/messages").join(format!("{}.json", msg_id));
232        fs::write(&path, serde_json::to_string_pretty(&msg_json).unwrap())
233            .expect("❌ Could not write message file");
234
235        ids_at_this_level.push(msg_id);
236    }
237
238    ids_at_this_level
239}