fur_cli/commands/
message.rs

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