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 crate::schema::bridge::sync_active();
143}
144
145fn handle_delete_thread(index: &mut Value, fur_dir: &Path, args: &ThreadArgs) {
146 let target_tid = match resolve_target_thread_id(index, args) {
147 Some(tid) => tid,
148 None => return,
149 };
150
151 let empty_vec: Vec<Value> = Vec::new();
153 let threads: Vec<String> = index["threads"]
154 .as_array()
155 .unwrap_or(&empty_vec)
156 .iter()
157 .filter_map(|v| v.as_str().map(|s| s.to_string()))
158 .collect();
159
160 if !confirm_delete_primary() {
161 println!("❌ Deletion aborted.");
162 return;
163 }
164
165 if !confirm_delete_destructive() {
166 println!("❌ Deletion aborted.");
167 return;
168 }
169
170 perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
171}
172
173fn handle_view_threads(index: &Value, fur_dir: &Path, args: &ThreadArgs) {
174 if !(args.view || args.id.is_none()) {
175 return;
176 }
177
178 let empty_vec: Vec<Value> = Vec::new();
179 let threads = index["threads"].as_array().unwrap_or(&empty_vec);
180 let active = index["active_thread"].as_str().unwrap_or("");
181
182 let mut rows = Vec::new();
183 let mut active_idx = None;
184
185 let mut total_size_bytes: u64 = 0;
186 let mut conversation_info = Vec::new();
187
188 for tid in threads {
189 if let Some(tid_str) = tid.as_str() {
190 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
191
192 if let Ok(content) = fs::read_to_string(&convo_path) {
193 if let Ok(convo) = serde_json::from_str::<Value>(&content) {
194 let title = convo["title"].as_str().unwrap_or("Untitled").to_string();
195 let created_raw = convo["created_at"].as_str().unwrap_or("");
196
197 let msg_ids = convo["messages"]
198 .as_array()
199 .map(|a| {
200 a.iter()
201 .filter_map(|v| v.as_str().map(|s| s.to_string()))
202 .collect::<Vec<_>>()
203 })
204 .unwrap_or_default();
205
206 let msg_count = msg_ids.len();
207
208 let parsed = DateTime::parse_from_rfc3339(created_raw)
209 .map(|dt| dt.with_timezone(&Utc))
210 .unwrap_or_else(|_| Utc::now());
211
212 let local: DateTime<Local> = DateTime::from(parsed);
213 let date_str = local.format("%Y-%m-%d").to_string();
214 let time_str = local.format("%H:%M").to_string();
215
216 let size_bytes = compute_conversation_size(fur_dir, tid_str, &msg_ids);
217 total_size_bytes += size_bytes;
218
219 let tags_str = convo["tags"]
220 .as_array()
221 .unwrap_or(&vec![])
222 .iter()
223 .filter_map(|v| v.as_str())
224 .collect::<Vec<_>>()
225 .join(", ");
226
227 conversation_info.push((
228 tid_str.to_string(),
229 title,
230 date_str,
231 time_str,
232 msg_count,
233 parsed,
234 format_size(size_bytes),
235 tags_str,
236 ));
237 }
238 }
239 }
240 }
241
242 conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
244
245 for (i, (tid, title, date, time, msg_count, _, size_str, tags_str)) in
246 conversation_info.iter().enumerate()
247 {
248 rows.push(vec![
249 tid[..8].to_string(),
250 title.to_string(),
251 format!("{} | {}", date, time),
252 msg_count.to_string(),
253 size_str.to_string(),
254 tags_str.to_string(),
255 ]);
256
257 if tid == active {
258 active_idx = Some(i);
259 }
260 }
261
262 if !args.all {
264 rows = truncate_around_active(rows, active_idx);
265
266 let active_prefix = &active[..8];
268 active_idx = rows.iter().position(|row| row[0] == active_prefix);
269 }
270
271 render_table(
273 "Conversations",
274 &["ID", "Title", "Created", "#Msgs", "Size", "Tags"],
275 rows,
276 active_idx,
277 );
278
279 println!("----------------------------");
280 println!("Total Memory Used: {}", format_size(total_size_bytes));
281}
282
283fn truncate_around_active(rows: Vec<Vec<String>>, active_idx: Option<usize>) -> Vec<Vec<String>> {
284 if active_idx.is_none() {
285 return rows;
286 }
287 let i = active_idx.unwrap();
288 let win = 3;
289
290 let start = i.saturating_sub(win);
291 let end = (i + win).min(rows.len() - 1);
292
293 let mut out = Vec::new();
294
295 if start > 0 {
296 out.push(vec!["...".into(); rows[0].len()]);
297 }
298
299 for idx in start..=end {
300 out.push(rows[idx].clone());
301 }
302
303 if end < rows.len() - 1 {
304 out.push(vec!["...".into(); rows[0].len()]);
305 }
306
307 out
308}
309
310fn handle_switch_thread(index: &mut Value, index_path: &Path, fur_dir: &Path, args: &ThreadArgs) {
311 let tid = match &args.id {
312 Some(id) => id,
313 None => return,
314 };
315
316 let empty_vec: Vec<Value> = Vec::new();
317 let threads: Vec<String> = index["threads"]
318 .as_array()
319 .unwrap_or(&empty_vec)
320 .iter()
321 .filter_map(|t| t.as_str().map(|s| s.to_string()))
322 .collect();
323
324 let mut found = threads.iter().find(|&s| s == tid);
325
326 if found.is_none() {
327 let matches: Vec<&String> = threads.iter().filter(|s| s.starts_with(tid)).collect();
328
329 if matches.len() == 1 {
330 found = Some(matches[0]);
331 } else if matches.len() > 1 {
332 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
333 return;
334 }
335 }
336
337 let tid_full = match found {
338 Some(s) => s,
339 None => {
340 eprintln!("❌ Thread not found: {}", tid);
341 return;
342 }
343 };
344
345 index["active_thread"] = json!(tid_full);
346 index["current_message"] = serde_json::Value::Null;
347
348 fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
349
350 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
351 let content = fs::read_to_string(convo_path).unwrap();
352 let conversation_json: Value = serde_json::from_str(&content).unwrap();
353 let title = conversation_json["title"].as_str().unwrap_or("Untitled");
354
355 println!(
356 "✔️ Switched active conversation to {} \"{}\"",
357 &tid_full[..8],
358 title
359 );
360}
361
362fn handle_tagging(args: &ThreadArgs, index: &mut Value, fur_dir: &Path) {
363 let empty_vec: Vec<Value> = Vec::new();
364 let threads: Vec<String> = index["threads"]
365 .as_array()
366 .unwrap_or(&empty_vec)
367 .iter()
368 .filter_map(|t| t.as_str().map(|s| s.to_string()))
369 .collect();
370
371 let target_tid = if let Some(prefix) = &args.id {
373 let matches: Vec<&String> = threads
374 .iter()
375 .filter(|tid| tid.starts_with(prefix))
376 .collect();
377
378 if matches.is_empty() {
379 eprintln!("❌ No conversation matches '{}'", prefix);
380 return;
381 }
382 if matches.len() > 1 {
383 eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
384 return;
385 }
386 matches[0].clone()
387 } else {
388 index["active_thread"].as_str().unwrap_or("").to_string()
389 };
390
391 let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
392 let mut convo: Value = serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
393
394 if args.clear_tags {
398 convo["tags"] = json!([]);
399 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
400 println!("🏷️ Cleared tags for {}", &target_tid[..8]);
401 return;
402 }
403
404 let mut existing: Vec<String> = convo["tags"]
406 .as_array()
407 .unwrap_or(&vec![])
408 .iter()
409 .filter_map(|v| v.as_str().map(|s| s.to_string()))
410 .collect();
411
412 if let Some(raw) = &args.untag {
416 let remove_list = parse_tag_list(raw);
417
418 existing.retain(|t| !remove_list.contains(t));
419
420 convo["tags"] = json!(existing);
421 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
422
423 println!(
424 "🏷️ Removed tag(s) [{}] from {}",
425 remove_list.join(", "),
426 &target_tid[..8]
427 );
428 return;
429 }
430
431 if let Some(raw) = &args.tag {
435 let add_list = parse_tag_list(raw);
436
437 for t in add_list {
438 if !existing.contains(&t) {
439 existing.push(t);
440 }
441 }
442
443 convo["tags"] = json!(existing);
444 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
445
446 println!("🏷️ Updated tags for {}", &target_tid[..8]);
447 return;
448 }
449}
450
451fn compute_conversation_size(fur_dir: &Path, tid: &str, msg_ids: &[String]) -> u64 {
453 let mut total: u64 = 0;
454
455 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
457 total += file_size(&convo_path);
458
459 total += get_message_file_sizes(fur_dir, msg_ids);
461
462 total
463}
464
465fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
466 let mut total = 0;
467
468 for mid in msg_ids {
469 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
470 total += file_size(&msg_path);
471
472 if let Ok(content) = fs::read_to_string(&msg_path) {
474 if let Ok(json) = serde_json::from_str::<Value>(&content) {
475 if let Some(md_raw) = json["markdown"].as_str() {
476 let md_path = Path::new(md_raw);
478 if md_path.is_absolute() {
479 total += file_size(md_path);
480 continue;
481 }
482
483 let project_root_path = Path::new(".").join(md_raw);
485 total += file_size(&project_root_path);
486 }
487 }
488 }
489 }
490
491 total
492}
493
494fn file_size(path: &Path) -> u64 {
495 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
496}
497
498pub fn format_size(bytes: u64) -> String {
499 if bytes < 1_048_576 {
500 format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
501 } else {
502 format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
503 }
504}