1use crate::helpers::conversation::{
2 confirm_delete_destructive, confirm_delete_primary, perform_conversation_deletion,
3 resolve_target_thread_id,
4};
5use crate::helpers::tags::parse_tag_list;
6use crate::renderer::table::render_table;
7use crate::schema::lineage::Lineage;
8use chrono::{DateTime, Local, Utc};
9use clap::Parser;
10use colored::*;
11use serde_json::{json, Value};
12use std::collections::HashMap;
13use std::fs;
14use std::path::Path;
15
16#[derive(Parser)]
18pub struct ThreadArgs {
19 pub id: Option<String>,
21
22 #[arg(long)]
24 pub view: bool,
25
26 #[arg(long, alias = "rn")]
28 pub rename: Option<String>,
29
30 #[arg(long)]
32 pub tag: Option<String>,
33
34 #[arg(long)]
35 pub untag: Option<String>,
36
37 #[arg(long)]
39 pub clear_tags: bool,
40
41 #[arg(long)]
43 pub delete: bool,
44
45 #[arg(long, short = 'a')]
47 pub all: bool,
48
49 #[arg(long)]
51 pub flat: bool,
52}
53
54struct ConvoInfo {
56 id: String,
57 title: String,
58 created: DateTime<Utc>,
59 date_str: String,
60 time_str: String,
61 msg_count: usize,
62 size_bytes: u64,
63 tags: String,
64}
65
66pub fn run_conversation(args: ThreadArgs) {
67 let fur_dir = Path::new(".fur");
68 let index_path = fur_dir.join("index.json");
69
70 if !index_path.exists() {
71 eprintln!("🚨 .fur/ not found. Run `fur new` first.");
72 return;
73 }
74
75 let mut index: Value = serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
76
77 if args.tag.is_some() || args.untag.is_some() || args.clear_tags {
78 return handle_tagging(&args, &mut index, fur_dir);
79 }
80
81 if args.rename.is_some() {
82 return handle_rename_thread(&mut index, fur_dir, &args);
83 }
84
85 if args.delete {
86 return handle_delete_thread(&mut index, fur_dir, &args);
87 }
88
89 if args.view || args.id.is_none() {
90 return handle_view_threads(&index, fur_dir, &args);
91 }
92
93 if args.id.is_some() {
94 return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
95 }
96}
97
98fn handle_rename_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
99 let new_title = match &args.rename {
100 Some(t) => t,
101 None => return,
102 };
103
104 let empty_vec: Vec<Value> = Vec::new();
105 let threads: Vec<String> = index["threads"]
106 .as_array()
107 .unwrap_or(&empty_vec)
108 .iter()
109 .filter_map(|t| t.as_str().map(|s| s.to_string()))
110 .collect();
111
112 let target_thread_id = if args.id.is_none() {
114 index["active_thread"].as_str().unwrap_or("").to_string()
115 } else {
116 let prefix = args.id.as_ref().unwrap();
118 let found = threads
119 .iter()
120 .filter(|tid| tid.starts_with(prefix))
121 .collect::<Vec<_>>();
122
123 if found.is_empty() {
124 eprintln!("❌ No conversation matches prefix '{}'", prefix);
125 return;
126 }
127 if found.len() > 1 {
128 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
129 return;
130 }
131
132 found[0].to_string()
133 };
134
135 let convo_path = fur_dir
136 .join("threads")
137 .join(format!("{}.json", target_thread_id));
138 let mut conversation_json: Value =
139 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
140
141 let old_title = conversation_json["title"]
142 .as_str()
143 .unwrap_or("Untitled")
144 .to_string();
145
146 conversation_json["title"] = Value::String(new_title.to_string());
148 fs::write(
149 &convo_path,
150 serde_json::to_string_pretty(&conversation_json).unwrap(),
151 )
152 .unwrap();
153
154 println!(
155 "✏️ Renamed conversation {} \"{}\" → \"{}\"",
156 &target_thread_id[..8],
157 old_title,
158 new_title
159 );
160
161 crate::schema::bridge::sync_active();
162}
163
164fn handle_delete_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
165 let target_tid = match resolve_target_thread_id(index, args) {
166 Some(tid) => tid,
167 None => return,
168 };
169
170 let empty_vec: Vec<Value> = Vec::new();
172 let threads: Vec<String> = index["threads"]
173 .as_array()
174 .unwrap_or(&empty_vec)
175 .iter()
176 .filter_map(|v| v.as_str().map(|s| s.to_string()))
177 .collect();
178
179 if !confirm_delete_primary() {
180 println!("❌ Deletion aborted.");
181 return;
182 }
183
184 if !confirm_delete_destructive() {
185 println!("❌ Deletion aborted.");
186 return;
187 }
188
189 perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
190}
191
192fn handle_view_threads(index: &Value, fur_dir: &Path, args: &ThreadArgs) {
197 if !(args.view || args.id.is_none()) {
198 return;
199 }
200
201 let active = index["active_thread"].as_str().unwrap_or("");
202 let infos = collect_infos(index, fur_dir);
203
204 if infos.is_empty() {
205 println!("📭 No conversations yet. Run `fur new \"Title\"`.");
206 return;
207 }
208
209 let total_size_bytes: u64 = infos.iter().map(|i| i.size_bytes).sum();
210
211 let _ = args.all;
214
215 if args.flat {
216 render_flat(&infos, active);
217 } else {
218 render_tree(&infos, active, fur_dir);
219 }
220
221 println!("----------------------------");
222 println!("Total Memory Used: {}", format_size(total_size_bytes));
223}
224
225fn collect_infos(index: &Value, fur_dir: &Path) -> Vec<ConvoInfo> {
227 let empty_vec: Vec<Value> = Vec::new();
228 let threads = index["threads"].as_array().unwrap_or(&empty_vec);
229
230 let mut infos = Vec::new();
231
232 for tid in threads {
233 let Some(tid_str) = tid.as_str() else {
234 continue;
235 };
236
237 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
238 let Ok(content) = fs::read_to_string(&convo_path) else {
239 continue;
240 };
241 let Ok(convo) = serde_json::from_str::<Value>(&content) else {
242 continue;
243 };
244
245 let msg_ids: Vec<String> = convo["messages"]
246 .as_array()
247 .map(|a| {
248 a.iter()
249 .filter_map(|v| v.as_str().map(|s| s.to_string()))
250 .collect::<Vec<_>>()
251 })
252 .unwrap_or_default();
253
254 let created = DateTime::parse_from_rfc3339(convo["created_at"].as_str().unwrap_or(""))
255 .map(|dt| dt.with_timezone(&Utc))
256 .unwrap_or_else(|_| Utc::now());
257
258 let local: DateTime<Local> = DateTime::from(created);
259
260 infos.push(ConvoInfo {
261 id: tid_str.to_string(),
262 title: convo["title"].as_str().unwrap_or("Untitled").to_string(),
263 created,
264 date_str: local.format("%Y-%m-%d").to_string(),
265 time_str: local.format("%H:%M").to_string(),
266 msg_count: msg_ids.len(),
267 size_bytes: compute_conversation_size(fur_dir, tid_str, &msg_ids),
268 tags: convo["tags"]
269 .as_array()
270 .unwrap_or(&vec![])
271 .iter()
272 .filter_map(|v| v.as_str())
273 .collect::<Vec<_>>()
274 .join(", "),
275 });
276 }
277
278 infos.sort_by(|a, b| b.created.cmp(&a.created));
279 infos
280}
281
282fn row_for(info: &ConvoInfo, title: String) -> Vec<String> {
283 vec![
284 info.id[..8].to_string(),
285 title,
286 format!("{} | {}", info.date_str, info.time_str),
287 info.msg_count.to_string(),
288 format_size(info.size_bytes),
289 info.tags.clone(),
290 ]
291}
292
293const HEADERS: [&str; 6] = ["ID", "Title", "Created", "#Msgs", "Size", "Tags"];
294
295fn render_flat(infos: &[ConvoInfo], active: &str) {
297 let rows: Vec<Vec<String>> = infos
298 .iter()
299 .map(|info| row_for(info, info.title.clone()))
300 .collect();
301
302 let active_idx = infos.iter().position(|i| i.id == active);
303
304 render_table("Conversations", &HEADERS, rows, active_idx, true);
305}
306
307fn render_tree(infos: &[ConvoInfo], active: &str, fur_dir: &Path) {
312 let lineage = match Lineage::load(fur_dir) {
313 Ok(l) => l,
314 Err(e) => {
315 eprintln!("⚠ lineage unavailable ({}); showing a flat list", e);
316 return render_flat(infos, active);
317 }
318 };
319
320 if lineage.is_empty() {
321 return render_flat(infos, active);
322 }
323
324 let by_id: HashMap<&str, &ConvoInfo> = infos.iter().map(|i| (i.id.as_str(), i)).collect();
325 let order: Vec<String> = infos.iter().map(|i| i.id.clone()).collect();
326
327 let mut rows = Vec::new();
328 let mut active_idx = None;
329 let mut any_repeat = false;
330 let mut any_orphan = false;
331
332 for entry in lineage.forest(&order) {
333 let Some(info) = by_id.get(entry.id.as_str()) else {
334 continue;
335 };
336
337 let indent = if entry.depth == 0 {
338 String::new()
339 } else {
340 format!("{}└─ ", " ".repeat(entry.depth - 1))
341 };
342
343 let mut title = format!("{}{}", indent, info.title);
344
345 if entry.orphan_parent {
346 any_orphan = true;
347 title.push_str(" ↑");
348 }
349
350 if entry.repeat {
351 any_repeat = true;
352 title.push_str(" (above)");
353 }
354
355 if entry.id == active && !entry.repeat {
356 active_idx = Some(rows.len());
357 }
358
359 rows.push(row_for(info, title));
360 }
361
362 render_table("Conversations", &HEADERS, rows, active_idx, false);
363
364 if any_orphan {
365 println!(
366 "{}",
367 "↑ linked to a conversation that is not in this project".bright_black()
368 );
369 }
370 if any_repeat {
371 println!(
372 "{}",
373 "(above) listed under another parent as well".bright_black()
374 );
375 }
376}
377
378fn handle_switch_thread(index: &mut Value, index_path: &Path, fur_dir: &Path, args: &ThreadArgs) {
383 let tid = match &args.id {
384 Some(id) => id,
385 None => return,
386 };
387
388 let empty_vec: Vec<Value> = Vec::new();
389 let threads: Vec<String> = index["threads"]
390 .as_array()
391 .unwrap_or(&empty_vec)
392 .iter()
393 .filter_map(|t| t.as_str().map(|s| s.to_string()))
394 .collect();
395
396 let mut found = threads.iter().find(|&s| s == tid);
397
398 if found.is_none() {
399 let matches: Vec<&String> = threads.iter().filter(|s| s.starts_with(tid)).collect();
400
401 if matches.len() == 1 {
402 found = Some(matches[0]);
403 } else if matches.len() > 1 {
404 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
405 return;
406 }
407 }
408
409 let tid_full = match found {
410 Some(s) => s,
411 None => {
412 eprintln!("❌ Thread not found: {}", tid);
413 return;
414 }
415 };
416
417 index["active_thread"] = json!(tid_full);
418 index["current_message"] = serde_json::Value::Null;
419
420 fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
421
422 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
423 let content = fs::read_to_string(convo_path).unwrap();
424 let conversation_json: Value = serde_json::from_str(&content).unwrap();
425 let title = conversation_json["title"].as_str().unwrap_or("Untitled");
426
427 println!(
428 "✔️ Switched active conversation to {} \"{}\"",
429 &tid_full[..8],
430 title
431 );
432}
433
434fn handle_tagging(args: &ThreadArgs, index: &mut Value, fur_dir: &Path) {
435 let empty_vec: Vec<Value> = Vec::new();
436 let threads: Vec<String> = index["threads"]
437 .as_array()
438 .unwrap_or(&empty_vec)
439 .iter()
440 .filter_map(|t| t.as_str().map(|s| s.to_string()))
441 .collect();
442
443 let target_tid = if let Some(prefix) = &args.id {
445 let matches: Vec<&String> = threads
446 .iter()
447 .filter(|tid| tid.starts_with(prefix))
448 .collect();
449
450 if matches.is_empty() {
451 eprintln!("❌ No conversation matches '{}'", prefix);
452 return;
453 }
454 if matches.len() > 1 {
455 eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
456 return;
457 }
458 matches[0].clone()
459 } else {
460 index["active_thread"].as_str().unwrap_or("").to_string()
461 };
462
463 let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
464 let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
465
466 if args.clear_tags {
470 convo["tags"] = json!([]);
471 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
472 println!("🏷️ Cleared tags for {}", &target_tid[..8]);
473 crate::schema::bridge::sync_active();
474 return;
475 }
476
477 let mut existing: Vec<String> = convo["tags"]
479 .as_array()
480 .unwrap_or(&vec![])
481 .iter()
482 .filter_map(|v| v.as_str().map(|s| s.to_string()))
483 .collect();
484
485 if let Some(raw) = &args.untag {
489 let remove_list = parse_tag_list(raw);
490
491 existing.retain(|t| !remove_list.contains(t));
492
493 convo["tags"] = json!(existing);
494 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
495
496 println!(
497 "🏷️ Removed tag(s) [{}] from {}",
498 remove_list.join(", "),
499 &target_tid[..8]
500 );
501 crate::schema::bridge::sync_active();
502 return;
503 }
504
505 if let Some(raw) = &args.tag {
509 let add_list = parse_tag_list(raw);
510
511 for t in add_list {
512 if !existing.contains(&t) {
513 existing.push(t);
514 }
515 }
516
517 convo["tags"] = json!(existing);
518 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
519
520 println!("🏷️ Updated tags for {}", &target_tid[..8]);
521 crate::schema::bridge::sync_active();
522 }
523}
524
525fn compute_conversation_size(fur_dir: &Path, tid: &str, msg_ids: &[String]) -> u64 {
531 let mut total: u64 = 0;
532
533 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
535 total += file_size(&convo_path);
536
537 total += get_message_file_sizes(fur_dir, msg_ids);
539
540 total
541}
542
543fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
544 let mut total = 0;
545
546 for mid in msg_ids {
547 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
548 total += file_size(&msg_path);
549
550 if let Ok(content) = fs::read_to_string(&msg_path) {
552 if let Ok(json) = serde_json::from_str::<Value>(&content) {
553 if let Some(md_raw) = json["markdown"].as_str() {
554 let md_path = Path::new(md_raw);
556 if md_path.is_absolute() {
557 total += file_size(md_path);
558 continue;
559 }
560
561 let project_root_path = Path::new(".").join(md_raw);
563 total += file_size(&project_root_path);
564 }
565 }
566 }
567 }
568
569 total
570}
571
572fn file_size(path: &Path) -> u64 {
573 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
574}
575
576pub fn format_size(bytes: u64) -> String {
577 if bytes < 1_048_576 {
578 format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
579 } else {
580 format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
581 }
582}