Skip to main content

fur_cli/schema/
rebuild.rs

1//! Reconstruct `.fur/` from `chats/`.
2//!
3//! This is the test of whether the architecture actually holds:
4//!
5//! ```text
6//! rm -rf .fur && fur convo
7//! ```
8//!
9//! If the archive comes back, `chats/` has genuinely become the durable format
10//! and `.fur/` is disposable operational state.
11//!
12//! Rebuild is deliberately conservative. It runs when `.fur/` is *absent*. It
13//! refuses to run over an existing `.fur/` without `--force`, because a stale
14//! or partially-synced `chats/` would otherwise destroy live local state
15//! (active conversation, cursor position, avatar emoji) on an ordinary command.
16
17use 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
26/// Written by `fur lock`, removed by `fur unlock`, never itself encrypted.
27///
28/// It lives in `chats/` rather than `.fur/` on purpose: once `.fur/` is
29/// disposable, a lock flag stored there could be deleted while the ciphertext
30/// remained, leaving rebuild to parse encrypted bytes as front matter.
31pub const LOCK_SENTINEL: &str = ".fur-locked";
32
33/// Encrypted password verifier, kept beside the data rather than inside the
34/// disposable index.
35pub const LOCK_CHECK: &str = ".fur-lockcheck";
36
37/// What a directory looks like before any command touches it.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ProjectState {
40    /// Neither `.fur/` nor any conversation document.
41    Empty,
42    /// `.fur/` present and usable.
43    Indexed,
44    /// Conversation documents exist but `.fur/` does not — a copied archive.
45    Unindexed,
46    /// `chats/` is encrypted; nothing may be parsed until unlocked.
47    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
79/// Every `chats/<slug>/convo.md` in the project.
80///
81/// Spines are found by *content*, not by filename: any `.md` directly inside a
82/// conversation folder that parses as a document counts. Filenames stay
83/// cosmetic so a user can rename freely without breaking rebuild.
84pub 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
119/// Cheap check: front matter opening plus a conversation id, without paying to
120/// parse every long-form document in the folder.
121fn 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
129/// Rebuild `.fur/` from every conversation document under `chats/`.
130pub 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                // One malformed document costs that document, not the archive.
172                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
209/// Add one already-validated canonical conversation document to the operational
210/// `.fur/` index without rebuilding or disturbing the rest of the project.
211///
212/// viceroy: this is the narrow import seam used by publication registries. The conversation
213/// directory remains authoritative; the JSON written here is only its local
214/// projection.
215pub 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    // Links are stored as basenames relative to the conversation folder;
321    // `.fur/` wants a path relative to the project root.
322    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
396/// Avatar *names* come from the documents; the emoji mapping and `main` are
397/// reader preferences that no document can carry. A placeholder is written so
398/// nothing crashes, and onboarding refines it later.
399fn 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}