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