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
19/// Main entry point for the `conversation` command
20pub fn run_conversation(args: ThreadArgs) {
21    let fur_dir = Path::new(".fur");
22    let index_path = fur_dir.join("index.json");
23
24    if !index_path.exists() {
25        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
26        return;
27    }
28
29    let mut index: Value =
30        serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
31
32    // ------------------------
33    // VIEW ALL THREADS
34    // ------------------------
35    if args.view || args.id.is_none() {
36        let empty_vec: Vec<Value> = Vec::new();
37        let threads = index["threads"].as_array().unwrap_or(&empty_vec);
38        let active = index["active_thread"].as_str().unwrap_or("");
39
40        let mut rows = Vec::new();
41        let mut active_idx = None;
42
43        let mut total_size_bytes: u64 = 0;
44
45        // Collect conversation metadata first
46        let mut conversation_info = Vec::new();
47        for tid in threads {
48            if let Some(tid_str) = tid.as_str() {
49                let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
50                if let Ok(content) = fs::read_to_string(&convo_path) {
51                    if let Ok(conversation_json) = serde_json::from_str::<Value>(&content) {
52                        let title = conversation_json["title"]
53                            .as_str()
54                            .unwrap_or("Untitled")
55                            .to_string();
56
57                        let created_raw =
58                            conversation_json["created_at"].as_str().unwrap_or("");
59                        let msg_ids = conversation_json["messages"]
60                            .as_array()
61                            .map(|a| {
62                                a.iter()
63                                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
64                                    .collect::<Vec<_>>()
65                            })
66                            .unwrap_or_default();
67
68                        let msg_count = msg_ids.len();
69
70                        // Parse created_at safely
71                        let parsed_time =
72                            DateTime::parse_from_rfc3339(created_raw)
73                                .map(|dt| dt.with_timezone(&Utc))
74                                .unwrap_or_else(|_| Utc::now());
75                        let local_time: DateTime<Local> =
76                            DateTime::from(parsed_time);
77                        let date_str = local_time.format("%Y-%m-%d").to_string();
78                        let time_str = local_time.format("%H:%M").to_string();
79
80                        // Compute total footprint (JSON + markdown attachments)
81                        let size_bytes = 
82                            compute_conversation_size(fur_dir, tid_str, &msg_ids);
83
84                        total_size_bytes += size_bytes;
85
86                        let size_str = format_size(size_bytes);
87
88                        conversation_info.push((
89                            tid_str.to_string(),
90                            title,
91                            date_str,
92                            time_str,
93                            msg_count,
94                            parsed_time,
95                            size_str,
96                        ));
97                    }
98                }
99            }
100        }
101
102        // Sort newest → oldest
103        conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
104
105        // Build rows and track active index
106        for (i, (tid, title, date, time, msg_count, _, size_str)) in
107            conversation_info.iter().enumerate()
108        {
109            let short_id = &tid[..8];
110
111            rows.push(vec![
112                short_id.to_string(),
113                title.to_string(),
114                format!("{} | {}", date, time),
115                msg_count.to_string(),
116                size_str.to_string(),
117            ]);
118
119            if tid == active {
120                active_idx = Some(i);
121            }
122        }
123
124        render_table(
125            "Threads",
126            &["ID", "Title", "Created", "#Msgs", "Size"],
127            rows,
128            active_idx,
129        );
130
131        let total_size_str = format_size(total_size_bytes);
132        println!("----------------------------");
133        println!("Total Memory Used: {}", total_size_str);
134
135        return;
136    }
137
138    // ------------------------
139    // SWITCH ACTIVE THREAD
140    // ------------------------
141    if let Some(tid) = args.id {
142        let empty_vec: Vec<Value> = Vec::new();
143        let threads: Vec<String> = index["threads"]
144            .as_array()
145            .unwrap_or(&empty_vec)
146            .iter()
147            .filter_map(|t| t.as_str().map(|s| s.to_string()))
148            .collect();
149
150        let mut found = threads.iter().find(|&s| s == &tid);
151        if found.is_none() {
152            let matches: Vec<&String> =
153                threads.iter().filter(|s| s.starts_with(&tid)).collect();
154            if matches.len() == 1 {
155                found = Some(matches[0]);
156            } else if matches.len() > 1 {
157                eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
158                return;
159            }
160        }
161
162        let tid_full = match found {
163            Some(s) => s,
164            None => {
165                eprintln!("❌ Thread not found: {}", tid);
166                return;
167            }
168        };
169
170        index["active_thread"] = json!(tid_full);
171        index["current_message"] = serde_json::Value::Null;
172        fs::write(
173            &index_path,
174            serde_json::to_string_pretty(&index).unwrap(),
175        )
176        .unwrap();
177
178        let convo_path =
179            fur_dir.join("threads").join(format!("{}.json", tid_full));
180        let content = fs::read_to_string(convo_path).unwrap();
181        let conversation_json: Value =
182            serde_json::from_str(&content).unwrap();
183        let title =
184            conversation_json["title"].as_str().unwrap_or("Untitled");
185
186        println!(
187            "✔️ Switched active conversation to {} \"{}\"",
188            &tid_full[..8],
189            title
190        );
191    }
192}
193
194
195/// Computes total storage: conversation.json + all message JSONs + all markdown attachments.
196fn compute_conversation_size(
197    fur_dir: &Path,
198    tid: &str,
199    msg_ids: &[String],
200) -> u64 {
201    let mut total: u64 = 0;
202
203    // Add main conversation JSON
204    let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
205    total += file_size(&convo_path);
206
207    // Add all messages + markdowns
208    total += get_message_file_sizes(fur_dir, msg_ids);
209
210    total
211}
212
213fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
214    let mut total = 0;
215
216    for mid in msg_ids {
217        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
218        total += file_size(&msg_path);
219
220        // Parse JSON to find ONLY message["markdown"]
221        if let Ok(content) = fs::read_to_string(&msg_path) {
222            if let Ok(json) = serde_json::from_str::<Value>(&content) {
223
224                if let Some(md_raw) = json["markdown"].as_str() {
225
226                    // CASE 1: absolute path -> use as-is
227                    let md_path = Path::new(md_raw);
228                    if md_path.is_absolute() {
229                        total += file_size(md_path);
230                        continue;
231                    }
232
233                    // CASE 2: relative path -> resolve relative to project root
234                    let project_root_path = Path::new(".").join(md_raw);
235                    total += file_size(&project_root_path);
236                }
237            }
238        }
239    }
240
241    total
242}
243
244fn file_size(path: &Path) -> u64 {
245    fs::metadata(path).map(|m| m.len()).unwrap_or(0)
246}
247
248pub fn format_size(bytes: u64) -> String {
249    if bytes < 1_048_576 {
250        format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
251    } else {
252        format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
253    }
254}