1use std::fs;
17use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20use sha2::{Digest, Sha256};
21
22use crate::schema::document::{serialize, FurDocument, FurMessage};
23
24pub fn document_from_thread(fur_dir: &Path, tid: &str) -> Result<FurDocument, String> {
30 let convo_path = fur_dir.join("threads").join(format!("{}.json", tid));
31
32 let convo: Value = read_json(&convo_path)?;
33
34 let mut doc = FurDocument::new(
35 convo["id"].as_str().unwrap_or(tid),
36 convo["title"].as_str().unwrap_or("Untitled"),
37 convo["created_at"].as_str().unwrap_or(""),
38 );
39
40 doc.tags = convo["tags"]
41 .as_array()
42 .map(|arr| {
43 arr.iter()
44 .filter_map(|v| v.as_str().map(|s| s.to_string()))
45 .collect()
46 })
47 .unwrap_or_default();
48
49 let msg_ids: Vec<String> = convo["messages"]
50 .as_array()
51 .map(|arr| {
52 arr.iter()
53 .filter_map(|v| v.as_str().map(|s| s.to_string()))
54 .collect()
55 })
56 .unwrap_or_default();
57
58 for mid in &msg_ids {
59 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
60
61 let msg: Value = read_json(&msg_path)
64 .map_err(|e| format!("message {} unreadable ({})", short(mid), e))?;
65
66 doc.messages.push(message_from_json(mid, &msg));
67 }
68
69 Ok(doc)
70}
71
72fn message_from_json(mid: &str, msg: &Value) -> FurMessage {
73 let mut out = FurMessage::new(
74 msg["id"].as_str().unwrap_or(mid),
75 msg["avatar"].as_str().unwrap_or("unknown"),
76 msg["timestamp"].as_str().unwrap_or(""),
77 );
78
79 if let Some(text) = msg["text"].as_str() {
80 out = out.with_body(text);
81 }
82
83 if let Some(md_raw) = msg["markdown"].as_str() {
84 let filename = Path::new(md_raw)
85 .file_name()
86 .and_then(|f| f.to_str())
87 .unwrap_or(md_raw)
88 .to_string();
89
90 let hash = msg["markdown_meta"]["hash"]
94 .as_str()
95 .map(|s| s.to_string())
96 .or_else(|| hash_file(Path::new(md_raw)));
97
98 out = out.with_link(filename, hash);
99 }
100
101 if let Some(img) = msg["attachment"].as_str() {
102 out.img = Path::new(img)
103 .file_name()
104 .and_then(|f| f.to_str())
105 .map(|s| s.to_string());
106 }
107
108 out
109}
110
111pub fn linked_sources(fur_dir: &Path, tid: &str) -> Result<Vec<PathBuf>, String> {
115 let convo: Value = read_json(&fur_dir.join("threads").join(format!("{}.json", tid)))?;
116
117 let mut out = Vec::new();
118
119 let msg_ids = convo["messages"].as_array().cloned().unwrap_or_default();
120
121 for mid in msg_ids.iter().filter_map(|v| v.as_str()) {
122 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
123 let Ok(msg) = read_json(&msg_path) else {
124 continue;
125 };
126
127 for key in ["markdown", "attachment"] {
128 if let Some(raw) = msg[key].as_str() {
129 out.push(resolve_relative(raw));
130 }
131 }
132 }
133
134 Ok(out)
135}
136
137pub fn write_conversation_folder(
143 project_root: &Path,
144 doc: &FurDocument,
145 linked: &[PathBuf],
146 force: bool,
147) -> Result<PathBuf, String> {
148 let folder = project_root.join("chats").join(slug_for(doc));
149 let spine = folder.join("convo.md");
150
151 if spine.exists() && !force {
152 return Err(format!(
153 "{} already exists (use --force to overwrite)",
154 spine.display()
155 ));
156 }
157
158 fs::create_dir_all(&folder).map_err(|e| format!("cannot create {}: {}", folder.display(), e))?;
159
160 fs::write(&spine, serialize(doc))
161 .map_err(|e| format!("cannot write {}: {}", spine.display(), e))?;
162
163 for src in linked {
164 let Some(name) = src.file_name() else {
165 continue;
166 };
167 let dst = folder.join(name);
168
169 if !src.exists() {
170 eprintln!("⚠ linked file missing, not copied: {}", src.display());
171 continue;
172 }
173 if dst.exists() && !force {
174 continue;
175 }
176 if same_file(src, &dst) {
177 continue;
178 }
179
180 fs::copy(src, &dst)
181 .map_err(|e| format!("cannot copy {} → {}: {}", src.display(), dst.display(), e))?;
182 }
183
184 Ok(folder)
185}
186
187pub fn sync_conversation(
194 project_root: &Path,
195 fur_dir: &Path,
196 tid: &str,
197) -> Result<PathBuf, String> {
198 let doc = document_from_thread(fur_dir, tid)?;
199 let linked = linked_sources(fur_dir, tid)?;
200
201 let chats = project_root.join("chats");
204 let desired = slug_for(&doc);
205
206 if let Some(existing) = find_folder_for(&chats, &doc.conversation_id) {
207 let same = existing.file_name().and_then(|f| f.to_str()) == Some(desired.as_str());
208 if !same {
209 let target = chats.join(&desired);
210 if !target.exists() {
211 fs::rename(&existing, &target).map_err(|e| {
212 format!("cannot rename {} → {}: {}", existing.display(), target.display(), e)
213 })?;
214 }
215 }
216 }
217
218 write_conversation_folder(project_root, &doc, &linked, true)
219}
220
221pub fn sync_active() {
228 if crate::security::state::is_locked() {
229 return;
230 }
231
232 let fur_dir = Path::new(".fur");
233 let index_path = fur_dir.join("index.json");
234
235 if !index_path.exists() {
236 return;
237 }
238
239 let Some(content) = crate::security::io::read_text_file(&index_path) else {
240 return;
241 };
242
243 let Ok(index) = serde_json::from_str::<Value>(&content) else {
244 return;
245 };
246
247 let Some(tid) = index["active_thread"].as_str() else {
248 return;
249 };
250
251 if tid.is_empty() {
252 return;
253 }
254
255 if let Err(e) = sync_conversation(Path::new("."), fur_dir, tid) {
256 eprintln!("⚠ chats/ not updated: {}", e);
257 }
258}
259
260fn same_file(a: &Path, b: &Path) -> bool {
261 match (fs::canonicalize(a), fs::canonicalize(b)) {
262 (Ok(x), Ok(y)) => x == y,
263 _ => false,
264 }
265}
266
267pub fn active_folder(project_root: &Path) -> Option<PathBuf> {
273 let fur_dir = project_root.join(".fur");
274
275 let index: Value = read_json(&fur_dir.join("index.json")).ok()?;
276 let tid = index["active_thread"].as_str()?;
277
278 if tid.is_empty() {
279 return None;
280 }
281
282 let doc = document_from_thread(&fur_dir, tid).ok()?;
283 let chats = project_root.join("chats");
284
285 let folder = find_folder_for(&chats, &doc.conversation_id)
287 .unwrap_or_else(|| chats.join(slug_for(&doc)));
288
289 fs::create_dir_all(&folder).ok()?;
290
291 Some(folder)
292}
293
294pub fn find_orphans(project_root: &Path) -> Vec<PathBuf> {
298 let chats = project_root.join("chats");
299
300 let Ok(entries) = fs::read_dir(&chats) else {
301 return Vec::new();
302 };
303
304 let mut adopted: Vec<String> = Vec::new();
305
306 for entry in entries.flatten() {
307 let dir = entry.path();
308 if !dir.is_dir() {
309 continue;
310 }
311 if let Ok(files) = fs::read_dir(&dir) {
312 for f in files.flatten() {
313 if let Some(name) = f.path().file_name().and_then(|n| n.to_str()) {
314 adopted.push(name.to_string());
315 }
316 }
317 }
318 }
319
320 let Ok(entries) = fs::read_dir(&chats) else {
321 return Vec::new();
322 };
323
324 entries
325 .flatten()
326 .map(|e| e.path())
327 .filter(|p| p.is_file())
328 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
329 .filter(|p| {
330 p.file_name()
331 .and_then(|n| n.to_str())
332 .map(|n| adopted.iter().any(|a| a == n))
333 .unwrap_or(false)
334 })
335 .collect()
336}
337
338fn find_folder_for(chats: &Path, conversation_id: &str) -> Option<PathBuf> {
341 let suffix = format!("-{}", short(conversation_id));
342
343 let entries = fs::read_dir(chats).ok()?;
344
345 for entry in entries.flatten() {
346 let path = entry.path();
347 if !path.is_dir() {
348 continue;
349 }
350
351 let name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
352 if name.ends_with(&suffix) || name == short(conversation_id) {
353 return Some(path);
354 }
355 }
356
357 None
358}
359
360pub fn slug_for(doc: &FurDocument) -> String {
363 let base = slugify(&doc.title);
364 let short = short(&doc.conversation_id);
365
366 if base.is_empty() {
367 return short.to_string();
368 }
369
370 format!("{}-{}", base, short)
371}
372
373fn slugify(raw: &str) -> String {
374 let mut out = String::new();
375 let mut last_dash = true; for c in raw.chars() {
378 if c.is_ascii_alphanumeric() {
379 out.push(c.to_ascii_lowercase());
380 last_dash = false;
381 } else if !last_dash {
382 out.push('-');
383 last_dash = true;
384 }
385
386 if out.len() >= 60 {
387 break;
388 }
389 }
390
391 out.trim_matches('-').to_string()
392}
393
394fn short(id: &str) -> &str {
395 if id.len() >= 8 {
396 &id[..8]
397 } else {
398 id
399 }
400}
401
402fn resolve_relative(raw: &str) -> PathBuf {
404 let p = PathBuf::from(raw);
405 if p.is_absolute() {
406 p
407 } else {
408 PathBuf::from(".").join(raw)
409 }
410}
411
412fn hash_file(path: &Path) -> Option<String> {
413 let resolved = resolve_relative(path.to_str()?);
414 let bytes = fs::read(&resolved).ok()?;
415
416 let mut hasher = Sha256::new();
417 hasher.update(&bytes);
418
419 Some(format!("{:x}", hasher.finalize()))
420}
421
422fn read_json(path: &Path) -> Result<Value, String> {
423 let content = crate::security::io::read_text_file(path)
424 .ok_or_else(|| format!("cannot read {}", path.display()))?;
425
426 serde_json::from_str(&content).map_err(|e| format!("invalid JSON in {}: {}", path.display(), e))
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::schema::document::FurDocument;
433
434 fn doc_titled(title: &str, id: &str) -> FurDocument {
435 FurDocument::new(id, title, "2026-08-08T18:11:40-05:00")
436 }
437
438 #[test]
439 fn slug_is_readable_and_disambiguated() {
440 let doc = doc_titled(
441 "Schema Proposal Changes FUR",
442 "8f0c4a2e-1b3d-4f5a-9c7e-2d8b6a1f0e33",
443 );
444 assert_eq!(slug_for(&doc), "schema-proposal-changes-fur-8f0c4a2e");
445 }
446
447 #[test]
448 fn slug_collapses_punctuation_and_spaces() {
449 let doc = doc_titled("GPT-5 Experiments!! (v2)", "aabbccddeeff");
450 assert_eq!(slug_for(&doc), "gpt-5-experiments-v2-aabbccdd");
451 }
452
453 #[test]
454 fn untitled_conversations_fall_back_to_the_id() {
455 let doc = doc_titled("!!!", "aabbccddeeff");
456 assert_eq!(slug_for(&doc), "aabbccdd");
457 }
458
459 #[test]
460 fn message_body_comes_from_text() {
461 let msg = serde_json::json!({
462 "id": "m1",
463 "avatar": "andrew",
464 "timestamp": "2026-08-08T18:11:40-05:00",
465 "text": "Symbolic regression tests using KAN",
466 "markdown": null,
467 "attachment": null
468 });
469
470 let out = message_from_json("m1", &msg);
471
472 assert_eq!(out.avatar, "andrew");
473 assert_eq!(out.body, "Symbolic regression tests using KAN");
474 assert!(out.link.is_none());
475 }
476
477 #[test]
478 fn markdown_attachment_becomes_a_link_with_basename_only() {
479 let msg = serde_json::json!({
480 "id": "m2",
481 "avatar": "gpt5",
482 "timestamp": "2026-08-08T18:11:57-05:00",
483 "text": null,
484 "markdown": "chats/CHAT-20260808-181158.md",
485 "markdown_meta": { "hash": "9f2c", "size": 12, "filename": "CHAT-20260808-181158.md" },
486 "attachment": null
487 });
488
489 let out = message_from_json("m2", &msg);
490
491 assert_eq!(out.link.as_deref(), Some("CHAT-20260808-181158.md"));
492 assert_eq!(out.sha256.as_deref(), Some("9f2c"));
493 assert!(out.body.is_empty());
494 }
495}