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 = string_list(&convo, "tags");
41 doc.parents = string_list(&convo, "parents");
42 doc.children = string_list(&convo, "children");
43
44 let msg_ids: Vec<String> = convo["messages"]
45 .as_array()
46 .map(|arr| {
47 arr.iter()
48 .filter_map(|v| v.as_str().map(|s| s.to_string()))
49 .collect()
50 })
51 .unwrap_or_default();
52
53 for mid in &msg_ids {
54 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
55
56 let msg: Value = read_json(&msg_path)
59 .map_err(|e| format!("message {} unreadable ({})", short(mid), e))?;
60
61 doc.messages.push(message_from_json(mid, &msg));
62 }
63
64 doc.updated_at = latest_message_ts(&doc);
65
66 Ok(doc)
67}
68
69fn latest_message_ts(doc: &FurDocument) -> Option<String> {
77 use chrono::DateTime;
78
79 let created = DateTime::parse_from_rfc3339(&doc.created_at).ok();
80
81 let mut best: Option<(DateTime<chrono::FixedOffset>, &str)> = None;
82
83 for msg in &doc.messages {
84 let Ok(parsed) = DateTime::parse_from_rfc3339(&msg.ts) else {
85 continue;
86 };
87 if best.map(|(t, _)| parsed > t).unwrap_or(true) {
88 best = Some((parsed, msg.ts.as_str()));
89 }
90 }
91
92 let (latest, raw) = best?;
93
94 match created {
95 Some(start) if latest <= start => None,
96 _ => Some(raw.to_string()),
97 }
98}
99
100fn message_from_json(mid: &str, msg: &Value) -> FurMessage {
101 let mut out = FurMessage::new(
102 msg["id"].as_str().unwrap_or(mid),
103 msg["avatar"].as_str().unwrap_or("unknown"),
104 msg["timestamp"].as_str().unwrap_or(""),
105 );
106
107 if let Some(text) = msg["text"].as_str() {
108 out = out.with_body(text);
109 }
110
111 if let Some(md_raw) = msg["markdown"].as_str() {
112 let filename = Path::new(md_raw)
113 .file_name()
114 .and_then(|f| f.to_str())
115 .unwrap_or(md_raw)
116 .to_string();
117
118 let hash = msg["markdown_meta"]["hash"]
122 .as_str()
123 .map(|s| s.to_string())
124 .or_else(|| hash_file(Path::new(md_raw)));
125
126 out = out.with_link(filename, hash);
127 }
128
129 if let Some(img) = msg["attachment"].as_str() {
130 out.img = Path::new(img)
131 .file_name()
132 .and_then(|f| f.to_str())
133 .map(|s| s.to_string());
134 }
135
136 out
137}
138
139pub fn linked_sources(fur_dir: &Path, tid: &str) -> Result<Vec<PathBuf>, String> {
143 let convo: Value = read_json(&fur_dir.join("threads").join(format!("{}.json", tid)))?;
144
145 let mut out = Vec::new();
146
147 let msg_ids = convo["messages"].as_array().cloned().unwrap_or_default();
148
149 for mid in msg_ids.iter().filter_map(|v| v.as_str()) {
150 let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
151 let Ok(msg) = read_json(&msg_path) else {
152 continue;
153 };
154
155 for key in ["markdown", "attachment"] {
156 if let Some(raw) = msg[key].as_str() {
157 out.push(resolve_relative(raw));
158 }
159 }
160 }
161
162 Ok(out)
163}
164
165pub fn write_conversation_folder(
171 project_root: &Path,
172 doc: &FurDocument,
173 linked: &[PathBuf],
174 force: bool,
175) -> Result<PathBuf, String> {
176 let folder = project_root.join("chats").join(slug_for(doc));
177 let spine = folder.join("convo.md");
178
179 if spine.exists() && !force {
180 return Err(format!(
181 "{} already exists (use --force to overwrite)",
182 spine.display()
183 ));
184 }
185
186 fs::create_dir_all(&folder).map_err(|e| format!("cannot create {}: {}", folder.display(), e))?;
187
188 fs::write(&spine, serialize(doc))
189 .map_err(|e| format!("cannot write {}: {}", spine.display(), e))?;
190
191 for src in linked {
192 let Some(name) = src.file_name() else {
193 continue;
194 };
195 let dst = folder.join(name);
196
197 if !src.exists() {
198 eprintln!("⚠ linked file missing, not copied: {}", src.display());
199 continue;
200 }
201 if dst.exists() && !force {
202 continue;
203 }
204 if same_file(src, &dst) {
205 continue;
206 }
207
208 fs::copy(src, &dst)
209 .map_err(|e| format!("cannot copy {} → {}: {}", src.display(), dst.display(), e))?;
210 }
211
212 Ok(folder)
213}
214
215pub fn sync_conversation(
222 project_root: &Path,
223 fur_dir: &Path,
224 tid: &str,
225) -> Result<PathBuf, String> {
226 let doc = document_from_thread(fur_dir, tid)?;
227 let linked = linked_sources(fur_dir, tid)?;
228
229 let chats = project_root.join("chats");
232 let desired = slug_for(&doc);
233
234 if let Some(existing) = find_folder_for(&chats, &doc.conversation_id) {
235 let same = existing.file_name().and_then(|f| f.to_str()) == Some(desired.as_str());
236 if !same {
237 let target = chats.join(&desired);
238 if !target.exists() {
239 fs::rename(&existing, &target).map_err(|e| {
240 format!("cannot rename {} → {}: {}", existing.display(), target.display(), e)
241 })?;
242 }
243 }
244 }
245
246 write_conversation_folder(project_root, &doc, &linked, true)
247}
248
249pub fn sync_active() {
256 if crate::security::state::is_locked() {
257 return;
258 }
259
260 let fur_dir = Path::new(".fur");
261 let index_path = fur_dir.join("index.json");
262
263 if !index_path.exists() {
264 return;
265 }
266
267 let Some(content) = crate::security::io::read_text_file(&index_path) else {
268 return;
269 };
270
271 let Ok(index) = serde_json::from_str::<Value>(&content) else {
272 return;
273 };
274
275 let Some(tid) = index["active_thread"].as_str() else {
276 return;
277 };
278
279 if tid.is_empty() {
280 return;
281 }
282
283 if let Err(e) = sync_conversation(Path::new("."), fur_dir, tid) {
284 eprintln!("⚠ chats/ not updated: {}", e);
285 }
286}
287
288fn same_file(a: &Path, b: &Path) -> bool {
289 match (fs::canonicalize(a), fs::canonicalize(b)) {
290 (Ok(x), Ok(y)) => x == y,
291 _ => false,
292 }
293}
294
295pub fn active_folder(project_root: &Path) -> Option<PathBuf> {
301 let fur_dir = project_root.join(".fur");
302
303 let index: Value = read_json(&fur_dir.join("index.json")).ok()?;
304 let tid = index["active_thread"].as_str()?;
305
306 if tid.is_empty() {
307 return None;
308 }
309
310 let doc = document_from_thread(&fur_dir, tid).ok()?;
311 let chats = project_root.join("chats");
312
313 let folder = find_folder_for(&chats, &doc.conversation_id)
315 .unwrap_or_else(|| chats.join(slug_for(&doc)));
316
317 fs::create_dir_all(&folder).ok()?;
318
319 Some(folder)
320}
321
322pub fn find_orphans(project_root: &Path) -> Vec<PathBuf> {
326 let chats = project_root.join("chats");
327
328 let Ok(entries) = fs::read_dir(&chats) else {
329 return Vec::new();
330 };
331
332 let mut adopted: Vec<String> = Vec::new();
333
334 for entry in entries.flatten() {
335 let dir = entry.path();
336 if !dir.is_dir() {
337 continue;
338 }
339 if let Ok(files) = fs::read_dir(&dir) {
340 for f in files.flatten() {
341 if let Some(name) = f.path().file_name().and_then(|n| n.to_str()) {
342 adopted.push(name.to_string());
343 }
344 }
345 }
346 }
347
348 let Ok(entries) = fs::read_dir(&chats) else {
349 return Vec::new();
350 };
351
352 entries
353 .flatten()
354 .map(|e| e.path())
355 .filter(|p| p.is_file())
356 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
357 .filter(|p| {
358 p.file_name()
359 .and_then(|n| n.to_str())
360 .map(|n| adopted.iter().any(|a| a == n))
361 .unwrap_or(false)
362 })
363 .collect()
364}
365
366fn find_folder_for(chats: &Path, conversation_id: &str) -> Option<PathBuf> {
369 let suffix = format!("-{}", short(conversation_id));
370
371 let entries = fs::read_dir(chats).ok()?;
372
373 for entry in entries.flatten() {
374 let path = entry.path();
375 if !path.is_dir() {
376 continue;
377 }
378
379 let name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
380 if name.ends_with(&suffix) || name == short(conversation_id) {
381 return Some(path);
382 }
383 }
384
385 None
386}
387
388pub fn slug_for(doc: &FurDocument) -> String {
391 let base = slugify(&doc.title);
392 let short = short(&doc.conversation_id);
393
394 if base.is_empty() {
395 return short.to_string();
396 }
397
398 format!("{}-{}", base, short)
399}
400
401fn slugify(raw: &str) -> String {
402 let mut out = String::new();
403 let mut last_dash = true; for c in raw.chars() {
406 if c.is_ascii_alphanumeric() {
407 out.push(c.to_ascii_lowercase());
408 last_dash = false;
409 } else if !last_dash {
410 out.push('-');
411 last_dash = true;
412 }
413
414 if out.len() >= 60 {
415 break;
416 }
417 }
418
419 out.trim_matches('-').to_string()
420}
421
422fn short(id: &str) -> &str {
423 if id.len() >= 8 {
424 &id[..8]
425 } else {
426 id
427 }
428}
429
430fn resolve_relative(raw: &str) -> PathBuf {
432 let p = PathBuf::from(raw);
433 if p.is_absolute() {
434 p
435 } else {
436 PathBuf::from(".").join(raw)
437 }
438}
439
440fn hash_file(path: &Path) -> Option<String> {
441 let resolved = resolve_relative(path.to_str()?);
442 let bytes = fs::read(&resolved).ok()?;
443
444 let mut hasher = Sha256::new();
445 hasher.update(&bytes);
446
447 Some(format!("{:x}", hasher.finalize()))
448}
449
450fn string_list(value: &Value, key: &str) -> Vec<String> {
457 value[key]
458 .as_array()
459 .map(|arr| {
460 arr.iter()
461 .filter_map(|v| v.as_str().map(|s| s.to_string()))
462 .collect()
463 })
464 .unwrap_or_default()
465}
466
467fn read_json(path: &Path) -> Result<Value, String> {
468 let content = crate::security::io::read_text_file(path)
469 .ok_or_else(|| format!("cannot read {}", path.display()))?;
470
471 serde_json::from_str(&content).map_err(|e| format!("invalid JSON in {}: {}", path.display(), e))
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::schema::document::FurDocument;
478
479 fn doc_titled(title: &str, id: &str) -> FurDocument {
480 FurDocument::new(id, title, "2026-08-08T18:11:40-05:00")
481 }
482
483 #[test]
484 fn slug_is_readable_and_disambiguated() {
485 let doc = doc_titled(
486 "Schema Proposal Changes FUR",
487 "8f0c4a2e-1b3d-4f5a-9c7e-2d8b6a1f0e33",
488 );
489 assert_eq!(slug_for(&doc), "schema-proposal-changes-fur-8f0c4a2e");
490 }
491
492 #[test]
493 fn slug_collapses_punctuation_and_spaces() {
494 let doc = doc_titled("GPT-5 Experiments!! (v2)", "aabbccddeeff");
495 assert_eq!(slug_for(&doc), "gpt-5-experiments-v2-aabbccdd");
496 }
497
498 #[test]
499 fn untitled_conversations_fall_back_to_the_id() {
500 let doc = doc_titled("!!!", "aabbccddeeff");
501 assert_eq!(slug_for(&doc), "aabbccdd");
502 }
503
504 #[test]
505 fn message_body_comes_from_text() {
506 let msg = serde_json::json!({
507 "id": "m1",
508 "avatar": "andrew",
509 "timestamp": "2026-08-08T18:11:40-05:00",
510 "text": "Symbolic regression tests using KAN",
511 "markdown": null,
512 "attachment": null
513 });
514
515 let out = message_from_json("m1", &msg);
516
517 assert_eq!(out.avatar, "andrew");
518 assert_eq!(out.body, "Symbolic regression tests using KAN");
519 assert!(out.link.is_none());
520 }
521
522 #[test]
523 fn markdown_attachment_becomes_a_link_with_basename_only() {
524 let msg = serde_json::json!({
525 "id": "m2",
526 "avatar": "gpt5",
527 "timestamp": "2026-08-08T18:11:57-05:00",
528 "text": null,
529 "markdown": "chats/CHAT-20260808-181158.md",
530 "markdown_meta": { "hash": "9f2c", "size": 12, "filename": "CHAT-20260808-181158.md" },
531 "attachment": null
532 });
533
534 let out = message_from_json("m2", &msg);
535
536 assert_eq!(out.link.as_deref(), Some("CHAT-20260808-181158.md"));
537 assert_eq!(out.sha256.as_deref(), Some("9f2c"));
538 assert!(out.body.is_empty());
539 }
540}