fur_cli/commands/
message.rs1use 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#[derive(Parser, Debug)]
10pub struct MsgArgs {
11 #[arg(index = 1)]
13 pub id_prefix: Option<String>,
14
15 #[arg(long)]
17 pub pre: bool,
18
19 #[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 #[arg(index = 2, trailing_var_arg = true)]
40 pub rest: Vec<String>,
41}
42
43pub fn run_msg(args: MsgArgs) {
45 if args.delete {
46 return run_delete(args);
47 }
48
49 if args.pre {
51 return run_insert(&args, true);
52 }
53
54 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
66fn run_delete(args: MsgArgs) {
73 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
94fn run_edit(args: MsgArgs) {
101 let (id_opt, mut text_opt) = classify_id_or_text(&args);
102
103 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 if args.interactive {
113 let edited = run_interactive_editor(msg["text"].as_str().unwrap_or_default());
114 text_opt = Some(edited);
115 }
116
117 if let Some(t) = text_opt {
119 msg["text"] = json!(t);
120 msg["markdown"] = json!(null);
121 }
122
123 if let Some(fp) = args.file {
125 msg["markdown"] = json!(fp);
126 msg["text"] = json!(null);
127 }
128
129 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
139pub 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
156pub fn classify_id_or_text(args: &MsgArgs) -> (Option<String>, Option<String>) {
161 if let Some(pfx) = &args.id_prefix {
163 if let Some(full_id) = detect_id(&Some(pfx.clone())) {
164 return (Some(full_id), extract_text_from_rest(args));
166 }
167
168 return (None, Some(pfx.clone()));
170 }
171
172 (None, extract_text_from_rest(args))
174}
175
176fn 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
185fn 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
214fn 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
275fn 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 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 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 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
352fn 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}