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