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 crate::schema::lineage::Lineage;
8use chrono::{DateTime, Local, Utc};
9use clap::Parser;
10use colored::*;
11use serde_json::{json, Value};
12use std::collections::HashMap;
13use std::fs;
14use std::path::Path;
15
16/// Arguments for the `conversation` command
17#[derive(Parser)]
18pub struct ThreadArgs {
19    /// Thread ID or prefix to switch
20    pub id: Option<String>,
21
22    /// View all threads
23    #[arg(long)]
24    pub view: bool,
25
26    /// Rename
27    #[arg(long, alias = "rn")]
28    pub rename: Option<String>,
29
30    /// Add tags (comma-separated, supports spaces)
31    #[arg(long)]
32    pub tag: Option<String>,
33
34    #[arg(long)]
35    pub untag: Option<String>,
36
37    /// Clear all tags from conversation
38    #[arg(long)]
39    pub clear_tags: bool,
40
41    /// Delete a conversation (destructive)
42    #[arg(long)]
43    pub delete: bool,
44
45    /// Show all conversations (no truncation)
46    #[arg(long, short = 'a')]
47    pub all: bool,
48
49    /// List conversations newest-first, ignoring lineage
50    #[arg(long)]
51    pub flat: bool,
52}
53
54/// One conversation's display data, gathered once and reused by both views.
55struct ConvoInfo {
56    id: String,
57    title: String,
58    created: DateTime<Utc>,
59    date_str: String,
60    time_str: String,
61    msg_count: usize,
62    size_bytes: u64,
63    tags: String,
64}
65
66pub fn run_conversation(args: ThreadArgs) {
67    let fur_dir = Path::new(".fur");
68    let index_path = fur_dir.join("index.json");
69
70    if !index_path.exists() {
71        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
72        return;
73    }
74
75    let mut index: Value = serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
76
77    if args.tag.is_some() || args.untag.is_some() || args.clear_tags {
78        return handle_tagging(&args, &mut index, fur_dir);
79    }
80
81    if args.rename.is_some() {
82        return handle_rename_thread(&mut index, fur_dir, &args);
83    }
84
85    if args.delete {
86        return handle_delete_thread(&mut index, fur_dir, &args);
87    }
88
89    if args.view || args.id.is_none() {
90        return handle_view_threads(&index, fur_dir, &args);
91    }
92
93    if args.id.is_some() {
94        return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
95    }
96}
97
98fn handle_rename_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
99    let new_title = match &args.rename {
100        Some(t) => t,
101        None => return,
102    };
103
104    let empty_vec: Vec<Value> = Vec::new();
105    let threads: Vec<String> = index["threads"]
106        .as_array()
107        .unwrap_or(&empty_vec)
108        .iter()
109        .filter_map(|t| t.as_str().map(|s| s.to_string()))
110        .collect();
111
112    // CASE 1: rename current thread
113    let target_thread_id = if args.id.is_none() {
114        index["active_thread"].as_str().unwrap_or("").to_string()
115    } else {
116        // CASE 2: rename by prefix
117        let prefix = args.id.as_ref().unwrap();
118        let found = threads
119            .iter()
120            .filter(|tid| tid.starts_with(prefix))
121            .collect::<Vec<_>>();
122
123        if found.is_empty() {
124            eprintln!("❌ No conversation matches prefix '{}'", prefix);
125            return;
126        }
127        if found.len() > 1 {
128            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
129            return;
130        }
131
132        found[0].to_string()
133    };
134
135    let convo_path = fur_dir
136        .join("threads")
137        .join(format!("{}.json", target_thread_id));
138    let mut conversation_json: Value =
139        serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
140
141    let old_title = conversation_json["title"]
142        .as_str()
143        .unwrap_or("Untitled")
144        .to_string();
145
146    // Update title
147    conversation_json["title"] = Value::String(new_title.to_string());
148    fs::write(
149        &convo_path,
150        serde_json::to_string_pretty(&conversation_json).unwrap(),
151    )
152    .unwrap();
153
154    println!(
155        "✏️  Renamed conversation {} \"{}\" → \"{}\"",
156        &target_thread_id[..8],
157        old_title,
158        new_title
159    );
160
161    crate::schema::bridge::sync_active();
162}
163
164fn handle_delete_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
165    let target_tid = match resolve_target_thread_id(index, args) {
166        Some(tid) => tid,
167        None => return,
168    };
169
170    // extract all thread IDs for later index update
171    let empty_vec: Vec<Value> = Vec::new();
172    let threads: Vec<String> = index["threads"]
173        .as_array()
174        .unwrap_or(&empty_vec)
175        .iter()
176        .filter_map(|v| v.as_str().map(|s| s.to_string()))
177        .collect();
178
179    if !confirm_delete_primary() {
180        println!("❌ Deletion aborted.");
181        return;
182    }
183
184    if !confirm_delete_destructive() {
185        println!("❌ Deletion aborted.");
186        return;
187    }
188
189    perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
190}
191
192// ======================================================
193//  VIEW
194// ======================================================
195
196fn handle_view_threads(index: &Value, fur_dir: &Path, args: &ThreadArgs) {
197    if !(args.view || args.id.is_none()) {
198        return;
199    }
200
201    let active = index["active_thread"].as_str().unwrap_or("");
202    let infos = collect_infos(index, fur_dir);
203
204    if infos.is_empty() {
205        println!("📭 No conversations yet. Run `fur new \"Title\"`.");
206        return;
207    }
208
209    let total_size_bytes: u64 = infos.iter().map(|i| i.size_bytes).sum();
210
211    // Neither view truncates. `--all` is kept as an accepted no-op so existing
212    // muscle memory and scripts do not break.
213    let _ = args.all;
214
215    if args.flat {
216        render_flat(&infos, active);
217    } else {
218        render_tree(&infos, active, fur_dir);
219    }
220
221    println!("----------------------------");
222    println!("Total Memory Used: {}", format_size(total_size_bytes));
223}
224
225/// Read every conversation named by the index, newest first.
226fn collect_infos(index: &Value, fur_dir: &Path) -> Vec<ConvoInfo> {
227    let empty_vec: Vec<Value> = Vec::new();
228    let threads = index["threads"].as_array().unwrap_or(&empty_vec);
229
230    let mut infos = Vec::new();
231
232    for tid in threads {
233        let Some(tid_str) = tid.as_str() else {
234            continue;
235        };
236
237        let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
238        let Ok(content) = fs::read_to_string(&convo_path) else {
239            continue;
240        };
241        let Ok(convo) = serde_json::from_str::<Value>(&content) else {
242            continue;
243        };
244
245        let msg_ids: Vec<String> = convo["messages"]
246            .as_array()
247            .map(|a| {
248                a.iter()
249                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
250                    .collect::<Vec<_>>()
251            })
252            .unwrap_or_default();
253
254        let created = DateTime::parse_from_rfc3339(convo["created_at"].as_str().unwrap_or(""))
255            .map(|dt| dt.with_timezone(&Utc))
256            .unwrap_or_else(|_| Utc::now());
257
258        let local: DateTime<Local> = DateTime::from(created);
259
260        infos.push(ConvoInfo {
261            id: tid_str.to_string(),
262            title: convo["title"].as_str().unwrap_or("Untitled").to_string(),
263            created,
264            date_str: local.format("%Y-%m-%d").to_string(),
265            time_str: local.format("%H:%M").to_string(),
266            msg_count: msg_ids.len(),
267            size_bytes: compute_conversation_size(fur_dir, tid_str, &msg_ids),
268            tags: convo["tags"]
269                .as_array()
270                .unwrap_or(&vec![])
271                .iter()
272                .filter_map(|v| v.as_str())
273                .collect::<Vec<_>>()
274                .join(", "),
275        });
276    }
277
278    infos.sort_by(|a, b| b.created.cmp(&a.created));
279    infos
280}
281
282fn row_for(info: &ConvoInfo, title: String) -> Vec<String> {
283    vec![
284        info.id[..8].to_string(),
285        title,
286        format!("{} | {}", info.date_str, info.time_str),
287        info.msg_count.to_string(),
288        format_size(info.size_bytes),
289        info.tags.clone(),
290    ]
291}
292
293const HEADERS: [&str; 6] = ["ID", "Title", "Created", "#Msgs", "Size", "Tags"];
294
295/// Newest-first list with no nesting — the pre-lineage view.
296fn render_flat(infos: &[ConvoInfo], active: &str) {
297    let rows: Vec<Vec<String>> = infos
298        .iter()
299        .map(|info| row_for(info, info.title.clone()))
300        .collect();
301
302    let active_idx = infos.iter().position(|i| i.id == active);
303
304    render_table("Conversations", &HEADERS, rows, active_idx, true);
305}
306
307/// Lineage view: parents at the margin, children indented beneath them.
308///
309/// Conversations with no edges sit flat at the margin exactly as they do in the
310/// flat view, so an archive that never uses linking looks unchanged.
311fn render_tree(infos: &[ConvoInfo], active: &str, fur_dir: &Path) {
312    let lineage = match Lineage::load(fur_dir) {
313        Ok(l) => l,
314        Err(e) => {
315            eprintln!("⚠ lineage unavailable ({}); showing a flat list", e);
316            return render_flat(infos, active);
317        }
318    };
319
320    if lineage.is_empty() {
321        return render_flat(infos, active);
322    }
323
324    let by_id: HashMap<&str, &ConvoInfo> = infos.iter().map(|i| (i.id.as_str(), i)).collect();
325    let order: Vec<String> = infos.iter().map(|i| i.id.clone()).collect();
326
327    let mut rows = Vec::new();
328    let mut active_idx = None;
329    let mut any_repeat = false;
330    let mut any_orphan = false;
331
332    for entry in lineage.forest(&order) {
333        let Some(info) = by_id.get(entry.id.as_str()) else {
334            continue;
335        };
336
337        let indent = if entry.depth == 0 {
338            String::new()
339        } else {
340            format!("{}└─ ", "   ".repeat(entry.depth - 1))
341        };
342
343        let mut title = format!("{}{}", indent, info.title);
344
345        if entry.orphan_parent {
346            any_orphan = true;
347            title.push_str(" ↑");
348        }
349
350        if entry.repeat {
351            any_repeat = true;
352            title.push_str(" (above)");
353        }
354
355        if entry.id == active && !entry.repeat {
356            active_idx = Some(rows.len());
357        }
358
359        rows.push(row_for(info, title));
360    }
361
362    render_table("Conversations", &HEADERS, rows, active_idx, false);
363
364    if any_orphan {
365        println!(
366            "{}",
367            "↑ linked to a conversation that is not in this project".bright_black()
368        );
369    }
370    if any_repeat {
371        println!(
372            "{}",
373            "(above) listed under another parent as well".bright_black()
374        );
375    }
376}
377
378// ======================================================
379//  SWITCH / TAG
380// ======================================================
381
382fn handle_switch_thread(index: &mut Value, index_path: &Path, fur_dir: &Path, args: &ThreadArgs) {
383    let tid = match &args.id {
384        Some(id) => id,
385        None => return,
386    };
387
388    let empty_vec: Vec<Value> = Vec::new();
389    let threads: Vec<String> = index["threads"]
390        .as_array()
391        .unwrap_or(&empty_vec)
392        .iter()
393        .filter_map(|t| t.as_str().map(|s| s.to_string()))
394        .collect();
395
396    let mut found = threads.iter().find(|&s| s == tid);
397
398    if found.is_none() {
399        let matches: Vec<&String> = threads.iter().filter(|s| s.starts_with(tid)).collect();
400
401        if matches.len() == 1 {
402            found = Some(matches[0]);
403        } else if matches.len() > 1 {
404            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
405            return;
406        }
407    }
408
409    let tid_full = match found {
410        Some(s) => s,
411        None => {
412            eprintln!("❌ Thread not found: {}", tid);
413            return;
414        }
415    };
416
417    index["active_thread"] = json!(tid_full);
418    index["current_message"] = serde_json::Value::Null;
419
420    fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
421
422    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
423    let content = fs::read_to_string(convo_path).unwrap();
424    let conversation_json: Value = serde_json::from_str(&content).unwrap();
425    let title = conversation_json["title"].as_str().unwrap_or("Untitled");
426
427    println!(
428        "✔️ Switched active conversation to {} \"{}\"",
429        &tid_full[..8],
430        title
431    );
432}
433
434fn handle_tagging(args: &ThreadArgs, index: &mut Value, fur_dir: &Path) {
435    let empty_vec: Vec<Value> = Vec::new();
436    let threads: Vec<String> = index["threads"]
437        .as_array()
438        .unwrap_or(&empty_vec)
439        .iter()
440        .filter_map(|t| t.as_str().map(|s| s.to_string()))
441        .collect();
442
443    // Determine which conversation to operate on
444    let target_tid = if let Some(prefix) = &args.id {
445        let matches: Vec<&String> = threads
446            .iter()
447            .filter(|tid| tid.starts_with(prefix))
448            .collect();
449
450        if matches.is_empty() {
451            eprintln!("❌ No conversation matches '{}'", prefix);
452            return;
453        }
454        if matches.len() > 1 {
455            eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
456            return;
457        }
458        matches[0].clone()
459    } else {
460        index["active_thread"].as_str().unwrap_or("").to_string()
461    };
462
463    let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
464    let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
465
466    // -------------------------------
467    // CLEAR ALL TAGS
468    // -------------------------------
469    if args.clear_tags {
470        convo["tags"] = json!([]);
471        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
472        println!("🏷️ Cleared tags for {}", &target_tid[..8]);
473        crate::schema::bridge::sync_active();
474        return;
475    }
476
477    // Load existing tags
478    let mut existing: Vec<String> = convo["tags"]
479        .as_array()
480        .unwrap_or(&vec![])
481        .iter()
482        .filter_map(|v| v.as_str().map(|s| s.to_string()))
483        .collect();
484
485    // -------------------------------
486    // REMOVE TAGS
487    // -------------------------------
488    if let Some(raw) = &args.untag {
489        let remove_list = parse_tag_list(raw);
490
491        existing.retain(|t| !remove_list.contains(t));
492
493        convo["tags"] = json!(existing);
494        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
495
496        println!(
497            "🏷️ Removed tag(s) [{}] from {}",
498            remove_list.join(", "),
499            &target_tid[..8]
500        );
501        crate::schema::bridge::sync_active();
502        return;
503    }
504
505    // -------------------------------
506    // ADD TAGS
507    // -------------------------------
508    if let Some(raw) = &args.tag {
509        let add_list = parse_tag_list(raw);
510
511        for t in add_list {
512            if !existing.contains(&t) {
513                existing.push(t);
514            }
515        }
516
517        convo["tags"] = json!(existing);
518        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
519
520        println!("🏷️ Updated tags for {}", &target_tid[..8]);
521        crate::schema::bridge::sync_active();
522    }
523}
524
525// ======================================================
526//  SIZE
527// ======================================================
528
529/// Computes total storage: conversation.json + all message JSONs + all markdown attachments.
530fn compute_conversation_size(fur_dir: &Path, tid: &str, msg_ids: &[String]) -> u64 {
531    let mut total: u64 = 0;
532
533    // Add main conversation JSON
534    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
535    total += file_size(&convo_path);
536
537    // Add all messages + markdowns
538    total += get_message_file_sizes(fur_dir, msg_ids);
539
540    total
541}
542
543fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
544    let mut total = 0;
545
546    for mid in msg_ids {
547        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
548        total += file_size(&msg_path);
549
550        // Parse JSON to find ONLY message["markdown"]
551        if let Ok(content) = fs::read_to_string(&msg_path) {
552            if let Ok(json) = serde_json::from_str::<Value>(&content) {
553                if let Some(md_raw) = json["markdown"].as_str() {
554                    // CASE 1: absolute path -> use as-is
555                    let md_path = Path::new(md_raw);
556                    if md_path.is_absolute() {
557                        total += file_size(md_path);
558                        continue;
559                    }
560
561                    // CASE 2: relative path -> resolve relative to project root
562                    let project_root_path = Path::new(".").join(md_raw);
563                    total += file_size(&project_root_path);
564                }
565            }
566        }
567    }
568
569    total
570}
571
572fn file_size(path: &Path) -> u64 {
573    fs::metadata(path).map(|m| m.len()).unwrap_or(0)
574}
575
576pub fn format_size(bytes: u64) -> String {
577    if bytes < 1_048_576 {
578        format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
579    } else {
580        format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
581    }
582}