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
40pub fn run_conversation(args: ThreadArgs) {
41 let fur_dir = Path::new(".fur");
42 let index_path = fur_dir.join("index.json");
43
44 if !index_path.exists() {
45 eprintln!("🚨 .fur/ not found. Run `fur new` first.");
46 return;
47 }
48
49 let mut index: Value =
50 serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
51
52 if args.tag.is_some() || args.untag.is_some() || args.clear_tags {
53 return handle_tagging(&args, &mut index, fur_dir);
54 }
55
56 if args.rename.is_some() {
57 return handle_rename_thread(&mut index, fur_dir, &args);
58 }
59
60 if args.delete {
61 return handle_delete_thread(&mut index, fur_dir, &args);
62 }
63
64 if args.view || args.id.is_none() {
65 return handle_view_threads(&index, fur_dir, &args);
66 }
67
68 if args.id.is_some() {
69 return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
70 }
71}
72
73fn handle_rename_thread(
74 index: &mut Value,
75 fur_dir: &Path,
76 args: &ThreadArgs,
77) {
78 let new_title = match &args.rename {
79 Some(t) => t,
80 None => return,
81 };
82
83 let empty_vec: Vec<Value> = Vec::new();
84 let threads: Vec<String> = index["threads"]
85 .as_array()
86 .unwrap_or(&empty_vec)
87 .iter()
88 .filter_map(|t| t.as_str().map(|s| s.to_string()))
89 .collect();
90
91 let target_thread_id = if args.id.is_none() {
93 index["active_thread"].as_str().unwrap_or("").to_string()
94 } else {
95 let prefix = args.id.as_ref().unwrap();
97 let found = threads
98 .iter()
99 .filter(|tid| tid.starts_with(prefix))
100 .collect::<Vec<_>>();
101
102 if found.is_empty() {
103 eprintln!("❌ No conversation matches prefix '{}'", prefix);
104 return;
105 }
106 if found.len() > 1 {
107 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
108 return;
109 }
110
111 found[0].to_string()
112 };
113
114 let convo_path = fur_dir.join("threads").join(format!("{}.json", target_thread_id));
115 let mut conversation_json: Value =
116 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
117
118 let old_title = conversation_json["title"].as_str().unwrap_or("Untitled").to_string();
119
120 conversation_json["title"] = Value::String(new_title.to_string());
122 fs::write(&convo_path, serde_json::to_string_pretty(&conversation_json).unwrap()).unwrap();
123
124 println!(
125 "✏️ Renamed conversation {} \"{}\" → \"{}\"",
126 &target_thread_id[..8],
127 old_title,
128 new_title
129 );
130}
131
132
133fn handle_delete_thread(
134 index: &mut Value,
135 fur_dir: &Path,
136 args: &ThreadArgs,
137) {
138 let target_tid = match resolve_target_thread_id(index, args) {
139 Some(tid) => tid,
140 None => return,
141 };
142
143 let empty_vec: Vec<Value> = Vec::new();
145 let threads: Vec<String> = index["threads"]
146 .as_array()
147 .unwrap_or(&empty_vec)
148 .iter()
149 .filter_map(|v| v.as_str().map(|s| s.to_string()))
150 .collect();
151
152 if !confirm_delete_primary() {
153 println!("❌ Deletion aborted.");
154 return;
155 }
156
157 if !confirm_delete_destructive() {
158 println!("❌ Deletion aborted.");
159 return;
160 }
161
162 perform_conversation_deletion(index, fur_dir, &target_tid, &threads);
163}
164
165
166
167fn handle_view_threads(
168 index: &Value,
169 fur_dir: &Path,
170 args: &ThreadArgs,
171) {
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 render_table(
262 "Threads",
263 &["ID", "Title", "Created", "#Msgs", "Size", "Tags"],
264 rows,
265 active_idx,
266 );
267
268 println!("----------------------------");
269 println!("Total Memory Used: {}", format_size(total_size_bytes));
270}
271
272fn handle_switch_thread(
273 index: &mut Value,
274 index_path: &Path,
275 fur_dir: &Path,
276 args: &ThreadArgs,
277) {
278 let tid = match &args.id {
279 Some(id) => id,
280 None => return,
281 };
282
283 let empty_vec: Vec<Value> = Vec::new();
284 let threads: Vec<String> = index["threads"]
285 .as_array()
286 .unwrap_or(&empty_vec)
287 .iter()
288 .filter_map(|t| t.as_str().map(|s| s.to_string()))
289 .collect();
290
291 let mut found = threads.iter().find(|&s| s == tid);
292
293 if found.is_none() {
294 let matches: Vec<&String> =
295 threads.iter().filter(|s| s.starts_with(tid)).collect();
296
297 if matches.len() == 1 {
298 found = Some(matches[0]);
299 } else if matches.len() > 1 {
300 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
301 return;
302 }
303 }
304
305 let tid_full = match found {
306 Some(s) => s,
307 None => {
308 eprintln!("❌ Thread not found: {}", tid);
309 return;
310 }
311 };
312
313 index["active_thread"] = json!(tid_full);
314 index["current_message"] = serde_json::Value::Null;
315
316 fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
317
318 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
319 let content = fs::read_to_string(convo_path).unwrap();
320 let conversation_json: Value = serde_json::from_str(&content).unwrap();
321 let title = conversation_json["title"].as_str().unwrap_or("Untitled");
322
323 println!(
324 "✔️ Switched active conversation to {} \"{}\"",
325 &tid_full[..8],
326 title
327 );
328}
329
330fn handle_tagging(
331 args: &ThreadArgs,
332 index: &mut Value,
333 fur_dir: &Path,
334) {
335 let empty_vec: Vec<Value> = Vec::new();
336 let threads: Vec<String> = index["threads"]
337 .as_array()
338 .unwrap_or(&empty_vec)
339 .iter()
340 .filter_map(|t| t.as_str().map(|s| s.to_string()))
341 .collect();
342
343 let target_tid = if let Some(prefix) = &args.id {
345 let matches: Vec<&String> =
346 threads.iter().filter(|tid| tid.starts_with(prefix)).collect();
347
348 if matches.is_empty() {
349 eprintln!("❌ No conversation matches '{}'", prefix);
350 return;
351 }
352 if matches.len() > 1 {
353 eprintln!("❌ Ambiguous prefix '{}': {:?}", prefix, matches);
354 return;
355 }
356 matches[0].clone()
357 } else {
358 index["active_thread"].as_str().unwrap_or("").to_string()
359 };
360
361 let convo_path = fur_dir.join("threads").join(format!("{}.json", target_tid));
362 let mut convo: Value =
363 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
364
365 if args.clear_tags {
369 convo["tags"] = json!([]);
370 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
371 println!("🏷️ Cleared tags for {}", &target_tid[..8]);
372 return;
373 }
374
375 let mut existing: Vec<String> = convo["tags"]
377 .as_array()
378 .unwrap_or(&vec![])
379 .iter()
380 .filter_map(|v| v.as_str().map(|s| s.to_string()))
381 .collect();
382
383 if let Some(raw) = &args.untag {
387 let remove_list = parse_tag_list(raw);
388
389 existing.retain(|t| !remove_list.contains(t));
390
391 convo["tags"] = json!(existing);
392 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
393
394 println!(
395 "🏷️ Removed tag(s) [{}] from {}",
396 remove_list.join(", "),
397 &target_tid[..8]
398 );
399 return;
400 }
401
402 if let Some(raw) = &args.tag {
406 let add_list = parse_tag_list(raw);
407
408 for t in add_list {
409 if !existing.contains(&t) {
410 existing.push(t);
411 }
412 }
413
414 convo["tags"] = json!(existing);
415 fs::write(&convo_path, serde_json::to_string_pretty(&convo).unwrap()).unwrap();
416
417 println!("🏷️ Updated tags for {}", &target_tid[..8]);
418 return;
419 }
420}
421
422fn compute_conversation_size(
424 fur_dir: &Path,
425 tid: &str,
426 msg_ids: &[String],
427) -> u64 {
428 let mut total: u64 = 0;
429
430 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
432 total += file_size(&convo_path);
433
434 total += get_message_file_sizes(fur_dir, msg_ids);
436
437 total
438}
439
440fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
441 let mut total = 0;
442
443 for mid in msg_ids {
444 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
445 total += file_size(&msg_path);
446
447 if let Ok(content) = fs::read_to_string(&msg_path) {
449 if let Ok(json) = serde_json::from_str::<Value>(&content) {
450
451 if let Some(md_raw) = json["markdown"].as_str() {
452
453 let md_path = Path::new(md_raw);
455 if md_path.is_absolute() {
456 total += file_size(md_path);
457 continue;
458 }
459
460 let project_root_path = Path::new(".").join(md_raw);
462 total += file_size(&project_root_path);
463 }
464 }
465 }
466 }
467
468 total
469}
470
471fn file_size(path: &Path) -> u64 {
472 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
473}
474
475pub fn format_size(bytes: u64) -> String {
476 if bytes < 1_048_576 {
477 format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
478 } else {
479 format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
480 }
481}