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#[derive(Parser)]
11pub struct ThreadArgs {
12 pub id: Option<String>,
14
15 #[arg(long)]
17 pub view: bool,
18
19 #[arg(long, alias = "rn")]
21 pub rename: Option<String>,
22
23 #[arg(long)]
25 pub tag: Option<String>,
26
27 #[arg(long)]
28 pub untag: Option<String>,
29
30 #[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 let target_thread_id = if args.id.is_none() {
84 index["active_thread"].as_str().unwrap_or("").to_string()
85 } else {
86 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 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 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 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 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 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 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 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 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
379fn compute_conversation_size(
381 fur_dir: &Path,
382 tid: &str,
383 msg_ids: &[String],
384) -> u64 {
385 let mut total: u64 = 0;
386
387 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
389 total += file_size(&convo_path);
390
391 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 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 let md_path = Path::new(md_raw);
412 if md_path.is_absolute() {
413 total += file_size(md_path);
414 continue;
415 }
416
417 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}