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    crate::schema::bridge::sync_active();
143}
144
145fn handle_delete_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
146    let target_tid = match resolve_target_thread_id(index, args) {
147        Some(tid) => tid,
148        None => return,
149    };
150
151    // extract all thread IDs for later index update
152    let empty_vec: Vec<Value> = Vec::new();
153    let threads: Vec<String> = index["threads"]
154        .as_array()
155        .unwrap_or(&empty_vec)
156        .iter()
157        .filter_map(|v| v.as_str().map(|s| s.to_string()))
158        .collect();
159
160    if !confirm_delete_primary() {
161        println!("❌ Deletion aborted.");
162        return;
163    }
164
165    if !confirm_delete_destructive() {
166        println!("❌ Deletion aborted.");
167        return;
168    }
169
170    perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
171}
172
173fn handle_view_threads(index: &Value, fur_dir: &Path, args: &ThreadArgs) {
174    if !(args.view || args.id.is_none()) {
175        return;
176    }
177
178    let empty_vec: Vec<Value> = Vec::new();
179    let threads = index["threads"].as_array().unwrap_or(&empty_vec);
180    let active = index["active_thread"].as_str().unwrap_or("");
181
182    let mut rows = Vec::new();
183    let mut active_idx = None;
184
185    let mut total_size_bytes: u64 = 0;
186    let mut conversation_info = Vec::new();
187
188    for tid in threads {
189        if let Some(tid_str) = tid.as_str() {
190            let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
191
192            if let Ok(content) = fs::read_to_string(&convo_path) {
193                if let Ok(convo) = serde_json::from_str::<Value>(&content) {
194                    let title = convo["title"].as_str().unwrap_or("Untitled").to_string();
195                    let created_raw = convo["created_at"].as_str().unwrap_or("");
196
197                    let msg_ids = convo["messages"]
198                        .as_array()
199                        .map(|a| {
200                            a.iter()
201                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
202                                .collect::<Vec<_>>()
203                        })
204                        .unwrap_or_default();
205
206                    let msg_count = msg_ids.len();
207
208                    let parsed = DateTime::parse_from_rfc3339(created_raw)
209                        .map(|dt| dt.with_timezone(&Utc))
210                        .unwrap_or_else(|_| Utc::now());
211
212                    let local: DateTime<Local> = DateTime::from(parsed);
213                    let date_str = local.format("%Y-%m-%d").to_string();
214                    let time_str = local.format("%H:%M").to_string();
215
216                    let size_bytes = compute_conversation_size(fur_dir, tid_str, &msg_ids);
217                    total_size_bytes += size_bytes;
218
219                    let tags_str = convo["tags"]
220                        .as_array()
221                        .unwrap_or(&vec![])
222                        .iter()
223                        .filter_map(|v| v.as_str())
224                        .collect::<Vec<_>>()
225                        .join(", ");
226
227                    conversation_info.push((
228                        tid_str.to_string(),
229                        title,
230                        date_str,
231                        time_str,
232                        msg_count,
233                        parsed,
234                        format_size(size_bytes),
235                        tags_str,
236                    ));
237                }
238            }
239        }
240    }
241
242    // Sort newest first
243    conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
244
245    for (i, (tid, title, date, time, msg_count, _, size_str, tags_str)) in
246        conversation_info.iter().enumerate()
247    {
248        rows.push(vec![
249            tid[..8].to_string(),
250            title.to_string(),
251            format!("{} | {}", date, time),
252            msg_count.to_string(),
253            size_str.to_string(),
254            tags_str.to_string(),
255        ]);
256
257        if tid == active {
258            active_idx = Some(i);
259        }
260    }
261
262    // Apply truncation AFTER rows and active_idx exist
263    if !args.all {
264        rows = truncate_around_active(rows, active_idx);
265
266        // Recalculate active_idx inside truncated rows
267        let active_prefix = &active[..8];
268        active_idx = rows.iter().position(|row| row[0] == active_prefix);
269    }
270
271    // UPDATED HEADERS: now includes TAGS
272    render_table(
273        "Conversations",
274        &["ID", "Title", "Created", "#Msgs", "Size", "Tags"],
275        rows,
276        active_idx,
277    );
278
279    println!("----------------------------");
280    println!("Total Memory Used: {}", format_size(total_size_bytes));
281}
282
283fn truncate_around_active(rows: Vec<Vec<String>>, active_idx: Option<usize>) -> Vec<Vec<String>> {
284    if active_idx.is_none() {
285        return rows;
286    }
287    let i = active_idx.unwrap();
288    let win = 3;
289
290    let start = i.saturating_sub(win);
291    let end = (i + win).min(rows.len() - 1);
292
293    let mut out = Vec::new();
294
295    if start > 0 {
296        out.push(vec!["...".into(); rows[0].len()]);
297    }
298
299    for idx in start..=end {
300        out.push(rows[idx].clone());
301    }
302
303    if end < rows.len() - 1 {
304        out.push(vec!["...".into(); rows[0].len()]);
305    }
306
307    out
308}
309
310fn handle_switch_thread(index: &mut Value, index_path: &Path, fur_dir: &Path, args: &ThreadArgs) {
311    let tid = match &args.id {
312        Some(id) => id,
313        None => return,
314    };
315
316    let empty_vec: Vec<Value> = Vec::new();
317    let threads: Vec<String> = index["threads"]
318        .as_array()
319        .unwrap_or(&empty_vec)
320        .iter()
321        .filter_map(|t| t.as_str().map(|s| s.to_string()))
322        .collect();
323
324    let mut found = threads.iter().find(|&s| s == tid);
325
326    if found.is_none() {
327        let matches: Vec<&String> = threads.iter().filter(|s| s.starts_with(tid)).collect();
328
329        if matches.len() == 1 {
330            found = Some(matches[0]);
331        } else if matches.len() > 1 {
332            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
333            return;
334        }
335    }
336
337    let tid_full = match found {
338        Some(s) => s,
339        None => {
340            eprintln!("❌ Thread not found: {}", tid);
341            return;
342        }
343    };
344
345    index["active_thread"] = json!(tid_full);
346    index["current_message"] = serde_json::Value::Null;
347
348    fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
349
350    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
351    let content = fs::read_to_string(convo_path).unwrap();
352    let conversation_json: Value = serde_json::from_str(&content).unwrap();
353    let title = conversation_json["title"].as_str().unwrap_or("Untitled");
354
355    println!(
356        "✔️ Switched active conversation to {} \"{}\"",
357        &tid_full[..8],
358        title
359    );
360}
361
362fn handle_tagging(args: &ThreadArgs, index: &mut Value, fur_dir: &Path) {
363    let empty_vec: Vec<Value> = Vec::new();
364    let threads: Vec<String> = index["threads"]
365        .as_array()
366        .unwrap_or(&empty_vec)
367        .iter()
368        .filter_map(|t| t.as_str().map(|s| s.to_string()))
369        .collect();
370
371    // Determine which conversation to operate on
372    let target_tid = if let Some(prefix) = &args.id {
373        let matches: Vec<&String> = threads
374            .iter()
375            .filter(|tid| tid.starts_with(prefix))
376            .collect();
377
378        if matches.is_empty() {
379            eprintln!("❌ No conversation matches '{}'", prefix);
380            return;
381        }
382        if matches.len() > 1 {
383            eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
384            return;
385        }
386        matches[0].clone()
387    } else {
388        index["active_thread"].as_str().unwrap_or("").to_string()
389    };
390
391    let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
392    let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
393
394    // -------------------------------
395    // CLEAR ALL TAGS
396    // -------------------------------
397    if args.clear_tags {
398        convo["tags"] = json!([]);
399        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
400        println!("🏷️ Cleared tags for {}", &target_tid[..8]);
401        return;
402    }
403
404    // Load existing tags
405    let mut existing: Vec<String> = convo["tags"]
406        .as_array()
407        .unwrap_or(&vec![])
408        .iter()
409        .filter_map(|v| v.as_str().map(|s| s.to_string()))
410        .collect();
411
412    // -------------------------------
413    // REMOVE TAGS
414    // -------------------------------
415    if let Some(raw) = &args.untag {
416        let remove_list = parse_tag_list(raw);
417
418        existing.retain(|t| !remove_list.contains(t));
419
420        convo["tags"] = json!(existing);
421        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
422
423        println!(
424            "🏷️ Removed tag(s) [{}] from {}",
425            remove_list.join(", "),
426            &target_tid[..8]
427        );
428        return;
429    }
430
431    // -------------------------------
432    // ADD TAGS
433    // -------------------------------
434    if let Some(raw) = &args.tag {
435        let add_list = parse_tag_list(raw);
436
437        for t in add_list {
438            if !existing.contains(&t) {
439                existing.push(t);
440            }
441        }
442
443        convo["tags"] = json!(existing);
444        fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
445
446        println!("🏷️ Updated tags for {}", &target_tid[..8]);
447        return;
448    }
449}
450
451/// Computes total storage: conversation.json + all message JSONs + all markdown attachments.
452fn compute_conversation_size(fur_dir: &Path, tid: &str, msg_ids: &[String]) -> u64 {
453    let mut total: u64 = 0;
454
455    // Add main conversation JSON
456    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
457    total += file_size(&convo_path);
458
459    // Add all messages + markdowns
460    total += get_message_file_sizes(fur_dir, msg_ids);
461
462    total
463}
464
465fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
466    let mut total = 0;
467
468    for mid in msg_ids {
469        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
470        total += file_size(&msg_path);
471
472        // Parse JSON to find ONLY message["markdown"]
473        if let Ok(content) = fs::read_to_string(&msg_path) {
474            if let Ok(json) = serde_json::from_str::<Value>(&content) {
475                if let Some(md_raw) = json["markdown"].as_str() {
476                    // CASE 1: absolute path -> use as-is
477                    let md_path = Path::new(md_raw);
478                    if md_path.is_absolute() {
479                        total += file_size(md_path);
480                        continue;
481                    }
482
483                    // CASE 2: relative path -> resolve relative to project root
484                    let project_root_path = Path::new(".").join(md_raw);
485                    total += file_size(&project_root_path);
486                }
487            }
488        }
489    }
490
491    total
492}
493
494fn file_size(path: &Path) -> u64 {
495    fs::metadata(path).map(|m| m.len()).unwrap_or(0)
496}
497
498pub fn format_size(bytes: u64) -> String {
499    if bytes < 1_048_576 {
500        format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
501    } else {
502        format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
503    }
504}