1use std::fs;
2use std::path::{Path};
3use serde_json::{Value, json};
4use clap::Parser;
5use chrono::{DateTime, Local, Utc};
6use crate::renderer::table::render_table;
7
8#[derive(Parser)]
10pub struct ThreadArgs {
11 pub id: Option<String>,
13
14 #[arg(long)]
16 pub view: bool,
17
18 #[arg(long, alias = "rn")]
20 pub rename: Option<String>,
21}
22
23pub fn run_conversation(args: ThreadArgs) {
24 let fur_dir = Path::new(".fur");
25 let index_path = fur_dir.join("index.json");
26
27 if !index_path.exists() {
28 eprintln!("🚨 .fur/ not found. Run `fur new` first.");
29 return;
30 }
31
32 let mut index: Value =
33 serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
34
35 if args.rename.is_some() {
36 return handle_rename_thread(&mut index, fur_dir, &args);
37 }
38
39 if args.view || args.id.is_none() {
40 return handle_view_threads(&index, fur_dir, &args);
41 }
42
43 if args.id.is_some() {
44 return handle_switch_thread(&mut index, &index_path, fur_dir, &args);
45 }
46}
47
48fn handle_rename_thread(
49 index: &mut Value,
50 fur_dir: &Path,
51 args: &ThreadArgs,
52) {
53 let new_title = match &args.rename {
54 Some(t) => t,
55 None => return,
56 };
57
58 let empty_vec: Vec<Value> = Vec::new();
59 let threads: Vec<String> = index["threads"]
60 .as_array()
61 .unwrap_or(&empty_vec)
62 .iter()
63 .filter_map(|t| t.as_str().map(|s| s.to_string()))
64 .collect();
65
66 let target_thread_id = if args.id.is_none() {
68 index["active_thread"].as_str().unwrap_or("").to_string()
69 } else {
70 let prefix = args.id.as_ref().unwrap();
72 let found = threads
73 .iter()
74 .filter(|tid| tid.starts_with(prefix))
75 .collect::<Vec<_>>();
76
77 if found.is_empty() {
78 eprintln!("❌ No conversation matches prefix '{}'", prefix);
79 return;
80 }
81 if found.len() > 1 {
82 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", prefix, found);
83 return;
84 }
85
86 found[0].to_string()
87 };
88
89 let convo_path = fur_dir.join("threads").join(format!("{}.json", target_thread_id));
90 let mut conversation_json: Value =
91 serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
92
93 let old_title = conversation_json["title"].as_str().unwrap_or("Untitled").to_string();
94
95 conversation_json["title"] = Value::String(new_title.to_string());
97 fs::write(&convo_path, serde_json::to_string_pretty(&conversation_json).unwrap()).unwrap();
98
99 println!(
100 "✏️ Renamed conversation {} \"{}\" → \"{}\"",
101 &target_thread_id[..8],
102 old_title,
103 new_title
104 );
105}
106
107
108fn handle_view_threads(
109 index: &Value,
110 fur_dir: &Path,
111 args: &ThreadArgs,
112) {
113 if !(args.view || args.id.is_none()) {
114 return;
115 }
116
117 let empty_vec: Vec<Value> = Vec::new();
118 let threads = index["threads"].as_array().unwrap_or(&empty_vec);
119 let active = index["active_thread"].as_str().unwrap_or("");
120
121 let mut rows = Vec::new();
122 let mut active_idx = None;
123
124 let mut total_size_bytes: u64 = 0;
125 let mut conversation_info = Vec::new();
126
127 for tid in threads {
128 if let Some(tid_str) = tid.as_str() {
129 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
130
131 if let Ok(content) = fs::read_to_string(&convo_path) {
132 if let Ok(convo) = serde_json::from_str::<Value>(&content) {
133 let title = convo["title"].as_str().unwrap_or("Untitled").to_string();
134 let created_raw = convo["created_at"].as_str().unwrap_or("");
135
136 let msg_ids = convo["messages"]
137 .as_array()
138 .map(|a| {
139 a.iter()
140 .filter_map(|v| v.as_str().map(|s| s.to_string()))
141 .collect::<Vec<_>>()
142 })
143 .unwrap_or_default();
144
145 let msg_count = msg_ids.len();
146 let parsed = DateTime::parse_from_rfc3339(created_raw)
147 .map(|dt| dt.with_timezone(&Utc))
148 .unwrap_or_else(|_| Utc::now());
149
150 let local: DateTime<Local> = DateTime::from(parsed);
151 let date_str = local.format("%Y-%m-%d").to_string();
152 let time_str = local.format("%H:%M").to_string();
153
154 let size_bytes = compute_conversation_size(fur_dir, tid_str, &msg_ids);
155 total_size_bytes += size_bytes;
156
157 conversation_info.push((
158 tid_str.to_string(),
159 title,
160 date_str,
161 time_str,
162 msg_count,
163 parsed,
164 format_size(size_bytes),
165 ));
166 }
167 }
168 }
169 }
170
171 conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
173
174 for (i, (tid, title, date, time, msg_count, _, size_str)) in
175 conversation_info.iter().enumerate()
176 {
177 rows.push(vec![
178 tid[..8].to_string(),
179 title.to_string(),
180 format!("{} | {}", date, time),
181 msg_count.to_string(),
182 size_str.to_string(),
183 ]);
184
185 if tid == active {
186 active_idx = Some(i);
187 }
188 }
189
190 render_table(
191 "Threads",
192 &["ID", "Title", "Created", "#Msgs", "Size"],
193 rows,
194 active_idx,
195 );
196
197 println!("----------------------------");
198 println!("Total Memory Used: {}", format_size(total_size_bytes));
199}
200
201fn handle_switch_thread(
202 index: &mut Value,
203 index_path: &Path,
204 fur_dir: &Path,
205 args: &ThreadArgs,
206) {
207 let tid = match &args.id {
208 Some(id) => id,
209 None => return,
210 };
211
212 let empty_vec: Vec<Value> = Vec::new();
213 let threads: Vec<String> = index["threads"]
214 .as_array()
215 .unwrap_or(&empty_vec)
216 .iter()
217 .filter_map(|t| t.as_str().map(|s| s.to_string()))
218 .collect();
219
220 let mut found = threads.iter().find(|&s| s == tid);
221
222 if found.is_none() {
223 let matches: Vec<&String> =
224 threads.iter().filter(|s| s.starts_with(tid)).collect();
225
226 if matches.len() == 1 {
227 found = Some(matches[0]);
228 } else if matches.len() > 1 {
229 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
230 return;
231 }
232 }
233
234 let tid_full = match found {
235 Some(s) => s,
236 None => {
237 eprintln!("❌ Thread not found: {}", tid);
238 return;
239 }
240 };
241
242 index["active_thread"] = json!(tid_full);
243 index["current_message"] = serde_json::Value::Null;
244
245 fs::write(index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
246
247 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
248 let content = fs::read_to_string(convo_path).unwrap();
249 let conversation_json: Value = serde_json::from_str(&content).unwrap();
250 let title = conversation_json["title"].as_str().unwrap_or("Untitled");
251
252 println!(
253 "✔️ Switched active conversation to {} \"{}\"",
254 &tid_full[..8],
255 title
256 );
257}
258
259
260fn compute_conversation_size(
262 fur_dir: &Path,
263 tid: &str,
264 msg_ids: &[String],
265) -> u64 {
266 let mut total: u64 = 0;
267
268 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
270 total += file_size(&convo_path);
271
272 total += get_message_file_sizes(fur_dir, msg_ids);
274
275 total
276}
277
278fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
279 let mut total = 0;
280
281 for mid in msg_ids {
282 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
283 total += file_size(&msg_path);
284
285 if let Ok(content) = fs::read_to_string(&msg_path) {
287 if let Ok(json) = serde_json::from_str::<Value>(&content) {
288
289 if let Some(md_raw) = json["markdown"].as_str() {
290
291 let md_path = Path::new(md_raw);
293 if md_path.is_absolute() {
294 total += file_size(md_path);
295 continue;
296 }
297
298 let project_root_path = Path::new(".").join(md_raw);
300 total += file_size(&project_root_path);
301 }
302 }
303 }
304 }
305
306 total
307}
308
309fn file_size(path: &Path) -> u64 {
310 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
311}
312
313pub fn format_size(bytes: u64) -> String {
314 if bytes < 1_048_576 {
315 format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
316 } else {
317 format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
318 }
319}