1use std::collections::BTreeMap;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21use serde_json::{json, Value};
22
23use crate::schema::document::{parse, FurDocument};
24use crate::schema::SCHEMA_VERSION;
25
26pub const LOCK_SENTINEL: &str = ".fur-locked";
32
33pub const LOCK_CHECK: &str = ".fur-lockcheck";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ProjectState {
40 Empty,
42 Indexed,
44 Unindexed,
46 Locked,
48}
49
50#[derive(Debug, Default)]
51pub struct RebuildSummary {
52 pub conversations: usize,
53 pub messages: usize,
54 pub avatars: Vec<String>,
55 pub guessed_main: Option<String>,
56 pub skipped: Vec<String>,
57}
58
59pub fn is_locked_archive(root: &Path) -> bool {
60 root.join("chats").join(LOCK_SENTINEL).exists()
61}
62
63pub fn detect_state(root: &Path) -> ProjectState {
64 if is_locked_archive(root) {
65 return ProjectState::Locked;
66 }
67
68 if root.join(".fur").join("index.json").exists() {
69 return ProjectState::Indexed;
70 }
71
72 if spine_paths(root).is_empty() {
73 ProjectState::Empty
74 } else {
75 ProjectState::Unindexed
76 }
77}
78
79pub fn spine_paths(root: &Path) -> Vec<PathBuf> {
85 let chats = root.join("chats");
86
87 let Ok(entries) = fs::read_dir(&chats) else {
88 return Vec::new();
89 };
90
91 let mut out = Vec::new();
92
93 for entry in entries.flatten() {
94 let dir = entry.path();
95 if !dir.is_dir() {
96 continue;
97 }
98
99 let Ok(files) = fs::read_dir(&dir) else {
100 continue;
101 };
102
103 let mut found: Vec<PathBuf> = files
104 .flatten()
105 .map(|f| f.path())
106 .filter(|p| p.is_file())
107 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
108 .filter(|p| looks_like_spine(p))
109 .collect();
110
111 found.sort();
112 out.append(&mut found);
113 }
114
115 out.sort();
116 out
117}
118
119fn looks_like_spine(path: &Path) -> bool {
122 let Ok(content) = fs::read_to_string(path) else {
123 return false;
124 };
125
126 content.starts_with("---\n") && content.contains("\nconversation_id:")
127}
128
129pub fn rebuild(root: &Path, force: bool) -> Result<RebuildSummary, String> {
131 if is_locked_archive(root) {
132 return Err("🔒 Archive is locked. Run `fur unlock` first.".to_string());
133 }
134
135 let fur_dir = root.join(".fur");
136
137 if fur_dir.join("index.json").exists() && !force {
138 return Err(
139 ".fur/ already exists — refusing to overwrite live state (use --force)".to_string(),
140 );
141 }
142
143 let spines = spine_paths(root);
144 if spines.is_empty() {
145 return Err("no conversation documents found under chats/".to_string());
146 }
147
148 fs::create_dir_all(fur_dir.join("threads"))
149 .map_err(|e| format!("cannot create .fur/threads: {}", e))?;
150 fs::create_dir_all(fur_dir.join("messages"))
151 .map_err(|e| format!("cannot create .fur/messages: {}", e))?;
152
153 let mut summary = RebuildSummary::default();
154 let mut thread_ids: Vec<String> = Vec::new();
155 let mut avatar_counts: BTreeMap<String, usize> = BTreeMap::new();
156
157 for spine in &spines {
158 let text = match fs::read_to_string(spine) {
159 Ok(t) => t,
160 Err(e) => {
161 summary
162 .skipped
163 .push(format!("{} ({})", spine.display(), e));
164 continue;
165 }
166 };
167
168 let doc = match parse(&text) {
169 Ok(d) => d,
170 Err(e) => {
171 summary
173 .skipped
174 .push(format!("{} ({})", spine.display(), e));
175 continue;
176 }
177 };
178
179 let folder = spine.parent().unwrap_or(root);
180
181 for msg in &doc.messages {
182 *avatar_counts.entry(msg.avatar.clone()).or_insert(0) += 1;
183 write_message(&fur_dir, root, folder, msg)?;
184 summary.messages += 1;
185 }
186
187 write_thread(&fur_dir, &doc)?;
188 thread_ids.push(doc.conversation_id.clone());
189 summary.conversations += 1;
190 }
191
192 if thread_ids.is_empty() {
193 return Err("no conversation documents could be parsed".to_string());
194 }
195
196 write_index(&fur_dir, &thread_ids)?;
197
198 summary.avatars = avatar_counts.keys().cloned().collect();
199 summary.guessed_main = avatar_counts
200 .iter()
201 .max_by_key(|(_, count)| **count)
202 .map(|(name, _)| name.clone());
203
204 write_avatars(&fur_dir, &summary)?;
205
206 Ok(summary)
207}
208
209pub fn import_document(
216 root: &Path,
217 folder: &Path,
218 doc: &FurDocument,
219) -> Result<(), String> {
220 let fur_dir = root.join(".fur");
221 let thread_path = fur_dir
222 .join("threads")
223 .join(format!("{}.json", doc.conversation_id));
224
225 if thread_path.exists() {
226 return Err(format!(
227 "conversation {} is already indexed",
228 doc.conversation_id
229 ));
230 }
231
232 for msg in &doc.messages {
233 let message_path = fur_dir.join("messages").join(format!("{}.json", msg.id));
234 if message_path.exists() {
235 return Err(format!("message id {} is already indexed", msg.id));
236 }
237 }
238
239 fs::create_dir_all(fur_dir.join("threads"))
240 .map_err(|e| format!("cannot create .fur/threads: {}", e))?;
241 fs::create_dir_all(fur_dir.join("messages"))
242 .map_err(|e| format!("cannot create .fur/messages: {}", e))?;
243
244 for msg in &doc.messages {
245 write_message(&fur_dir, root, folder, msg)?;
246 }
247 write_thread(&fur_dir, doc)?;
248 append_to_index(&fur_dir, &doc.conversation_id)?;
249 merge_avatars(&fur_dir, doc)?;
250
251 Ok(())
252}
253
254fn append_to_index(fur_dir: &Path, conversation_id: &str) -> Result<(), String> {
255 let path = fur_dir.join("index.json");
256 let mut value: Value = if path.exists() {
257 let content = fs::read_to_string(&path)
258 .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
259 serde_json::from_str(&content)
260 .map_err(|e| format!("invalid JSON in {}: {}", path.display(), e))?
261 } else {
262 json!({
263 "threads": [],
264 "active_thread": Value::Null,
265 "current_message": Value::Null,
266 "created_at": chrono::Utc::now().to_rfc3339(),
267 "schema_version": SCHEMA_VERSION
268 })
269 };
270
271 let threads = value["threads"]
272 .as_array_mut()
273 .ok_or("index.json has no threads array")?;
274 if threads.iter().any(|item| item.as_str() == Some(conversation_id)) {
275 return Err(format!("conversation {} is already indexed", conversation_id));
276 }
277 threads.push(json!(conversation_id));
278 value["active_thread"] = json!(conversation_id);
279 value["current_message"] = Value::Null;
280
281 fs::write(&path, pretty(&value))
282 .map_err(|e| format!("cannot write {}: {}", path.display(), e))
283}
284
285fn merge_avatars(fur_dir: &Path, doc: &FurDocument) -> Result<(), String> {
286 let path = fur_dir.join("avatars.json");
287 let mut value: Value = if path.exists() {
288 let content = fs::read_to_string(&path)
289 .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
290 serde_json::from_str(&content)
291 .map_err(|e| format!("invalid JSON in {}: {}", path.display(), e))?
292 } else {
293 json!({})
294 };
295
296 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
297 for msg in &doc.messages {
298 *counts.entry(msg.avatar.clone()).or_insert(0) += 1;
299 if value.get(&msg.avatar).is_none() {
300 value[msg.avatar.as_str()] = json!("🐾");
301 }
302 }
303
304 if value.get("main").and_then(|item| item.as_str()).is_none() {
305 if let Some((name, _)) = counts.iter().max_by_key(|(_, count)| **count) {
306 value["main"] = json!(name);
307 }
308 }
309
310 fs::write(&path, pretty(&value))
311 .map_err(|e| format!("cannot write {}: {}", path.display(), e))
312}
313
314fn write_message(
315 fur_dir: &Path,
316 root: &Path,
317 folder: &Path,
318 msg: &crate::schema::document::FurMessage,
319) -> Result<(), String> {
320 let markdown = msg
323 .link
324 .as_ref()
325 .map(|name| relative_to_root(root, &folder.join(name)));
326
327 let markdown_meta = match (&markdown, &msg.sha256) {
328 (Some(path), hash) => {
329 let size = fs::metadata(root.join(path)).map(|m| m.len()).ok();
330 json!({
331 "hash": hash,
332 "size": size,
333 "filename": Path::new(path).file_name().and_then(|f| f.to_str())
334 })
335 }
336 _ => Value::Null,
337 };
338
339 let attachment = msg
340 .img
341 .as_ref()
342 .map(|name| relative_to_root(root, &folder.join(name)));
343
344 let value = json!({
345 "id": msg.id,
346 "avatar": msg.avatar,
347 "timestamp": msg.ts,
348 "text": if msg.body.is_empty() { Value::Null } else { json!(msg.body) },
349 "markdown": markdown,
350 "markdown_meta": markdown_meta,
351 "attachment": attachment,
352 "parent": Value::Null,
353 "children": [],
354 "branches": [],
355 "schema_version": SCHEMA_VERSION
356 });
357
358 let path = fur_dir.join("messages").join(format!("{}.json", msg.id));
359
360 fs::write(&path, pretty(&value)).map_err(|e| format!("cannot write {}: {}", path.display(), e))
361}
362
363fn write_thread(fur_dir: &Path, doc: &FurDocument) -> Result<(), String> {
364 let value = json!({
365 "id": doc.conversation_id,
366 "created_at": doc.created_at,
367 "messages": doc.messages.iter().map(|m| m.id.clone()).collect::<Vec<_>>(),
368 "tags": doc.tags,
369 "parents": doc.parents,
370 "children": doc.children,
371 "title": doc.title,
372 "schema_version": SCHEMA_VERSION
373 });
374
375 let path = fur_dir
376 .join("threads")
377 .join(format!("{}.json", doc.conversation_id));
378
379 fs::write(&path, pretty(&value)).map_err(|e| format!("cannot write {}: {}", path.display(), e))
380}
381
382fn write_index(fur_dir: &Path, thread_ids: &[String]) -> Result<(), String> {
383 let value = json!({
384 "threads": thread_ids,
385 "active_thread": thread_ids.last(),
386 "current_message": Value::Null,
387 "created_at": chrono::Utc::now().to_rfc3339(),
388 "schema_version": SCHEMA_VERSION
389 });
390
391 let path = fur_dir.join("index.json");
392
393 fs::write(&path, pretty(&value)).map_err(|e| format!("cannot write {}: {}", path.display(), e))
394}
395
396fn write_avatars(fur_dir: &Path, summary: &RebuildSummary) -> Result<(), String> {
400 let path = fur_dir.join("avatars.json");
401
402 if path.exists() {
403 return Ok(());
404 }
405
406 let mut map = serde_json::Map::new();
407
408 if let Some(main) = &summary.guessed_main {
409 map.insert("main".to_string(), json!(main));
410 }
411
412 for name in &summary.avatars {
413 map.insert(name.clone(), json!("🐾"));
414 }
415
416 fs::write(&path, pretty(&Value::Object(map)))
417 .map_err(|e| format!("cannot write {}: {}", path.display(), e))
418}
419
420fn relative_to_root(root: &Path, path: &Path) -> String {
421 path.strip_prefix(root)
422 .unwrap_or(path)
423 .to_string_lossy()
424 .replace('\\', "/")
425}
426
427fn pretty(value: &Value) -> String {
428 serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn scratch(name: &str) -> PathBuf {
436 let dir = std::env::temp_dir().join(name);
437 let _ = fs::remove_dir_all(&dir);
438 fs::create_dir_all(dir.join("chats")).unwrap();
439 dir
440 }
441
442 fn spine(root: &Path, slug: &str, body: &str) {
443 let folder = root.join("chats").join(slug);
444 fs::create_dir_all(&folder).unwrap();
445 fs::write(folder.join("convo.md"), body).unwrap();
446 }
447
448 const DOC: &str = "---\nfur_schema: 1\nconversation_id: 14e87a09-d24e-4f4e-be73-7c76b4a15f5f\ntitle: Hello\ncreated_at: 2026-08-09T01:23:14Z\ntags: []\n---\n\n<!-- fur:msg id=m1 avatar=andrew ts=2026-08-09T01:23:58Z -->\n\nfirst\n\n<!-- fur:msg id=m2 avatar=andrew ts=2026-08-09T01:24:58Z -->\n\nsecond\n";
449
450 #[test]
451 fn detects_an_unindexed_archive() {
452 let root = scratch("fur_rb_state");
453 spine(&root, "hello-14e87a09", DOC);
454 assert_eq!(detect_state(&root), ProjectState::Unindexed);
455 }
456
457 #[test]
458 fn detects_empty_and_locked() {
459 let root = scratch("fur_rb_empty");
460 assert_eq!(detect_state(&root), ProjectState::Empty);
461
462 fs::write(root.join("chats").join(LOCK_SENTINEL), "1").unwrap();
463 assert_eq!(detect_state(&root), ProjectState::Locked);
464 }
465
466 #[test]
467 fn rebuilds_threads_messages_and_index() {
468 let root = scratch("fur_rb_build");
469 spine(&root, "hello-14e87a09", DOC);
470
471 let summary = rebuild(&root, false).unwrap();
472
473 assert_eq!(summary.conversations, 1);
474 assert_eq!(summary.messages, 2);
475 assert_eq!(summary.guessed_main.as_deref(), Some("andrew"));
476
477 assert!(root
478 .join(".fur/threads/14e87a09-d24e-4f4e-be73-7c76b4a15f5f.json")
479 .exists());
480 assert!(root.join(".fur/messages/m1.json").exists());
481 assert!(root.join(".fur/messages/m2.json").exists());
482
483 let index: Value =
484 serde_json::from_str(&fs::read_to_string(root.join(".fur/index.json")).unwrap())
485 .unwrap();
486 assert_eq!(index["threads"].as_array().unwrap().len(), 1);
487 }
488
489 #[test]
490 fn refuses_to_clobber_live_state() {
491 let root = scratch("fur_rb_guard");
492 spine(&root, "hello-14e87a09", DOC);
493 rebuild(&root, false).unwrap();
494
495 assert!(rebuild(&root, false).is_err());
496 assert!(rebuild(&root, true).is_ok());
497 }
498
499 #[test]
500 fn refuses_to_parse_a_locked_archive() {
501 let root = scratch("fur_rb_locked");
502 spine(&root, "hello-14e87a09", DOC);
503 fs::write(root.join("chats").join(LOCK_SENTINEL), "1").unwrap();
504
505 let err = rebuild(&root, false).unwrap_err();
506 assert!(err.contains("locked"), "got: {}", err);
507 }
508
509 #[test]
510 fn one_bad_document_does_not_sink_the_archive() {
511 let root = scratch("fur_rb_partial");
512 spine(&root, "hello-14e87a09", DOC);
513 spine(&root, "broken-deadbeef", "---\nconversation_id: x\n---\n\nnope\n");
514
515 let summary = rebuild(&root, false).unwrap();
516
517 assert_eq!(summary.conversations, 1);
518 assert_eq!(summary.skipped.len(), 1);
519 }
520}