fur_cli/commands/
conversation.rs1use 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
19pub fn run_conversation(args: ThreadArgs) {
21 let fur_dir = Path::new(".fur");
22 let index_path = fur_dir.join("index.json");
23
24 if !index_path.exists() {
25 eprintln!("🚨 .fur/ not found. Run `fur new` first.");
26 return;
27 }
28
29 let mut index: Value =
30 serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
31
32 if args.view || args.id.is_none() {
36 let empty_vec: Vec<Value> = Vec::new();
37 let threads = index["threads"].as_array().unwrap_or(&empty_vec);
38 let active = index["active_thread"].as_str().unwrap_or("");
39
40 let mut rows = Vec::new();
41 let mut active_idx = None;
42
43 let mut total_size_bytes: u64 = 0;
44
45 let mut conversation_info = Vec::new();
47 for tid in threads {
48 if let Some(tid_str) = tid.as_str() {
49 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
50 if let Ok(content) = fs::read_to_string(&convo_path) {
51 if let Ok(conversation_json) = serde_json::from_str::<Value>(&content) {
52 let title = conversation_json["title"]
53 .as_str()
54 .unwrap_or("Untitled")
55 .to_string();
56
57 let created_raw =
58 conversation_json["created_at"].as_str().unwrap_or("");
59 let msg_ids = conversation_json["messages"]
60 .as_array()
61 .map(|a| {
62 a.iter()
63 .filter_map(|v| v.as_str().map(|s| s.to_string()))
64 .collect::<Vec<_>>()
65 })
66 .unwrap_or_default();
67
68 let msg_count = msg_ids.len();
69
70 let parsed_time =
72 DateTime::parse_from_rfc3339(created_raw)
73 .map(|dt| dt.with_timezone(&Utc))
74 .unwrap_or_else(|_| Utc::now());
75 let local_time: DateTime<Local> =
76 DateTime::from(parsed_time);
77 let date_str = local_time.format("%Y-%m-%d").to_string();
78 let time_str = local_time.format("%H:%M").to_string();
79
80 let size_bytes =
82 compute_conversation_size(fur_dir, tid_str, &msg_ids);
83
84 total_size_bytes += size_bytes;
85
86 let size_str = format_size(size_bytes);
87
88 conversation_info.push((
89 tid_str.to_string(),
90 title,
91 date_str,
92 time_str,
93 msg_count,
94 parsed_time,
95 size_str,
96 ));
97 }
98 }
99 }
100 }
101
102 conversation_info.sort_by(|a, b| b.5.cmp(&a.5));
104
105 for (i, (tid, title, date, time, msg_count, _, size_str)) in
107 conversation_info.iter().enumerate()
108 {
109 let short_id = &tid[..8];
110
111 rows.push(vec![
112 short_id.to_string(),
113 title.to_string(),
114 format!("{} | {}", date, time),
115 msg_count.to_string(),
116 size_str.to_string(),
117 ]);
118
119 if tid == active {
120 active_idx = Some(i);
121 }
122 }
123
124 render_table(
125 "Threads",
126 &["ID", "Title", "Created", "#Msgs", "Size"],
127 rows,
128 active_idx,
129 );
130
131 let total_size_str = format_size(total_size_bytes);
132 println!("----------------------------");
133 println!("Total Memory Used: {}", total_size_str);
134
135 return;
136 }
137
138 if let Some(tid) = args.id {
142 let empty_vec: Vec<Value> = Vec::new();
143 let threads: Vec<String> = index["threads"]
144 .as_array()
145 .unwrap_or(&empty_vec)
146 .iter()
147 .filter_map(|t| t.as_str().map(|s| s.to_string()))
148 .collect();
149
150 let mut found = threads.iter().find(|&s| s == &tid);
151 if found.is_none() {
152 let matches: Vec<&String> =
153 threads.iter().filter(|s| s.starts_with(&tid)).collect();
154 if matches.len() == 1 {
155 found = Some(matches[0]);
156 } else if matches.len() > 1 {
157 eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
158 return;
159 }
160 }
161
162 let tid_full = match found {
163 Some(s) => s,
164 None => {
165 eprintln!("❌ Thread not found: {}", tid);
166 return;
167 }
168 };
169
170 index["active_thread"] = json!(tid_full);
171 index["current_message"] = serde_json::Value::Null;
172 fs::write(
173 &index_path,
174 serde_json::to_string_pretty(&index).unwrap(),
175 )
176 .unwrap();
177
178 let convo_path =
179 fur_dir.join("threads").join(format!("{}.json", tid_full));
180 let content = fs::read_to_string(convo_path).unwrap();
181 let conversation_json: Value =
182 serde_json::from_str(&content).unwrap();
183 let title =
184 conversation_json["title"].as_str().unwrap_or("Untitled");
185
186 println!(
187 "✔️ Switched active conversation to {} \"{}\"",
188 &tid_full[..8],
189 title
190 );
191 }
192}
193
194
195fn compute_conversation_size(
197 fur_dir: &Path,
198 tid: &str,
199 msg_ids: &[String],
200) -> u64 {
201 let mut total: u64 = 0;
202
203 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
205 total += file_size(&convo_path);
206
207 total += get_message_file_sizes(fur_dir, msg_ids);
209
210 total
211}
212
213fn get_message_file_sizes(fur_dir: &Path, msg_ids: &[String]) -> u64 {
214 let mut total = 0;
215
216 for mid in msg_ids {
217 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
218 total += file_size(&msg_path);
219
220 if let Ok(content) = fs::read_to_string(&msg_path) {
222 if let Ok(json) = serde_json::from_str::<Value>(&content) {
223
224 if let Some(md_raw) = json["markdown"].as_str() {
225
226 let md_path = Path::new(md_raw);
228 if md_path.is_absolute() {
229 total += file_size(md_path);
230 continue;
231 }
232
233 let project_root_path = Path::new(".").join(md_raw);
235 total += file_size(&project_root_path);
236 }
237 }
238 }
239 }
240
241 total
242}
243
244fn file_size(path: &Path) -> u64 {
245 fs::metadata(path).map(|m| m.len()).unwrap_or(0)
246}
247
248pub fn format_size(bytes: u64) -> String {
249 if bytes < 1_048_576 {
250 format!("{} KB", (bytes as f64 / 1024.0).round() as u64)
251 } else {
252 format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
253 }
254}