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
209fn write_message(
210    fur_dir: &Path,
211    root: &Path,
212    folder: &Path,
213    msg: &crate::schema::document::FurMessage,
214) -> Result<(), String> {
215    // Links are stored as basenames relative to the conversation folder;
216    // `.fur/` wants a path relative to the project root.
217    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
289/// Avatar *names* come from the documents; the emoji mapping and `main` are
290/// reader preferences that no document can carry. A placeholder is written so
291/// nothing crashes, and onboarding refines it later.
292fn 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}