Skip to main content

fur_cli/commands/
message.rs

1use crate::helpers::insertion::run_insert;
2use clap::Parser;
3use serde_json::{json, Value};
4use std::fs;
5use std::io::Write;
6use std::path::Path;
7
8/// Subcommand: `fur msg`
9#[derive(Parser, Debug)]
10pub struct MsgArgs {
11    /// First positional: ID prefix (only if it matches) or text
12    #[arg(index = 1)]
13    pub id_prefix: Option<String>,
14
15    /// Insert before target
16    #[arg(long)]
17    pub pre: bool,
18
19    /// Insert after target
20    #[arg(long)]
21    pub post: bool,
22
23    #[arg(long)]
24    pub edit: bool,
25
26    #[arg(long, alias = "rem")]
27    pub delete: bool,
28
29    #[arg(long, alias = "file")]
30    pub file: Option<String>,
31
32    #[arg(long)]
33    pub avatar: Option<String>,
34
35    #[arg(long)]
36    pub interactive: bool,
37
38    /// Everything *after* the ID
39    #[arg(index = 2, trailing_var_arg = true)]
40    pub rest: Vec<String>,
41}
42
43/// Entry point
44pub fn run_msg(args: MsgArgs) {
45    if args.delete {
46        return run_delete(args);
47    }
48
49    // INSERT BEFORE
50    if args.pre {
51        return run_insert(&args, true);
52    }
53
54    // INSERT AFTER
55    if args.post {
56        return run_insert(&args, false);
57    }
58
59    if args.edit {
60        return run_edit(args);
61    }
62
63    eprintln!("❌ msg requires: --pre | --post | --edit | --delete");
64}
65
66//
67// ======================================================
68//  DELETE LOGIC
69// ======================================================
70//
71
72fn run_delete(args: MsgArgs) {
73    // Delete target: ID prefix OR last message
74    let target = detect_id(&args.id_prefix).unwrap_or_else(|| resolve_target_message(None));
75
76    print!("Delete message {}? [y/N]: ", &target[..8]);
77    std::io::stdout().flush().unwrap();
78
79    let mut buf = String::new();
80    std::io::stdin().read_line(&mut buf).unwrap();
81
82    if !["y", "Y", "yes", "YES"].contains(&buf.trim()) {
83        println!("❌ Cancelled.");
84        return;
85    }
86
87    recursive_delete(&target);
88    remove_from_parent_or_root(&target);
89    update_current_after_delete(&target);
90
91    println!("🗑️ Deleted {}", &target[..8]);
92
93    crate::schema::bridge::sync_active();
94}
95
96//
97// ======================================================
98//  EDIT LOGIC
99// ======================================================
100//
101
102fn run_edit(args: MsgArgs) {
103    let (id_opt, mut text_opt) = classify_id_or_text(&args);
104
105    // Final target message ID
106    let id = id_opt.unwrap_or_else(|| resolve_target_message(None));
107
108    let fur = Path::new(".fur");
109    let msg_path = fur.join("messages").join(format!("{}.json", id));
110
111    let mut msg: Value = serde_json::from_str(&fs::read_to_string(&msg_path).unwrap()).unwrap();
112
113    // Interactive override
114    if args.interactive {
115        let edited = run_interactive_editor(msg["text"].as_str().unwrap_or_default());
116        text_opt = Some(edited);
117    }
118
119    // Apply text
120    if let Some(t) = text_opt {
121        msg["text"] = json!(t);
122        msg["markdown"] = json!(null);
123    }
124
125    // Apply markdown
126    if let Some(fp) = args.file {
127        msg["markdown"] = json!(fp);
128        msg["text"] = json!(null);
129    }
130
131    // Avatar change
132    if let Some(a) = args.avatar {
133        msg["avatar"] = json!(a);
134    }
135
136    write_json(&msg_path, &msg);
137
138    println!("✏️ Edited {}", &id[..8]);
139
140    crate::schema::bridge::sync_active();
141}
142
143//
144// ======================================================
145//  POSITONAL ID RESOLUTION
146// ======================================================
147//
148
149/// Detect if value looks like an ID prefix.
150pub fn detect_id(x: &Option<String>) -> Option<String> {
151    let Some(val) = x else { return None };
152
153    if val.starts_with("--") {
154        return None;
155    }
156
157    resolve_prefix_if_exists(val)
158}
159
160/// Determine if the call looked like:
161///   msg <id> --edit new text...
162/// OR:
163///   msg "some text" --edit
164pub fn classify_id_or_text(args: &MsgArgs) -> (Option<String>, Option<String>) {
165    // Case A: First positional *could* be an ID
166    if let Some(pfx) = &args.id_prefix {
167        if let Some(full_id) = detect_id(&Some(pfx.clone())) {
168            // ID detected
169            return (Some(full_id), extract_text_from_rest(args));
170        }
171
172        // Not an ID → treat as text
173        return (None, Some(pfx.clone()));
174    }
175
176    // No id_prefix → rely on rest as text
177    (None, extract_text_from_rest(args))
178}
179
180/// Combine trailing args into text
181fn extract_text_from_rest(args: &MsgArgs) -> Option<String> {
182    if args.rest.is_empty() {
183        None
184    } else {
185        Some(args.rest.join(" "))
186    }
187}
188
189//
190// ======================================================
191//  PREFIX UTILITIES
192// ======================================================
193//
194
195fn resolve_prefix_if_exists(pfx: &str) -> Option<String> {
196    let fur = Path::new(".fur");
197    let (_index, tid) = resolve_active_conversation();
198
199    let convo_path = fur.join("threads").join(format!("{}.json", tid));
200    let convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
201
202    let root_ids = convo["messages"]
203        .as_array()
204        .unwrap_or(&vec![])
205        .iter()
206        .filter_map(|x| x.as_str().map(|s| s.to_string()))
207        .collect::<Vec<_>>();
208
209    let matches: Vec<&String> = root_ids.iter().filter(|id| id.starts_with(pfx)).collect();
210
211    if matches.len() == 1 {
212        Some(matches[0].clone())
213    } else {
214        None
215    }
216}
217
218//
219// ======================================================
220//  ACTIVE CONVERSATION RESOLUTION
221// ======================================================
222//
223
224fn resolve_active_conversation() -> (Value, String) {
225    let idx_path = Path::new(".fur/index.json");
226    let index: Value = serde_json::from_str(&fs::read_to_string(idx_path).unwrap()).unwrap();
227
228    let tid = index["active_thread"].as_str().unwrap_or("").to_string();
229
230    (index, tid)
231}
232
233pub fn resolve_target_message(prefix: Option<String>) -> String {
234    let fur = Path::new(".fur");
235
236    let (index, tid) = resolve_active_conversation();
237    let convo_path = fur.join("threads").join(format!("{}.json", tid));
238
239    let convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
240
241    let root_ids = convo["messages"]
242        .as_array()
243        .unwrap()
244        .iter()
245        .filter_map(|v| v.as_str().map(|s| s.to_string()))
246        .collect::<Vec<_>>();
247
248    if let Some(pfx) = prefix {
249        return resolve_prefix(&root_ids, &pfx);
250    }
251
252    if let Some(cur) = index["current_message"].as_str() {
253        if !cur.is_empty() {
254            return cur.to_string();
255        }
256    }
257
258    root_ids.last().expect("❌ No messages").to_string()
259}
260
261fn resolve_prefix(root_ids: &Vec<String>, prefix: &str) -> String {
262    let matches: Vec<&String> = root_ids
263        .iter()
264        .filter(|id| id.starts_with(prefix))
265        .collect();
266
267    if matches.is_empty() {
268        eprintln!("❌ No message matches '{}'", prefix);
269        std::process::exit(1);
270    }
271    if matches.len() > 1 {
272        eprintln!("❌ Ambiguous '{}': {:?}", prefix, matches);
273        std::process::exit(1);
274    }
275
276    matches[0].to_string()
277}
278
279//
280// ======================================================
281//  DELETE IMPLEMENTATION
282// ======================================================
283//
284
285fn recursive_delete(mid: &str) {
286    let fur = Path::new(".fur");
287    let msg_path = fur.join("messages").join(format!("{}.json", mid));
288
289    let Ok(content) = fs::read_to_string(&msg_path) else {
290        return;
291    };
292    let Ok(msg) = serde_json::from_str::<Value>(&content) else {
293        return;
294    };
295
296    if let Some(children) = msg["children"].as_array() {
297        for child in children {
298            if let Some(cid) = child.as_str() {
299                recursive_delete(cid);
300            }
301        }
302    }
303
304    let _ = fs::remove_file(&msg_path);
305}
306
307fn remove_from_parent_or_root(mid: &str) {
308    let fur = Path::new(".fur");
309
310    // Load deleted msg metadata (if exists)
311    let msg_path = fur.join("messages").join(format!("{}.json", mid));
312    let raw = fs::read_to_string(&msg_path).unwrap_or("{}".into());
313    let msg: Value = serde_json::from_str(&raw).unwrap_or(json!({}));
314
315    // If part of a thread tree
316    if let Some(pid) = msg["parent"].as_str() {
317        let ppath = fur.join("messages").join(format!("{}.json", pid));
318        if let Ok(content) = fs::read_to_string(&ppath) {
319            let mut parent: Value = serde_json::from_str(&content).unwrap();
320            if let Some(arr) = parent["children"].as_array_mut() {
321                arr.retain(|v| v.as_str() != Some(mid));
322            }
323            write_json(&ppath, &parent);
324        }
325        return;
326    }
327
328    // Otherwise part of root list
329    let (_index, tid) = resolve_active_conversation();
330    let convo_path = fur.join("threads").join(format!("{}.json", tid));
331
332    let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
333
334    if let Some(arr) = convo["messages"].as_array_mut() {
335        arr.retain(|v| v.as_str() != Some(mid));
336    }
337
338    write_json(&convo_path, &convo);
339}
340
341fn update_current_after_delete(mid: &str) {
342    let fur = Path::new(".fur");
343    let idx_path = fur.join("index.json");
344
345    let mut index: Value = serde_json::from_str(&fs::read_to_string(&idx_path).unwrap()).unwrap();
346
347    if let Some(cur) = index["current_message"].as_str() {
348        if cur == mid {
349            index["current_message"] = json!(null);
350        }
351    }
352
353    write_json(&idx_path, &index);
354}
355
356//
357// ======================================================
358//  HELPERS
359// ======================================================
360//
361
362fn run_interactive_editor(initial: &str) -> String {
363    use std::env;
364    use std::process::Command;
365
366    let tmp = "/tmp/fur_edit_msg.txt";
367    fs::write(tmp, initial).unwrap();
368
369    let editor = env::var("EDITOR").unwrap_or("nano".into());
370
371    Command::new(editor)
372        .arg(tmp)
373        .status()
374        .expect("❌ Could not start editor");
375
376    fs::read_to_string(tmp).unwrap()
377}
378
379fn write_json(path: &Path, v: &Value) {
380    fs::write(path, serde_json::to_string_pretty(v).unwrap()).unwrap();
381}