fur_cli/commands/
conversation.rs

1use std::fs;
2use std::path::{Path};
3use serde_json::{Value, json};
4use clap::Parser;
5use chrono::{DateTime, Local, Utc};
6use crate::helpers::conversation::{resolve_target_thread_id,confirm_delete_primary,confirm_delete_destructive, perform_conversation_deletion};
7use crate::renderer::table::render_table;
8use crate::helpers::tags::parse_tag_list;
9
10/// Arguments for the `conversation` command
11#[derive(Parser)]
12pub struct ThreadArgs {
13    /// Thread ID or prefix to switch
14    pub id: Option<String>,
15
16    /// View all threads
17    #[arg(long)]
18    pub view: bool,
19
20    /// Rename
21    #[arg(long, alias = "rn")]
22    pub rename: Option<String>,
23
24    /// Add tags (comma-separated, supports spaces)
25    #[arg(long)]
26    pub tag: Option<String>,
27
28    #[arg(long)]
29    pub untag: Option<String>,
30
31    /// Clear all tags from conversation
32    #[arg(long)]
33    pub clear_tags: bool,
34
35    /// Delete a conversation (destructive)
36    #[arg(long)]
37    pub delete: bool,
38}
39
40pub fn run_conversation(args: ThreadArgs) {
41    let fur_dir = Path::new(".fur");
42    let index_path = fur_dir.join("index.json");
43
44    if !index_path.exists() {
45        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
46        return;
47    }
48
49    let mut index: Value =
50        serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
51
52    if args.tag.is_some() || args.untag.is_some() || args.clear_tags {
53        return handle_tagging(&args, &mut index, fur_dir);
54    }
55
56    if args.rename.is_some() {
57        return handle_rename_thread(&mut index, fur_dir, &args);
58    }
59
60    if args.delete {
61        return handle_delete_thread(&mut index, fur_dir, &args);
62    }
63
64    if args.view || args.id.is_none() {
65        return handle_view_threads(&index, fur_dir, &args);
66    }
67
68    if args.id.is_some() {
69        return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
70    }
71}
72
73fn handle_rename_thread(
74    index: &mut Value,
75    fur_dir: &Path,
76    args: &ThreadArgs,
77) {
78    let new_title = match &args.rename {
79        Some(t) => t,
80        None => return,
81    };
82
83    let empty_vec: Vec<Value> = Vec::new();
84    let threads: Vec<String> = index["threads"]
85        .as_array()
86        .unwrap_or(&empty_vec)
87        .iter()
88        .filter_map(|t| t.as_str().map(|s| s.to_string()))
89        .collect();
90
91    // CASE 1: rename current thread
92    let target_thread_id = if args.id.is_none() {
93        index["active_thread"].as_str().unwrap_or("").to_string()
94    } else {
95        // CASE 2: rename by prefix
96        let prefix = args.id.as_ref().unwrap();
97        let found = threads
98            .iter()
99            .filter(|tid| tid.starts_with(prefix))
100            .collect::<Vec<_>>();
101
102        if found.is_empty() {
103            eprintln!("❌ No conversation matches prefix '{}'", prefix);
104            return;
105        }
106        if found.len() > 1 {
107            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
108            return;
109        }
110
111        found[0].to_string()
112    };
113
114    let convo_path = fur_dir.join("threads").join(format!("{}.json", target_thread_id));
115    let mut conversation_json: Value =
116        serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
117
118    let old_title = conversation_json["title"].as_str().unwrap_or("Untitled").to_string();
119
120    // Update title
121    conversation_json["title"] = Value::String(new_title.to_string());
122    fs::write(&convo_path, serde_json::to_string_pretty(&conversation_json).unwrap()).unwrap();
123
124    println!(
125        "✏️  Renamed conversation {} \"{}\" → \"{}\"",
126        &target_thread_id[..8],
127        old_title,
128        new_title
129    );
130}
131
132
133fn handle_delete_thread(
134    index: &mut Value,
135    fur_dir: &Path,
136    args: &ThreadArgs,
137) {
138    let target_tid = match resolve_target_thread_id(index, args) {
139        Some(tid) => tid,
140        None => return,
141    };
142
143    // extract all thread IDs for later index update
144    let empty_vec: Vec<Value> = Vec::new();
145    let threads: Vec<String> = index["threads"]
146        .as_array()
147        .unwrap_or(&empty_vec)
148        .iter()
149        .filter_map(|v| v.as_str().map(|s| s.to_string()))
150        .collect();
151
152    if !confirm_delete_primary() {
153        println!("❌ Deletion aborted.");
154        return;
155    }
156
157    if !confirm_delete_destructive() {
158        println!("❌ Deletion aborted.");
159        return;
160    }
161
162    perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
163}
164
165
166
167fn handle_view_threads(
168    index: &Value,
169    fur_dir: &Path,
170    args: &ThreadArgs,
171) {
172    if !(args.view || args.id.is_none()) {
173        return;
174    }
175
176    let empty_vec: Vec<Value> = Vec::new();
177    let threads = index["threads"].as_array().unwrap_or(&empty_vec);
178    let active = index["active_thread"].as_str().unwrap_or("");
179
180    let mut rows = Vec::new();
181    let mut active_idx = None;
182
183    let mut total_size_bytes: u64 = 0;
184    let mut conversation_info = Vec::new();
185
186    for tid in threads {
187        if let Some(tid_str) = tid.as_str() {
188            let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
189
190            if let Ok(content) = fs::read_to_string(&convo_path) {
191                if let Ok(convo) = serde_json::from_str::<Value>(&content) {
192                    let title = convo["title"].as_str().unwrap_or("Untitled").to_string();
193                    let created_raw = convo["created_at"].as_str().unwrap_or("");
194
195                    let msg_ids = convo["messages"]
196                        .as_array()
197                        .map(|a| {
198                            a.iter()
199                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
200                                .collect::<Vec<_>>()
201                        })
202                        .unwrap_or_default();
203
204                    let msg_count = msg_ids.len();
205
206                    let parsed = DateTime::parse_from_rfc3339(created_raw)
207                        .map(|dt| dt.with_timezone(&Utc))
208                        .unwrap_or_else(|_| Utc::now());
209
210                    let local: DateTime<Local> = DateTime::from(parsed);
211                    let date_str = local.format("%Y-%m-%d").to_string();
212                    let time_str = local.format("%H:%M").to_string();
213
214                    let size_bytes = compute_conversation_size(fur_dir, tid_str, &msg_ids);
215                    total_size_bytes += size_bytes;
216
217                    let tags_str = convo["tags"]
218                        .as_array()
219                        .unwrap_or(&vec![])
220                        .iter()
221                        .filter_map(|v| v.as_str())
222                        .collect::<Vec<_>>()
223                        .join(", ");
224
225                    conversation_info.push((
226                        tid_str.to_string(),
227                        title,
228                        date_str,
229                        time_str,
230                        msg_count,
231                        parsed,
232                        format_size(size_bytes),
233                        tags_str,
234                    ));
235                }
236            }
237        }
238    }
239
240    // Sort newest first
241    conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
242
243    for (i, (tid, title, date, time, msg_count, _, size_str, tags_str)) in
244        conversation_info.iter().enumerate()
245    {
246        rows.push(vec![
247            tid[..8].to_string(),
248            title.to_string(),
249            format!("{} | {}", date, time),
250            msg_count.to_string(),
251            size_str.to_string(),
252            tags_str.to_string(),
253        ]);
254
255        if tid == active {
256            active_idx = Some(i);
257        }
258    }
259
260    // UPDATED HEADERS: now includes TAGS
261    render_table(
262        "Threads",
263        &["ID", "Title", "Created", "#Msgs", "Size", "Tags"],
264        rows,
265        active_idx,
266    );
267
268    println!("----------------------------");
269    println!("Total Memory Used: {}", format_size(total_size_bytes));
270}
271
272fn handle_switch_thread(
273    index: &mut Value,
274    index_path: &Path,
275    fur_dir: &Path,
276    args: &ThreadArgs,
277) {
278    let tid = match &args.id {
279        Some(id) => id,
280        None => return,
281    };
282
283    let empty_vec: Vec<Value> = Vec::new();
284    let threads: Vec<String> = index["threads"]
285        .as_array()
286        .unwrap_or(&empty_vec)
287        .iter()
288        .filter_map(|t| t.as_str().map(|s| s.to_string()))
289        .collect();
290
291    let mut found = threads.iter().find(|&s| s == tid);
292
293    if found.is_none() {
294        let matches: Vec<&String> =
295            threads.iter().filter(|s| s.starts_with(tid)).collect();
296
297        if matches.len() == 1 {
298            found = Some(matches[0]);
299        } else if matches.len() > 1 {
300            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
301            return;
302        }
303    }
304
305    let tid_full = match found {
306        Some(s) => s,
307        None => {
308            eprintln!("❌ Thread not found: {}", tid);
309            return;
310        }
311    };
312
313    index["active_thread"] = json!(tid_full);
314    index["current_message"] = serde_json::Value::Null;
315
316    fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
317
318    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
319    let content = fs::read_to_string(convo_path).unwrap();
320    let conversation_json: Value = serde_json::from_str(&content).unwrap();
321    let title = conversation_json["title"].as_str().unwrap_or("Untitled");
322
323    println!(
324        "✔️ Switched active conversation to {} \"{}\"",
325        &tid_full[..8],
326        title
327    );
328}
329
330fn handle_tagging(
331    args: &ThreadArgs,
332    index: &mut Value,
333    fur_dir: &Path,
334) {
335    let empty_vec: Vec<Value> = Vec::new();
336    let threads: Vec<String> = index["threads"]
337        .as_array()
338        .unwrap_or(&empty_vec)
339        .iter()
340        .filter_map(|t| t.as_str().map(|s| s.to_string()))
341        .collect();
342
343    // Determine which conversation to operate on
344    let target_tid = if let Some(prefix) = &args.id {
345        let matches: Vec<&String> =
346            threads.iter().filter(|tid| tid.starts_with(prefix)).collect();
347
348        if matches.is_empty() {
349            eprintln!("❌ No conversation matches '{}'", prefix);
350            return;
351        }
352        if matches.len() > 1 {
353            eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
354            return;
355        }
356        matches[0].clone()
357    } else {
358        index["active_thread"].as_str().unwrap_or("").to_string()
359    };
360
361    let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
362    let mut convo: Value =
363        serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
364
365    // -------------------------------
366    // CLEAR ALL TAGS
367    // -------------------------------
368    if args.clear_tags {
369        convo["tags"] = json!([]);
370        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
371        println!("🏷️ Cleared tags for {}", &target_tid[..8]);
372        return;
373    }
374
375    // Load existing tags
376    let mut existing: Vec<String> = convo["tags"]
377        .as_array()
378        .unwrap_or(&vec![])
379        .iter()
380        .filter_map(|v| v.as_str().map(|s| s.to_string()))
381        .collect();
382
383    // -------------------------------
384    // REMOVE TAGS
385    // -------------------------------
386    if let Some(raw) = &args.untag {
387        let remove_list = parse_tag_list(raw);
388
389        existing.retain(|t| !remove_list.contains(t));
390
391        convo["tags"] = json!(existing);
392        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
393
394        println!(
395            "🏷️ Removed tag(s) [{}] from {}",
396            remove_list.join(", "),
397            &target_tid[..8]
398        );
399        return;
400    }
401
402    // -------------------------------
403    // ADD TAGS
404    // -------------------------------
405    if let Some(raw) = &args.tag {
406        let add_list = parse_tag_list(raw);
407
408        for t in add_list {
409            if !existing.contains(&t) {
410                existing.push(t);
411            }
412        }
413
414        convo["tags"] = json!(existing);
415        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
416
417        println!("🏷️ Updated tags for {}", &target_tid[..8]);
418        return;
419    }
420}
421
422/// Computes total storage: conversation.json + all message JSONs + all markdown attachments.
423fn compute_conversation_size(
424    fur_dir: &Path,
425    tid: &str,
426    msg_ids: &[String],
427) -> u64 {
428    let mut total: u64 = 0;
429
430    // Add main conversation JSON
431    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
432    total += file_size(&convo_path);
433
434    // Add all messages + markdowns
435    total += get_message_file_sizes(fur_dir, msg_ids);
436
437    total
438}
439
440fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
441    let mut total = 0;
442
443    for mid in msg_ids {
444        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
445        total += file_size(&msg_path);
446
447        // Parse JSON to find ONLY message["markdown"]
448        if let Ok(content) = fs::read_to_string(&msg_path) {
449            if let Ok(json) = serde_json::from_str::<Value>(&content) {
450
451                if let Some(md_raw) = json["markdown"].as_str() {
452
453                    // CASE 1: absolute path -> use as-is
454                    let md_path = Path::new(md_raw);
455                    if md_path.is_absolute() {
456                        total += file_size(md_path);
457                        continue;
458                    }
459
460                    // CASE 2: relative path -> resolve relative to project root
461                    let project_root_path = Path::new(".").join(md_raw);
462                    total += file_size(&project_root_path);
463                }
464            }
465        }
466    }
467
468    total
469}
470
471fn file_size(path: &Path) -> u64 {
472    fs::metadata(path).map(|m| m.len()).unwrap_or(0)
473}
474
475pub fn format_size(bytes: u64) -> String {
476    if bytes < 1_048_576 {
477        format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
478    } else {
479        format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
480    }
481}