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 crate::schema::bridge::sync_active();
94}
95
96fn run_edit(args: MsgArgs) {
103 let (id_opt, mut text_opt) = classify_id_or_text(&args);
104
105 let id = id_opt.unwrap_or_else(|| resolve_target_message(None));
107
108 let fur = Path::new(".fur");
109 let msg_path = fur.join("messages").join(format!("{}.json", id));
110
111 let mut msg: Value = serde_json::from_str(&fs::read_to_string(&msg_path).unwrap()).unwrap();
112
113 if args.interactive {
115 let edited = run_interactive_editor(msg["text"].as_str().unwrap_or_default());
116 text_opt = Some(edited);
117 }
118
119 if let Some(t) = text_opt {
121 msg["text"] = json!(t);
122 msg["markdown"] = json!(null);
123 }
124
125 if let Some(fp) = args.file {
127 msg["markdown"] = json!(fp);
128 msg["text"] = json!(null);
129 }
130
131 if let Some(a) = args.avatar {
133 msg["avatar"] = json!(a);
134 }
135
136 write_json(&msg_path, &msg);
137
138 println!("✏️ Edited {}", &id[..8]);
139
140 crate::schema::bridge::sync_active();
141}
142
143pub fn detect_id(x: &Option<String>) -> Option<String> {
151 let Some(val) = x else { return None };
152
153 if val.starts_with("--") {
154 return None;
155 }
156
157 resolve_prefix_if_exists(val)
158}
159
160pub fn classify_id_or_text(args: &MsgArgs) -> (Option<String>, Option<String>) {
165 if let Some(pfx) = &args.id_prefix {
167 if let Some(full_id) = detect_id(&Some(pfx.clone())) {
168 return (Some(full_id), extract_text_from_rest(args));
170 }
171
172 return (None, Some(pfx.clone()));
174 }
175
176 (None, extract_text_from_rest(args))
178}
179
180fn extract_text_from_rest(args: &MsgArgs) -> Option<String> {
182 if args.rest.is_empty() {
183 None
184 } else {
185 Some(args.rest.join(" "))
186 }
187}
188
189fn resolve_prefix_if_exists(pfx: &str) -> Option<String> {
196 let fur = Path::new(".fur");
197 let (_index, tid) = resolve_active_conversation();
198
199 let convo_path = fur.join("threads").join(format!("{}.json", tid));
200 let convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
201
202 let root_ids = convo["messages"]
203 .as_array()
204 .unwrap_or(&vec![])
205 .iter()
206 .filter_map(|x| x.as_str().map(|s| s.to_string()))
207 .collect::<Vec<_>>();
208
209 let matches: Vec<&String> = root_ids.iter().filter(|id| id.starts_with(pfx)).collect();
210
211 if matches.len() == 1 {
212 Some(matches[0].clone())
213 } else {
214 None
215 }
216}
217
218fn resolve_active_conversation() -> (Value, String) {
225 let idx_path = Path::new(".fur/index.json");
226 let index: Value = serde_json::from_str(&fs::read_to_string(idx_path).unwrap()).unwrap();
227
228 let tid = index["active_thread"].as_str().unwrap_or("").to_string();
229
230 (index, tid)
231}
232
233pub fn resolve_target_message(prefix: Option<String>) -> String {
234 let fur = Path::new(".fur");
235
236 let (index, tid) = resolve_active_conversation();
237 let convo_path = fur.join("threads").join(format!("{}.json", tid));
238
239 let convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
240
241 let root_ids = convo["messages"]
242 .as_array()
243 .unwrap()
244 .iter()
245 .filter_map(|v| v.as_str().map(|s| s.to_string()))
246 .collect::<Vec<_>>();
247
248 if let Some(pfx) = prefix {
249 return resolve_prefix(&root_ids, &pfx);
250 }
251
252 if let Some(cur) = index["current_message"].as_str() {
253 if !cur.is_empty() {
254 return cur.to_string();
255 }
256 }
257
258 root_ids.last().expect("❌ No messages").to_string()
259}
260
261fn resolve_prefix(root_ids: &Vec<String>, prefix: &str) -> String {
262 let matches: Vec<&String> = root_ids
263 .iter()
264 .filter(|id| id.starts_with(prefix))
265 .collect();
266
267 if matches.is_empty() {
268 eprintln!("❌ No message matches '{}'", prefix);
269 std::process::exit(1);
270 }
271 if matches.len() > 1 {
272 eprintln!("❌ Ambiguous '{}': {:?}", prefix, matches);
273 std::process::exit(1);
274 }
275
276 matches[0].to_string()
277}
278
279fn recursive_delete(mid: &str) {
286 let fur = Path::new(".fur");
287 let msg_path = fur.join("messages").join(format!("{}.json", mid));
288
289 let Ok(content) = fs::read_to_string(&msg_path) else {
290 return;
291 };
292 let Ok(msg) = serde_json::from_str::<Value>(&content) else {
293 return;
294 };
295
296 if let Some(children) = msg["children"].as_array() {
297 for child in children {
298 if let Some(cid) = child.as_str() {
299 recursive_delete(cid);
300 }
301 }
302 }
303
304 let _ = fs::remove_file(&msg_path);
305}
306
307fn remove_from_parent_or_root(mid: &str) {
308 let fur = Path::new(".fur");
309
310 let msg_path = fur.join("messages").join(format!("{}.json", mid));
312 let raw = fs::read_to_string(&msg_path).unwrap_or("{}".into());
313 let msg: Value = serde_json::from_str(&raw).unwrap_or(json!({}));
314
315 if let Some(pid) = msg["parent"].as_str() {
317 let ppath = fur.join("messages").join(format!("{}.json", pid));
318 if let Ok(content) = fs::read_to_string(&ppath) {
319 let mut parent: Value = serde_json::from_str(&content).unwrap();
320 if let Some(arr) = parent["children"].as_array_mut() {
321 arr.retain(|v| v.as_str() != Some(mid));
322 }
323 write_json(&ppath, &parent);
324 }
325 return;
326 }
327
328 let (_index, tid) = resolve_active_conversation();
330 let convo_path = fur.join("threads").join(format!("{}.json", tid));
331
332 let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
333
334 if let Some(arr) = convo["messages"].as_array_mut() {
335 arr.retain(|v| v.as_str() != Some(mid));
336 }
337
338 write_json(&convo_path, &convo);
339}
340
341fn update_current_after_delete(mid: &str) {
342 let fur = Path::new(".fur");
343 let idx_path = fur.join("index.json");
344
345 let mut index: Value = serde_json::from_str(&fs::read_to_string(&idx_path).unwrap()).unwrap();
346
347 if let Some(cur) = index["current_message"].as_str() {
348 if cur == mid {
349 index["current_message"] = json!(null);
350 }
351 }
352
353 write_json(&idx_path, &index);
354}
355
356fn run_interactive_editor(initial: &str) -> String {
363 use std::env;
364 use std::process::Command;
365
366 let tmp = "/tmp/fur_edit_msg.txt";
367 fs::write(tmp, initial).unwrap();
368
369 let editor = env::var("EDITOR").unwrap_or("nano".into());
370
371 Command::new(editor)
372 .arg(tmp)
373 .status()
374 .expect("❌ Could not start editor");
375
376 fs::read_to_string(tmp).unwrap()
377}
378
379fn write_json(path: &Path, v: &Value) {
380 fs::write(path, serde_json::to_string_pretty(v).unwrap()).unwrap();
381}