fur_cli/commands/
message.rs1use 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#[derive(Parser, Debug)]
10pub struct MsgArgs {
11 #[arg(index = 1)]
13 pub id_prefix: Option<String>,
14
15
16 #[arg(long)]
18 pub pre: bool,
19
20 #[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 #[arg(index = 2, trailing_var_arg = true)]
41 pub rest: Vec<String>,
42
43}
44
45
46pub fn run_msg(args: MsgArgs) {
48
49 if args.delete {
50 return run_delete(args);
51 }
52
53 if args.pre {
55 return run_insert(&args, true);
56 }
57
58 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
73fn run_delete(args: MsgArgs) {
80 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
102fn run_edit(args: MsgArgs) {
109 let (id_opt, mut text_opt) = classify_id_or_text(&args);
110
111 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 if args.interactive {
122 let edited = run_interactive_editor(msg["text"].as_str().unwrap_or_default());
123 text_opt = Some(edited);
124 }
125
126 if let Some(t) = text_opt {
128 msg["text"] = json!(t);
129 msg["markdown"] = json!(null);
130 }
131
132 if let Some(fp) = args.file {
134 msg["markdown"] = json!(fp);
135 msg["text"] = json!(null);
136 }
137
138 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
150pub 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
168pub fn classify_id_or_text(args: &MsgArgs) -> (Option<String>, Option<String>) {
173 if let Some(pfx) = &args.id_prefix {
175 if let Some(full_id) = detect_id(&Some(pfx.clone())) {
176 return (Some(full_id), extract_text_from_rest(args));
178 }
179
180 return (None, Some(pfx.clone()));
182 }
183
184 (None, extract_text_from_rest(args))
186}
187
188fn 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
197fn 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
228fn 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
289fn 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 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 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 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
364fn 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}