fur_cli/commands/
message.rs

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