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;
7
8/// Arguments for the `conversation` command
9#[derive(Parser)]
10pub struct ThreadArgs {
11    /// Thread ID or prefix to switch
12    pub id: Option<String>,
13
14    /// View all threads
15    #[arg(long)]
16    pub view: bool,
17
18    /// Rename
19    #[arg(long, alias = "rn")]
20    pub rename: Option<String>,
21}
22
23pub fn run_conversation(args: ThreadArgs) {
24    let fur_dir = Path::new(".fur");
25    let index_path = fur_dir.join("index.json");
26
27    if !index_path.exists() {
28        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
29        return;
30    }
31
32    let mut index: Value =
33        serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
34
35    if args.rename.is_some() {
36        return handle_rename_thread(&mut index, fur_dir, &args);
37    }
38
39    if args.view || args.id.is_none() {
40        return handle_view_threads(&index, fur_dir, &args);
41    }
42
43    if args.id.is_some() {
44        return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
45    }
46}
47
48fn handle_rename_thread(
49    index: &mut Value,
50    fur_dir: &Path,
51    args: &ThreadArgs,
52) {
53    let new_title = match &args.rename {
54        Some(t) => t,
55        None => return,
56    };
57
58    let empty_vec: Vec<Value> = Vec::new();
59    let threads: Vec<String> = index["threads"]
60        .as_array()
61        .unwrap_or(&empty_vec)
62        .iter()
63        .filter_map(|t| t.as_str().map(|s| s.to_string()))
64        .collect();
65
66    // CASE 1: rename current thread
67    let target_thread_id = if args.id.is_none() {
68        index["active_thread"].as_str().unwrap_or("").to_string()
69    } else {
70        // CASE 2: rename by prefix
71        let prefix = args.id.as_ref().unwrap();
72        let found = threads
73            .iter()
74            .filter(|tid| tid.starts_with(prefix))
75            .collect::<Vec<_>>();
76
77        if found.is_empty() {
78            eprintln!("❌ No conversation matches prefix '{}'", prefix);
79            return;
80        }
81        if found.len() > 1 {
82            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
83            return;
84        }
85
86        found[0].to_string()
87    };
88
89    let convo_path = fur_dir.join("threads").join(format!("{}.json", target_thread_id));
90    let mut conversation_json: Value =
91        serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
92
93    let old_title = conversation_json["title"].as_str().unwrap_or("Untitled").to_string();
94
95    // Update title
96    conversation_json["title"] = Value::String(new_title.to_string());
97    fs::write(&convo_path, serde_json::to_string_pretty(&conversation_json).unwrap()).unwrap();
98
99    println!(
100        "✏️  Renamed conversation {} \"{}\" → \"{}\"",
101        &target_thread_id[..8],
102        old_title,
103        new_title
104    );
105}
106
107
108fn handle_view_threads(
109    index: &Value,
110    fur_dir: &Path,
111    args: &ThreadArgs,
112) {
113    if !(args.view || args.id.is_none()) {
114        return;
115    }
116
117    let empty_vec: Vec<Value> = Vec::new();
118    let threads = index["threads"].as_array().unwrap_or(&empty_vec);
119    let active = index["active_thread"].as_str().unwrap_or("");
120
121    let mut rows = Vec::new();
122    let mut active_idx = None;
123
124    let mut total_size_bytes: u64 = 0;
125    let mut conversation_info = Vec::new();
126
127    for tid in threads {
128        if let Some(tid_str) = tid.as_str() {
129            let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
130
131            if let Ok(content) = fs::read_to_string(&convo_path) {
132                if let Ok(convo) = serde_json::from_str::<Value>(&content) {
133                    let title = convo["title"].as_str().unwrap_or("Untitled").to_string();
134                    let created_raw = convo["created_at"].as_str().unwrap_or("");
135
136                    let msg_ids = convo["messages"]
137                        .as_array()
138                        .map(|a| {
139                            a.iter()
140                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
141                                .collect::<Vec<_>>()
142                        })
143                        .unwrap_or_default();
144
145                    let msg_count = msg_ids.len();
146                    let parsed = DateTime::parse_from_rfc3339(created_raw)
147                        .map(|dt| dt.with_timezone(&Utc))
148                        .unwrap_or_else(|_| Utc::now());
149
150                    let local: DateTime<Local> = DateTime::from(parsed);
151                    let date_str = local.format("%Y-%m-%d").to_string();
152                    let time_str = local.format("%H:%M").to_string();
153
154                    let size_bytes = compute_conversation_size(fur_dir, tid_str, &msg_ids);
155                    total_size_bytes += size_bytes;
156
157                    conversation_info.push((
158                        tid_str.to_string(),
159                        title,
160                        date_str,
161                        time_str,
162                        msg_count,
163                        parsed,
164                        format_size(size_bytes),
165                    ));
166                }
167            }
168        }
169    }
170
171    // Sort newest first
172    conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
173
174    for (i, (tid, title, date, time, msg_count, _, size_str)) in
175        conversation_info.iter().enumerate()
176    {
177        rows.push(vec![
178            tid[..8].to_string(),
179            title.to_string(),
180            format!("{} | {}", date, time),
181            msg_count.to_string(),
182            size_str.to_string(),
183        ]);
184
185        if tid == active {
186            active_idx = Some(i);
187        }
188    }
189
190    render_table(
191        "Threads",
192        &["ID", "Title", "Created", "#Msgs", "Size"],
193        rows,
194        active_idx,
195    );
196
197    println!("----------------------------");
198    println!("Total Memory Used: {}", format_size(total_size_bytes));
199}
200
201fn handle_switch_thread(
202    index: &mut Value,
203    index_path: &Path,
204    fur_dir: &Path,
205    args: &ThreadArgs,
206) {
207    let tid = match &args.id {
208        Some(id) => id,
209        None => return,
210    };
211
212    let empty_vec: Vec<Value> = Vec::new();
213    let threads: Vec<String> = index["threads"]
214        .as_array()
215        .unwrap_or(&empty_vec)
216        .iter()
217        .filter_map(|t| t.as_str().map(|s| s.to_string()))
218        .collect();
219
220    let mut found = threads.iter().find(|&s| s == tid);
221
222    if found.is_none() {
223        let matches: Vec<&String> =
224            threads.iter().filter(|s| s.starts_with(tid)).collect();
225
226        if matches.len() == 1 {
227            found = Some(matches[0]);
228        } else if matches.len() > 1 {
229            eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
230            return;
231        }
232    }
233
234    let tid_full = match found {
235        Some(s) => s,
236        None => {
237            eprintln!("❌ Thread not found: {}", tid);
238            return;
239        }
240    };
241
242    index["active_thread"] = json!(tid_full);
243    index["current_message"] = serde_json::Value::Null;
244
245    fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
246
247    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
248    let content = fs::read_to_string(convo_path).unwrap();
249    let conversation_json: Value = serde_json::from_str(&content).unwrap();
250    let title = conversation_json["title"].as_str().unwrap_or("Untitled");
251
252    println!(
253        "✔️ Switched active conversation to {} \"{}\"",
254        &tid_full[..8],
255        title
256    );
257}
258
259
260/// Computes total storage: conversation.json + all message JSONs + all markdown attachments.
261fn compute_conversation_size(
262    fur_dir: &Path,
263    tid: &str,
264    msg_ids: &[String],
265) -> u64 {
266    let mut total: u64 = 0;
267
268    // Add main conversation JSON
269    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
270    total += file_size(&convo_path);
271
272    // Add all messages + markdowns
273    total += get_message_file_sizes(fur_dir, msg_ids);
274
275    total
276}
277
278fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
279    let mut total = 0;
280
281    for mid in msg_ids {
282        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
283        total += file_size(&msg_path);
284
285        // Parse JSON to find ONLY message["markdown"]
286        if let Ok(content) = fs::read_to_string(&msg_path) {
287            if let Ok(json) = serde_json::from_str::<Value>(&content) {
288
289                if let Some(md_raw) = json["markdown"].as_str() {
290
291                    // CASE 1: absolute path -> use as-is
292                    let md_path = Path::new(md_raw);
293                    if md_path.is_absolute() {
294                        total += file_size(md_path);
295                        continue;
296                    }
297
298                    // CASE 2: relative path -> resolve relative to project root
299                    let project_root_path = Path::new(".").join(md_raw);
300                    total += file_size(&project_root_path);
301                }
302            }
303        }
304    }
305
306    total
307}
308
309fn file_size(path: &Path) -> u64 {
310    fs::metadata(path).map(|m| m.len()).unwrap_or(0)
311}
312
313pub fn format_size(bytes: u64) -> String {
314    if bytes < 1_048_576 {
315        format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
316    } else {
317        format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
318    }
319}