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
209fn write_message(
210 fur_dir: &Path,
211 root: &Path,
212 folder: &Path,
213 msg: &crate::schema::document::FurMessage,
214) -> Result<(), String> {
215 let markdown = msg
218 .link
219 .as_ref()
220 .map(|name| relative_to_root(root, &folder.join(name)));
221
222 let markdown_meta = match (&markdown, &msg.sha256) {
223 (Some(path), hash) => {
224 let size = fs::metadata(root.join(path)).map(|m| m.len()).ok();
225 json!({
226 "hash": hash,
227 "size": size,
228 "filename": Path::new(path).file_name().and_then(|f| f.to_str())
229 })
230 }
231 _ => Value::Null,
232 };
233
234 let attachment = msg
235 .img
236 .as_ref()
237 .map(|name| relative_to_root(root, &folder.join(name)));
238
239 let value = json!({
240 "id": msg.id,
241 "avatar": msg.avatar,
242 "timestamp": msg.ts,
243 "text": if msg.body.is_empty() { Value::Null } else { json!(msg.body) },
244 "markdown": markdown,
245 "markdown_meta": markdown_meta,
246 "attachment": attachment,
247 "parent": Value::Null,
248 "children": [],
249 "branches": [],
250 "schema_version": SCHEMA_VERSION
251 });
252
253 let path = fur_dir.join("messages").join(format!("{}.json", msg.id));
254
255 fs::write(&path, pretty(&value)).map_err(|e| format!("cannot write {}: {}", path.display(), e))
256}
257
258fn write_thread(fur_dir: &Path, doc: &FurDocument) -> Result<(), String> {
259 let value = json!({
260 "id": doc.conversation_id,
261 "created_at": doc.created_at,
262 "messages": doc.messages.iter().map(|m| m.id.clone()).collect::<Vec<_>>(),
263 "tags": doc.tags,
264 "title": doc.title,
265 "schema_version": SCHEMA_VERSION
266 });
267
268 let path = fur_dir
269 .join("threads")
270 .join(format!("{}.json", doc.conversation_id));
271
272 fs::write(&path, pretty(&value)).map_err(|e| format!("cannot write {}: {}", path.display(), e))
273}
274
275fn write_index(fur_dir: &Path, thread_ids: &[String]) -> Result<(), String> {
276 let value = json!({
277 "threads": thread_ids,
278 "active_thread": thread_ids.last(),
279 "current_message": Value::Null,
280 "created_at": chrono::Utc::now().to_rfc3339(),
281 "schema_version": SCHEMA_VERSION
282 });
283
284 let path = fur_dir.join("index.json");
285
286 fs::write(&path, pretty(&value)).map_err(|e| format!("cannot write {}: {}", path.display(), e))
287}
288
289fn write_avatars(fur_dir: &Path, summary: &RebuildSummary) -> Result<(), String> {
293 let path = fur_dir.join("avatars.json");
294
295 if path.exists() {
296 return Ok(());
297 }
298
299 let mut map = serde_json::Map::new();
300
301 if let Some(main) = &summary.guessed_main {
302 map.insert("main".to_string(), json!(main));
303 }
304
305 for name in &summary.avatars {
306 map.insert(name.clone(), json!("🐾"));
307 }
308
309 fs::write(&path, pretty(&Value::Object(map)))
310 .map_err(|e| format!("cannot write {}: {}", path.display(), e))
311}
312
313fn relative_to_root(root: &Path, path: &Path) -> String {
314 path.strip_prefix(root)
315 .unwrap_or(path)
316 .to_string_lossy()
317 .replace('\\', "/")
318}
319
320fn pretty(value: &Value) -> String {
321 serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string())
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn scratch(name: &str) -> PathBuf {
329 let dir = std::env::temp_dir().join(name);
330 let _ = fs::remove_dir_all(&dir);
331 fs::create_dir_all(dir.join("chats")).unwrap();
332 dir
333 }
334
335 fn spine(root: &Path, slug: &str, body: &str) {
336 let folder = root.join("chats").join(slug);
337 fs::create_dir_all(&folder).unwrap();
338 fs::write(folder.join("convo.md"), body).unwrap();
339 }
340
341 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";
342
343 #[test]
344 fn detects_an_unindexed_archive() {
345 let root = scratch("fur_rb_state");
346 spine(&root, "hello-14e87a09", DOC);
347 assert_eq!(detect_state(&root), ProjectState::Unindexed);
348 }
349
350 #[test]
351 fn detects_empty_and_locked() {
352 let root = scratch("fur_rb_empty");
353 assert_eq!(detect_state(&root), ProjectState::Empty);
354
355 fs::write(root.join("chats").join(LOCK_SENTINEL), "1").unwrap();
356 assert_eq!(detect_state(&root), ProjectState::Locked);
357 }
358
359 #[test]
360 fn rebuilds_threads_messages_and_index() {
361 let root = scratch("fur_rb_build");
362 spine(&root, "hello-14e87a09", DOC);
363
364 let summary = rebuild(&root, false).unwrap();
365
366 assert_eq!(summary.conversations, 1);
367 assert_eq!(summary.messages, 2);
368 assert_eq!(summary.guessed_main.as_deref(), Some("andrew"));
369
370 assert!(root
371 .join(".fur/threads/14e87a09-d24e-4f4e-be73-7c76b4a15f5f.json")
372 .exists());
373 assert!(root.join(".fur/messages/m1.json").exists());
374 assert!(root.join(".fur/messages/m2.json").exists());
375
376 let index: Value =
377 serde_json::from_str(&fs::read_to_string(root.join(".fur/index.json")).unwrap())
378 .unwrap();
379 assert_eq!(index["threads"].as_array().unwrap().len(), 1);
380 }
381
382 #[test]
383 fn refuses_to_clobber_live_state() {
384 let root = scratch("fur_rb_guard");
385 spine(&root, "hello-14e87a09", DOC);
386 rebuild(&root, false).unwrap();
387
388 assert!(rebuild(&root, false).is_err());
389 assert!(rebuild(&root, true).is_ok());
390 }
391
392 #[test]
393 fn refuses_to_parse_a_locked_archive() {
394 let root = scratch("fur_rb_locked");
395 spine(&root, "hello-14e87a09", DOC);
396 fs::write(root.join("chats").join(LOCK_SENTINEL), "1").unwrap();
397
398 let err = rebuild(&root, false).unwrap_err();
399 assert!(err.contains("locked"), "got: {}", err);
400 }
401
402 #[test]
403 fn one_bad_document_does_not_sink_the_archive() {
404 let root = scratch("fur_rb_partial");
405 spine(&root, "hello-14e87a09", DOC);
406 spine(&root, "broken-deadbeef", "---\nconversation_id: x\n---\n\nnope\n");
407
408 let summary = rebuild(&root, false).unwrap();
409
410 assert_eq!(summary.conversations, 1);
411 assert_eq!(summary.skipped.len(), 1);
412 }
413}