Skip to main content

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