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
94//
95// ======================================================
96//  EDIT LOGIC
97// ======================================================
98//
99
100fn run_edit(args: MsgArgs) {
101    let (id_opt, mut text_opt) = classify_id_or_text(&args);
102
103    // Final target message ID
104    let id = id_opt.unwrap_or_else(|| resolve_target_message(None));
105
106    let fur = Path::new(".fur");
107    let msg_path = fur.join("messages").join(format!("{}.json", id));
108
109    let mut msg: Value = serde_json::from_str(&fs::read_to_string(&msg_path).unwrap()).unwrap();
110
111    // Interactive override
112    if args.interactive {
113        let edited = run_interactive_editor(msg["text"].as_str().unwrap_or_default());
114        text_opt = Some(edited);
115    }
116
117    // Apply text
118    if let Some(t) = text_opt {
119        msg["text"] = json!(t);
120        msg["markdown"] = json!(null);
121    }
122
123    // Apply markdown
124    if let Some(fp) = args.file {
125        msg["markdown"] = json!(fp);
126        msg["text"] = json!(null);
127    }
128
129    // Avatar change
130    if let Some(a) = args.avatar {
131        msg["avatar"] = json!(a);
132    }
133
134    write_json(&msg_path, &msg);
135
136    println!("✏️ Edited {}", &id[..8]);
137}
138
139//
140// ======================================================
141//  POSITONAL ID RESOLUTION
142// ======================================================
143//
144
145/// Detect if value looks like an ID prefix.
146pub fn detect_id(x: &Option<String>) -> Option<String> {
147    let Some(val) = x else { return None };
148
149    if val.starts_with("--") {
150        return None;
151    }
152
153    resolve_prefix_if_exists(val)
154}
155
156/// Determine if the call looked like:
157///   msg <id> --edit new text...
158/// OR:
159///   msg "some text" --edit
160pub fn classify_id_or_text(args: &MsgArgs) -> (Option<String>, Option<String>) {
161    // Case A: First positional *could* be an ID
162    if let Some(pfx) = &args.id_prefix {
163        if let Some(full_id) = detect_id(&Some(pfx.clone())) {
164            // ID detected
165            return (Some(full_id), extract_text_from_rest(args));
166        }
167
168        // Not an ID → treat as text
169        return (None, Some(pfx.clone()));
170    }
171
172    // No id_prefix → rely on rest as text
173    (None, extract_text_from_rest(args))
174}
175
176/// Combine trailing args into text
177fn extract_text_from_rest(args: &MsgArgs) -> Option<String> {
178    if args.rest.is_empty() {
179        None
180    } else {
181        Some(args.rest.join(" "))
182    }
183}
184
185//
186// ======================================================
187//  PREFIX UTILITIES
188// ======================================================
189//
190
191fn resolve_prefix_if_exists(pfx: &str) -> Option<String> {
192    let fur = Path::new(".fur");
193    let (_index, tid) = resolve_active_conversation();
194
195    let convo_path = fur.join("threads").join(format!("{}.json", tid));
196    let convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
197
198    let root_ids = convo["messages"]
199        .as_array()
200        .unwrap_or(&vec![])
201        .iter()
202        .filter_map(|x| x.as_str().map(|s| s.to_string()))
203        .collect::<Vec<_>>();
204
205    let matches: Vec<&String> = root_ids.iter().filter(|id| id.starts_with(pfx)).collect();
206
207    if matches.len() == 1 {
208        Some(matches[0].clone())
209    } else {
210        None
211    }
212}
213
214//
215// ======================================================
216//  ACTIVE CONVERSATION RESOLUTION
217// ======================================================
218//
219
220fn resolve_active_conversation() -> (Value, String) {
221    let idx_path = Path::new(".fur/index.json");
222    let index: Value = serde_json::from_str(&fs::read_to_string(idx_path).unwrap()).unwrap();
223
224    let tid = index["active_thread"].as_str().unwrap_or("").to_string();
225
226    (index, tid)
227}
228
229pub fn resolve_target_message(prefix: Option<String>) -> String {
230    let fur = Path::new(".fur");
231
232    let (index, tid) = resolve_active_conversation();
233    let convo_path = fur.join("threads").join(format!("{}.json", tid));
234
235    let convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
236
237    let root_ids = convo["messages"]
238        .as_array()
239        .unwrap()
240        .iter()
241        .filter_map(|v| v.as_str().map(|s| s.to_string()))
242        .collect::<Vec<_>>();
243
244    if let Some(pfx) = prefix {
245        return resolve_prefix(&root_ids, &pfx);
246    }
247
248    if let Some(cur) = index["current_message"].as_str() {
249        if !cur.is_empty() {
250            return cur.to_string();
251        }
252    }
253
254    root_ids.last().expect("❌ No messages").to_string()
255}
256
257fn resolve_prefix(root_ids: &Vec<String>, prefix: &str) -> String {
258    let matches: Vec<&String> = root_ids
259        .iter()
260        .filter(|id| id.starts_with(prefix))
261        .collect();
262
263    if matches.is_empty() {
264        eprintln!("❌ No message matches '{}'", prefix);
265        std::process::exit(1);
266    }
267    if matches.len() > 1 {
268        eprintln!("❌ Ambiguous '{}': {:?}", prefix, matches);
269        std::process::exit(1);
270    }
271
272    matches[0].to_string()
273}
274
275//
276// ======================================================
277//  DELETE IMPLEMENTATION
278// ======================================================
279//
280
281fn recursive_delete(mid: &str) {
282    let fur = Path::new(".fur");
283    let msg_path = fur.join("messages").join(format!("{}.json", mid));
284
285    let Ok(content) = fs::read_to_string(&msg_path) else {
286        return;
287    };
288    let Ok(msg) = serde_json::from_str::<Value>(&content) else {
289        return;
290    };
291
292    if let Some(children) = msg["children"].as_array() {
293        for child in children {
294            if let Some(cid) = child.as_str() {
295                recursive_delete(cid);
296            }
297        }
298    }
299
300    let _ = fs::remove_file(&msg_path);
301}
302
303fn remove_from_parent_or_root(mid: &str) {
304    let fur = Path::new(".fur");
305
306    // Load deleted msg metadata (if exists)
307    let msg_path = fur.join("messages").join(format!("{}.json", mid));
308    let raw = fs::read_to_string(&msg_path).unwrap_or("{}".into());
309    let msg: Value = serde_json::from_str(&raw).unwrap_or(json!({}));
310
311    // If part of a thread tree
312    if let Some(pid) = msg["parent"].as_str() {
313        let ppath = fur.join("messages").join(format!("{}.json", pid));
314        if let Ok(content) = fs::read_to_string(&ppath) {
315            let mut parent: Value = serde_json::from_str(&content).unwrap();
316            if let Some(arr) = parent["children"].as_array_mut() {
317                arr.retain(|v| v.as_str() != Some(mid));
318            }
319            write_json(&ppath, &parent);
320        }
321        return;
322    }
323
324    // Otherwise part of root list
325    let (_index, tid) = resolve_active_conversation();
326    let convo_path = fur.join("threads").join(format!("{}.json", tid));
327
328    let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
329
330    if let Some(arr) = convo["messages"].as_array_mut() {
331        arr.retain(|v| v.as_str() != Some(mid));
332    }
333
334    write_json(&convo_path, &convo);
335}
336
337fn update_current_after_delete(mid: &str) {
338    let fur = Path::new(".fur");
339    let idx_path = fur.join("index.json");
340
341    let mut index: Value = serde_json::from_str(&fs::read_to_string(&idx_path).unwrap()).unwrap();
342
343    if let Some(cur) = index["current_message"].as_str() {
344        if cur == mid {
345            index["current_message"] = json!(null);
346        }
347    }
348
349    write_json(&idx_path, &index);
350}
351
352//
353// ======================================================
354//  HELPERS
355// ======================================================
356//
357
358fn run_interactive_editor(initial: &str) -> String {
359    use std::env;
360    use std::process::Command;
361
362    let tmp = "/tmp/fur_edit_msg.txt";
363    fs::write(tmp, initial).unwrap();
364
365    let editor = env::var("EDITOR").unwrap_or("nano".into());
366
367    Command::new(editor)
368        .arg(tmp)
369        .status()
370        .expect("❌ Could not start editor");
371
372    fs::read_to_string(tmp).unwrap()
373}
374
375fn write_json(path: &Path, v: &Value) {
376    fs::write(path, serde_json::to_string_pretty(v).unwrap()).unwrap();
377}