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 chrono::{DateTime, Local, Utc};
8use clap::Parser;
9use serde_json::{json, Value};
10use std::fs;
11use std::path::Path;
12
13#[derive(Parser)]
15pub struct ThreadArgs {
16 pub id: Option<String>,
18
19 #[arg(long)]
21 pub view: bool,
22
23 #[arg(long, alias = "rn")]
25 pub rename: Option<String>,
26
27 #[arg(long)]
29 pub tag: Option<String>,
30
31 #[arg(long)]
32 pub untag: Option<String>,
33
34 #[arg(long)]
36 pub clear_tags: bool,
37
38 #[arg(long)]
40 pub delete: bool,
41
42 #[arg(long, short = 'a')]
44 pub all: bool,
45}
46
47pub fn run_conversation(args: ThreadArgs) {
48 let fur_dir = Path::new(".fur");
49 let index_path = fur_dir.join("index.json");
50
51 if !index_path.exists() {
52 eprintln!("🚨 .fur/ not found. Run `fur new` first.");
53 return;
54 }
55
56 let mut index: Value = serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
57
58 if args.tag.is_some() || args.untag.is_some() || args.clear_tags {
59 return handle_tagging(&args, &mut index, fur_dir);
60 }
61
62 if args.rename.is_some() {
63 return handle_rename_thread(&mut index, fur_dir, &args);
64 }
65
66 if args.delete {
67 return handle_delete_thread(&mut index, fur_dir, &args);
68 }
69
70 if args.view || args.id.is_none() {
71 return handle_view_threads(&index, fur_dir, &args);
72 }
73
74 if args.id.is_some() {
75 return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
76 }
77}
78
79fn handle_rename_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
80 let new_title = match &args.rename {
81 Some(t) => t,
82 None => return,
83 };
84
85 let empty_vec: Vec<Value> = Vec::new();
86 let threads: Vec<String> = index["threads"]
87 .as_array()
88 .unwrap_or(&empty_vec)
89 .iter()
90 .filter_map(|t| t.as_str().map(|s| s.to_string()))
91 .collect();
92
93 let target_thread_id = if args.id.is_none() {
95 index["active_thread"].as_str().unwrap_or("").to_string()
96 } else {
97 let prefix = args.id.as_ref().unwrap();
99 let found = threads
100 .iter()
101 .filter(|tid| tid.starts_with(prefix))
102 .collect::<Vec<_>>();
103
104 if found.is_empty() {
105 eprintln!("❌ No conversation matches prefix '{}'", prefix);
106 return;
107 }
108 if found.len() > 1 {
109 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
110 return;
111 }
112
113 found[0].to_string()
114 };
115
116 let convo_path = fur_dir
117 .join("threads")
118 .join(format!("{}.json", target_thread_id));
119 let mut conversation_json: Value =
120 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
121
122 let old_title = conversation_json["title"]
123 .as_str()
124 .unwrap_or("Untitled")
125 .to_string();
126
127 conversation_json["title"] = Value::String(new_title.to_string());
129 fs::write(
130 &convo_path,
131 serde_json::to_string_pretty(&conversation_json).unwrap(),
132 )
133 .unwrap();
134
135 println!(
136 "✏️ Renamed conversation {} \"{}\" → \"{}\"",
137 &target_thread_id[..8],
138 old_title,
139 new_title
140 );
141}
142
143fn handle_delete_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
144 let target_tid = match resolve_target_thread_id(index, args) {
145 Some(tid) => tid,
146 None => return,
147 };
148
149 let empty_vec: Vec<Value> = Vec::new();
151 let threads: Vec<String> = index["threads"]
152 .as_array()
153 .unwrap_or(&empty_vec)
154 .iter()
155 .filter_map(|v| v.as_str().map(|s| s.to_string()))
156 .collect();
157
158 if !confirm_delete_primary() {
159 println!("❌ Deletion aborted.");
160 return;
161 }
162
163 if !confirm_delete_destructive() {
164 println!("❌ Deletion aborted.");
165 return;
166 }
167
168 perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
169}
170
171fn handle_view_threads(index: &Value, fur_dir: &Path, args: &ThreadArgs) {
172 if !(args.view || args.id.is_none()) {
173 return;
174 }
175
176 let empty_vec: Vec<Value> = Vec::new();
177 let threads = index["threads"].as_array().unwrap_or(&empty_vec);
178 let active = index["active_thread"].as_str().unwrap_or("");
179
180 let mut rows = Vec::new();
181 let mut active_idx = None;
182
183 let mut total_size_bytes: u64 = 0;
184 let mut conversation_info = Vec::new();
185
186 for tid in threads {
187 if let Some(tid_str) = tid.as_str() {
188 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
189
190 if let Ok(content) = fs::read_to_string(&convo_path) {
191 if let Ok(convo) = serde_json::from_str::<Value>(&content) {
192 let title = convo["title"].as_str().unwrap_or("Untitled").to_string();
193 let created_raw = convo["created_at"].as_str().unwrap_or("");
194
195 let msg_ids = convo["messages"]
196 .as_array()
197 .map(|a| {
198 a.iter()
199 .filter_map(|v| v.as_str().map(|s| s.to_string()))
200 .collect::<Vec<_>>()
201 })
202 .unwrap_or_default();
203
204 let msg_count = msg_ids.len();
205
206 let parsed = DateTime::parse_from_rfc3339(created_raw)
207 .map(|dt| dt.with_timezone(&Utc))
208 .unwrap_or_else(|_| Utc::now());
209
210 let local: DateTime<Local> = DateTime::from(parsed);
211 let date_str = local.format("%Y-%m-%d").to_string();
212 let time_str = local.format("%H:%M").to_string();
213
214 let size_bytes = compute_conversation_size(fur_dir, tid_str, &msg_ids);
215 total_size_bytes += size_bytes;
216
217 let tags_str = convo["tags"]
218 .as_array()
219 .unwrap_or(&vec![])
220 .iter()
221 .filter_map(|v| v.as_str())
222 .collect::<Vec<_>>()
223 .join(", ");
224
225 conversation_info.push((
226 tid_str.to_string(),
227 title,
228 date_str,
229 time_str,
230 msg_count,
231 parsed,
232 format_size(size_bytes),
233 tags_str,
234 ));
235 }
236 }
237 }
238 }
239
240 conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
242
243 for (i, (tid, title, date, time, msg_count, _, size_str, tags_str)) in
244 conversation_info.iter().enumerate()
245 {
246 rows.push(vec![
247 tid[..8].to_string(),
248 title.to_string(),
249 format!("{} | {}", date, time),
250 msg_count.to_string(),
251 size_str.to_string(),
252 tags_str.to_string(),
253 ]);
254
255 if tid == active {
256 active_idx = Some(i);
257 }
258 }
259
260 if !args.all {
262 rows = truncate_around_active(rows, active_idx);
263
264 let active_prefix = &active[..8];
266 active_idx = rows.iter().position(|row| row[0] == active_prefix);
267 }
268
269 render_table(
271 "Conversations",
272 &["ID", "Title", "Created", "#Msgs", "Size", "Tags"],
273 rows,
274 active_idx,
275 );
276
277 println!("----------------------------");
278 println!("Total Memory Used: {}", format_size(total_size_bytes));
279}
280
281fn truncate_around_active(rows: Vec<Vec<String>>, active_idx: Option<usize>) -> Vec<Vec<String>> {
282 if active_idx.is_none() {
283 return rows;
284 }
285 let i = active_idx.unwrap();
286 let win = 3;
287
288 let start = i.saturating_sub(win);
289 let end = (i + win).min(rows.len() - 1);
290
291 let mut out = Vec::new();
292
293 if start > 0 {
294 out.push(vec!["...".into(); rows[0].len()]);
295 }
296
297 for idx in start..=end {
298 out.push(rows[idx].clone());
299 }
300
301 if end < rows.len() - 1 {
302 out.push(vec!["...".into(); rows[0].len()]);
303 }
304
305 out
306}
307
308fn handle_switch_thread(index: &mut Value, index_path: &Path, fur_dir: &Path, args: &ThreadArgs) {
309 let tid = match &args.id {
310 Some(id) => id,
311 None => return,
312 };
313
314 let empty_vec: Vec<Value> = Vec::new();
315 let threads: Vec<String> = index["threads"]
316 .as_array()
317 .unwrap_or(&empty_vec)
318 .iter()
319 .filter_map(|t| t.as_str().map(|s| s.to_string()))
320 .collect();
321
322 let mut found = threads.iter().find(|&s| s == tid);
323
324 if found.is_none() {
325 let matches: Vec<&String> = threads.iter().filter(|s| s.starts_with(tid)).collect();
326
327 if matches.len() == 1 {
328 found = Some(matches[0]);
329 } else if matches.len() > 1 {
330 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
331 return;
332 }
333 }
334
335 let tid_full = match found {
336 Some(s) => s,
337 None => {
338 eprintln!("❌ Thread not found: {}", tid);
339 return;
340 }
341 };
342
343 index["active_thread"] = json!(tid_full);
344 index["current_message"] = serde_json::Value::Null;
345
346 fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
347
348 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
349 let content = fs::read_to_string(convo_path).unwrap();
350 let conversation_json: Value = serde_json::from_str(&content).unwrap();
351 let title = conversation_json["title"].as_str().unwrap_or("Untitled");
352
353 println!(
354 "✔️ Switched active conversation to {} \"{}\"",
355 &tid_full[..8],
356 title
357 );
358}
359
360fn handle_tagging(args: &ThreadArgs, index: &mut Value, fur_dir: &Path) {
361 let empty_vec: Vec<Value> = Vec::new();
362 let threads: Vec<String> = index["threads"]
363 .as_array()
364 .unwrap_or(&empty_vec)
365 .iter()
366 .filter_map(|t| t.as_str().map(|s| s.to_string()))
367 .collect();
368
369 let target_tid = if let Some(prefix) = &args.id {
371 let matches: Vec<&String> = threads
372 .iter()
373 .filter(|tid| tid.starts_with(prefix))
374 .collect();
375
376 if matches.is_empty() {
377 eprintln!("❌ No conversation matches '{}'", prefix);
378 return;
379 }
380 if matches.len() > 1 {
381 eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
382 return;
383 }
384 matches[0].clone()
385 } else {
386 index["active_thread"].as_str().unwrap_or("").to_string()
387 };
388
389 let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
390 let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
391
392 if args.clear_tags {
396 convo["tags"] = json!([]);
397 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
398 println!("🏷️ Cleared tags for {}", &target_tid[..8]);
399 return;
400 }
401
402 let mut existing: Vec<String> = convo["tags"]
404 .as_array()
405 .unwrap_or(&vec![])
406 .iter()
407 .filter_map(|v| v.as_str().map(|s| s.to_string()))
408 .collect();
409
410 if let Some(raw) = &args.untag {
414 let remove_list = parse_tag_list(raw);
415
416 existing.retain(|t| !remove_list.contains(t));
417
418 convo["tags"] = json!(existing);
419 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
420
421 println!(
422 "🏷️ Removed tag(s) [{}] from {}",
423 remove_list.join(", "),
424 &target_tid[..8]
425 );
426 return;
427 }
428
429 if let Some(raw) = &args.tag {
433 let add_list = parse_tag_list(raw);
434
435 for t in add_list {
436 if !existing.contains(&t) {
437 existing.push(t);
438 }
439 }
440
441 convo["tags"] = json!(existing);
442 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
443
444 println!("🏷️ Updated tags for {}", &target_tid[..8]);
445 return;
446 }
447}
448
449fn compute_conversation_size(fur_dir: &Path, tid: &str, msg_ids: &[String]) -> u64 {
451 let mut total: u64 = 0;
452
453 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
455 total += file_size(&convo_path);
456
457 total += get_message_file_sizes(fur_dir, msg_ids);
459
460 total
461}
462
463fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
464 let mut total = 0;
465
466 for mid in msg_ids {
467 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
468 total += file_size(&msg_path);
469
470 if let Ok(content) = fs::read_to_string(&msg_path) {
472 if let Ok(json) = serde_json::from_str::<Value>(&content) {
473 if let Some(md_raw) = json["markdown"].as_str() {
474 let md_path = Path::new(md_raw);
476 if md_path.is_absolute() {
477 total += file_size(md_path);
478 continue;
479 }
480
481 let project_root_path = Path::new(".").join(md_raw);
483 total += file_size(&project_root_path);
484 }
485 }
486 }
487 }
488
489 total
490}
491
492fn file_size(path: &Path) -> u64 {
493 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
494}
495
496pub fn format_size(bytes: u64) -> String {
497 if bytes < 1_048_576 {
498 format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
499 } else {
500 format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
501 }
502}