Skip to main content

fur_cli/commands/
conversation.rs

1use crate::helpers::conversation::{
2    confirm_delete_destructive, confirm_delete_primary, perform_conversation_deletion,
3    resolve_target_thread_id,
4};
5use crate::helpers::tags::parse_tag_list;
6use crate::renderer::table::render_table;
7use chrono::{DateTime, Local, Utc};
8use clap::Parser;
9use serde_json::{json, Value};
10use std::fs;
11use std::path::Path;
12
13/// Arguments for the `conversation` command
14#[derive(Parser)]
15pub struct ThreadArgs {
16    /// Thread ID or prefix to switch
17    pub id: Option<String>,
18
19    /// View all threads
20    #[arg(long)]
21    pub view: bool,
22
23    /// Rename
24    #[arg(long, alias = "rn")]
25    pub rename: Option<String>,
26
27    /// Add tags (comma-separated, supports spaces)
28    #[arg(long)]
29    pub tag: Option<String>,
30
31    #[arg(long)]
32    pub untag: Option<String>,
33
34    /// Clear all tags from conversation
35    #[arg(long)]
36    pub clear_tags: bool,
37
38    /// Delete a conversation (destructive)
39    #[arg(long)]
40    pub delete: bool,
41
42    /// Show all conversations (no truncation)
43    #[arg(long, short = 'a')]
44    pub all: bool,
45}
46
47pub fn run_conversation(args: ThreadArgs) {
48    let fur_dir = Path::new(".fur");
49    let index_path = fur_dir.join("index.json");
50
51    if !index_path.exists() {
52        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
53        return;
54    }
55
56    let mut index: Value = serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
57
58    if args.tag.is_some() || args.untag.is_some() || args.clear_tags {
59        return handle_tagging(&args, &mut index, fur_dir);
60    }
61
62    if args.rename.is_some() {
63        return handle_rename_thread(&mut index, fur_dir, &args);
64    }
65
66    if args.delete {
67        return handle_delete_thread(&mut index, fur_dir, &args);
68    }
69
70    if args.view || args.id.is_none() {
71        return handle_view_threads(&index, fur_dir, &args);
72    }
73
74    if args.id.is_some() {
75        return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
76    }
77}
78
79fn handle_rename_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
80    let new_title = match &args.rename {
81        Some(t) => t,
82        None => return,
83    };
84
85    let empty_vec: Vec<Value> = Vec::new();
86    let threads: Vec<String> = index["threads"]
87        .as_array()
88        .unwrap_or(&empty_vec)
89        .iter()
90        .filter_map(|t| t.as_str().map(|s| s.to_string()))
91        .collect();
92
93    // CASE 1: rename current thread
94    let target_thread_id = if args.id.is_none() {
95        index["active_thread"].as_str().unwrap_or("").to_string()
96    } else {
97        // CASE 2: rename by prefix
98        let prefix = args.id.as_ref().unwrap();
99        let found = threads
100            .iter()
101            .filter(|tid| tid.starts_with(prefix))
102            .collect::<Vec<_>>();
103
104        if found.is_empty() {
105            eprintln!("❌ No conversation matches prefix '{}'", prefix);
106            return;
107        }
108        if found.len() > 1 {
109            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
110            return;
111        }
112
113        found[0].to_string()
114    };
115
116    let convo_path = fur_dir
117        .join("threads")
118        .join(format!("{}.json", target_thread_id));
119    let mut conversation_json: Value =
120        serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
121
122    let old_title = conversation_json["title"]
123        .as_str()
124        .unwrap_or("Untitled")
125        .to_string();
126
127    // Update title
128    conversation_json["title"] = Value::String(new_title.to_string());
129    fs::write(
130        &convo_path,
131        serde_json::to_string_pretty(&conversation_json).unwrap(),
132    )
133    .unwrap();
134
135    println!(
136        "✏️  Renamed conversation {} \"{}\" → \"{}\"",
137        &target_thread_id[..8],
138        old_title,
139        new_title
140    );
141}
142
143fn handle_delete_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
144    let target_tid = match resolve_target_thread_id(index, args) {
145        Some(tid) => tid,
146        None => return,
147    };
148
149    // extract all thread IDs for later index update
150    let empty_vec: Vec<Value> = Vec::new();
151    let threads: Vec<String> = index["threads"]
152        .as_array()
153        .unwrap_or(&empty_vec)
154        .iter()
155        .filter_map(|v| v.as_str().map(|s| s.to_string()))
156        .collect();
157
158    if !confirm_delete_primary() {
159        println!("❌ Deletion aborted.");
160        return;
161    }
162
163    if !confirm_delete_destructive() {
164        println!("❌ Deletion aborted.");
165        return;
166    }
167
168    perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
169}
170
171fn handle_view_threads(index: &Value, fur_dir: &Path, args: &ThreadArgs) {
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    // Apply truncation AFTER rows and active_idx exist
261    if !args.all {
262        rows = truncate_around_active(rows, active_idx);
263
264        // Recalculate active_idx inside truncated rows
265        let active_prefix = &active[..8];
266        active_idx = rows.iter().position(|row| row[0] == active_prefix);
267    }
268
269    // UPDATED HEADERS: now includes TAGS
270    render_table(
271        "Conversations",
272        &["ID", "Title", "Created", "#Msgs", "Size", "Tags"],
273        rows,
274        active_idx,
275    );
276
277    println!("----------------------------");
278    println!("Total Memory Used: {}", format_size(total_size_bytes));
279}
280
281fn truncate_around_active(rows: Vec<Vec<String>>, active_idx: Option<usize>) -> Vec<Vec<String>> {
282    if active_idx.is_none() {
283        return rows;
284    }
285    let i = active_idx.unwrap();
286    let win = 3;
287
288    let start = i.saturating_sub(win);
289    let end = (i + win).min(rows.len() - 1);
290
291    let mut out = Vec::new();
292
293    if start > 0 {
294        out.push(vec!["...".into(); rows[0].len()]);
295    }
296
297    for idx in start..=end {
298        out.push(rows[idx].clone());
299    }
300
301    if end < rows.len() - 1 {
302        out.push(vec!["...".into(); rows[0].len()]);
303    }
304
305    out
306}
307
308fn handle_switch_thread(index: &mut Value, index_path: &Path, fur_dir: &Path, args: &ThreadArgs) {
309    let tid = match &args.id {
310        Some(id) => id,
311        None => return,
312    };
313
314    let empty_vec: Vec<Value> = Vec::new();
315    let threads: Vec<String> = index["threads"]
316        .as_array()
317        .unwrap_or(&empty_vec)
318        .iter()
319        .filter_map(|t| t.as_str().map(|s| s.to_string()))
320        .collect();
321
322    let mut found = threads.iter().find(|&s| s == tid);
323
324    if found.is_none() {
325        let matches: Vec<&String> = threads.iter().filter(|s| s.starts_with(tid)).collect();
326
327        if matches.len() == 1 {
328            found = Some(matches[0]);
329        } else if matches.len() > 1 {
330            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
331            return;
332        }
333    }
334
335    let tid_full = match found {
336        Some(s) => s,
337        None => {
338            eprintln!("❌ Thread not found: {}", tid);
339            return;
340        }
341    };
342
343    index["active_thread"] = json!(tid_full);
344    index["current_message"] = serde_json::Value::Null;
345
346    fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
347
348    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
349    let content = fs::read_to_string(convo_path).unwrap();
350    let conversation_json: Value = serde_json::from_str(&content).unwrap();
351    let title = conversation_json["title"].as_str().unwrap_or("Untitled");
352
353    println!(
354        "✔️ Switched active conversation to {} \"{}\"",
355        &tid_full[..8],
356        title
357    );
358}
359
360fn handle_tagging(args: &ThreadArgs, index: &mut Value, fur_dir: &Path) {
361    let empty_vec: Vec<Value> = Vec::new();
362    let threads: Vec<String> = index["threads"]
363        .as_array()
364        .unwrap_or(&empty_vec)
365        .iter()
366        .filter_map(|t| t.as_str().map(|s| s.to_string()))
367        .collect();
368
369    // Determine which conversation to operate on
370    let target_tid = if let Some(prefix) = &args.id {
371        let matches: Vec<&String> = threads
372            .iter()
373            .filter(|tid| tid.starts_with(prefix))
374            .collect();
375
376        if matches.is_empty() {
377            eprintln!("❌ No conversation matches '{}'", prefix);
378            return;
379        }
380        if matches.len() > 1 {
381            eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
382            return;
383        }
384        matches[0].clone()
385    } else {
386        index["active_thread"].as_str().unwrap_or("").to_string()
387    };
388
389    let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
390    let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
391
392    // -------------------------------
393    // CLEAR ALL TAGS
394    // -------------------------------
395    if args.clear_tags {
396        convo["tags"] = json!([]);
397        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
398        println!("🏷️ Cleared tags for {}", &target_tid[..8]);
399        return;
400    }
401
402    // Load existing tags
403    let mut existing: Vec<String> = convo["tags"]
404        .as_array()
405        .unwrap_or(&vec![])
406        .iter()
407        .filter_map(|v| v.as_str().map(|s| s.to_string()))
408        .collect();
409
410    // -------------------------------
411    // REMOVE TAGS
412    // -------------------------------
413    if let Some(raw) = &args.untag {
414        let remove_list = parse_tag_list(raw);
415
416        existing.retain(|t| !remove_list.contains(t));
417
418        convo["tags"] = json!(existing);
419        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
420
421        println!(
422            "🏷️ Removed tag(s) [{}] from {}",
423            remove_list.join(", "),
424            &target_tid[..8]
425        );
426        return;
427    }
428
429    // -------------------------------
430    // ADD TAGS
431    // -------------------------------
432    if let Some(raw) = &args.tag {
433        let add_list = parse_tag_list(raw);
434
435        for t in add_list {
436            if !existing.contains(&t) {
437                existing.push(t);
438            }
439        }
440
441        convo["tags"] = json!(existing);
442        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
443
444        println!("🏷️ Updated tags for {}", &target_tid[..8]);
445        return;
446    }
447}
448
449/// Computes total storage: conversation.json + all message JSONs + all markdown attachments.
450fn compute_conversation_size(fur_dir: &Path, tid: &str, msg_ids: &[String]) -> u64 {
451    let mut total: u64 = 0;
452
453    // Add main conversation JSON
454    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
455    total += file_size(&convo_path);
456
457    // Add all messages + markdowns
458    total += get_message_file_sizes(fur_dir, msg_ids);
459
460    total
461}
462
463fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
464    let mut total = 0;
465
466    for mid in msg_ids {
467        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
468        total += file_size(&msg_path);
469
470        // Parse JSON to find ONLY message["markdown"]
471        if let Ok(content) = fs::read_to_string(&msg_path) {
472            if let Ok(json) = serde_json::from_str::<Value>(&content) {
473                if let Some(md_raw) = json["markdown"].as_str() {
474                    // CASE 1: absolute path -> use as-is
475                    let md_path = Path::new(md_raw);
476                    if md_path.is_absolute() {
477                        total += file_size(md_path);
478                        continue;
479                    }
480
481                    // CASE 2: relative path -> resolve relative to project root
482                    let project_root_path = Path::new(".").join(md_raw);
483                    total += file_size(&project_root_path);
484                }
485            }
486        }
487    }
488
489    total
490}
491
492fn file_size(path: &Path) -> u64 {
493    fs::metadata(path).map(|m| m.len()).unwrap_or(0)
494}
495
496pub fn format_size(bytes: u64) -> String {
497    if bytes < 1_048_576 {
498        format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
499    } else {
500        format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
501    }
502}