Skip to main content

fur_cli/schema/
bridge.rs

1//! Bridge between the `.fur/` JSON store and the canonical Markdown document.
2//!
3//! Phase A: this reads `.fur/` and *writes* `chats/<slug>/convo.md`. It never
4//! writes back into `.fur/` and never deletes anything, so running the export
5//! is non-destructive and repeatable.
6//!
7//! Output layout:
8//!
9//! ```text
10//! chats/
11//! └── schema-proposal-changes-fur/
12//!     ├── convo.md                    <- spine
13//!     └── CHAT-20260808-181158.md     <- long-form body, copied in
14//! ```
15
16use 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
24/// Build a `FurDocument` from a conversation in `.fur/`.
25///
26/// Message bodies come from `text`. When a message has a `markdown`
27/// attachment instead, the body stays empty and the file is recorded as a
28/// `link=` for the caller to copy alongside the spine.
29pub 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 = convo["tags"]
41        .as_array()
42        .map(|arr| {
43            arr.iter()
44                .filter_map(|v| v.as_str().map(|s| s.to_string()))
45                .collect()
46        })
47        .unwrap_or_default();
48
49    let msg_ids: Vec<String> = convo["messages"]
50        .as_array()
51        .map(|arr| {
52            arr.iter()
53                .filter_map(|v| v.as_str().map(|s| s.to_string()))
54                .collect()
55        })
56        .unwrap_or_default();
57
58    for mid in &msg_ids {
59        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
60
61        // A message file that has gone missing is reported, not silently
62        // dropped: losing a message during export would be invisible data loss.
63        let msg: Value = read_json(&msg_path)
64            .map_err(|e| format!("message {} unreadable ({})", short(mid), e))?;
65
66        doc.messages.push(message_from_json(mid, &msg));
67    }
68
69    Ok(doc)
70}
71
72fn message_from_json(mid: &str, msg: &Value) -> FurMessage {
73    let mut out = FurMessage::new(
74        msg["id"].as_str().unwrap_or(mid),
75        msg["avatar"].as_str().unwrap_or("unknown"),
76        msg["timestamp"].as_str().unwrap_or(""),
77    );
78
79    if let Some(text) = msg["text"].as_str() {
80        out = out.with_body(text);
81    }
82
83    if let Some(md_raw) = msg["markdown"].as_str() {
84        let filename = Path::new(md_raw)
85            .file_name()
86            .and_then(|f| f.to_str())
87            .unwrap_or(md_raw)
88            .to_string();
89
90        // Prefer the hash already stored by the schema; fall back to reading
91        // the file. Absent both, emit the link without a hash rather than
92        // fabricating one.
93        let hash = msg["markdown_meta"]["hash"]
94            .as_str()
95            .map(|s| s.to_string())
96            .or_else(|| hash_file(Path::new(md_raw)));
97
98        out = out.with_link(filename, hash);
99    }
100
101    if let Some(img) = msg["attachment"].as_str() {
102        out.img = Path::new(img)
103            .file_name()
104            .and_then(|f| f.to_str())
105            .map(|s| s.to_string());
106    }
107
108    out
109}
110
111/// Source paths of every long-form file referenced by a conversation, taken
112/// from `.fur/` rather than from the document, since the document stores only
113/// basenames.
114pub fn linked_sources(fur_dir: &Path, tid: &str) -> Result<Vec<PathBuf>, String> {
115    let convo: Value = read_json(&fur_dir.join("threads").join(format!("{}.json", tid)))?;
116
117    let mut out = Vec::new();
118
119    let msg_ids = convo["messages"].as_array().cloned().unwrap_or_default();
120
121    for mid in msg_ids.iter().filter_map(|v| v.as_str()) {
122        let msg_path = fur_dir.join("messages").join(format!("{}.json", mid));
123        let Ok(msg) = read_json(&msg_path) else {
124            continue;
125        };
126
127        for key in ["markdown", "attachment"] {
128            if let Some(raw) = msg[key].as_str() {
129                out.push(resolve_relative(raw));
130            }
131        }
132    }
133
134    Ok(out)
135}
136
137/// Write `chats/<slug>/convo.md` plus copies of every linked file.
138///
139/// Returns the folder that was written. Refuses to clobber an existing spine
140/// unless `force` is set — an export that silently overwrote a hand-edited
141/// document would be exactly the data loss this whole design is trying to end.
142pub fn write_conversation_folder(
143    project_root: &Path,
144    doc: &FurDocument,
145    linked: &[PathBuf],
146    force: bool,
147) -> Result<PathBuf, String> {
148    let folder = project_root.join("chats").join(slug_for(doc));
149    let spine = folder.join("convo.md");
150
151    if spine.exists() && !force {
152        return Err(format!(
153            "{} already exists (use --force to overwrite)",
154            spine.display()
155        ));
156    }
157
158    fs::create_dir_all(&folder).map_err(|e| format!("cannot create {}: {}", folder.display(), e))?;
159
160    fs::write(&spine, serialize(doc))
161        .map_err(|e| format!("cannot write {}: {}", spine.display(), e))?;
162
163    for src in linked {
164        let Some(name) = src.file_name() else {
165            continue;
166        };
167        let dst = folder.join(name);
168
169        if !src.exists() {
170            eprintln!("⚠ linked file missing, not copied: {}", src.display());
171            continue;
172        }
173        if dst.exists() && !force {
174            continue;
175        }
176        if same_file(src, &dst) {
177            continue;
178        }
179
180        fs::copy(src, &dst)
181            .map_err(|e| format!("cannot copy {} → {}: {}", src.display(), dst.display(), e))?;
182    }
183
184    Ok(folder)
185}
186
187/// Keep `chats/<slug>/` in step with `.fur/` for one conversation.
188///
189/// Dual-write: `.fur/` remains authoritative and is written first by the
190/// calling command; this regenerates the document from it. Regeneration rather
191/// than append is deliberate — `serialize` is byte-stable, so rewriting the
192/// whole spine is idempotent and the two stores cannot drift apart.
193pub fn sync_conversation(
194    project_root: &Path,
195    fur_dir: &Path,
196    tid: &str,
197) -> Result<PathBuf, String> {
198    let doc = document_from_thread(fur_dir, tid)?;
199    let linked = linked_sources(fur_dir, tid)?;
200
201    // A retitled conversation wants a new slug. Move the existing folder
202    // instead of leaving an orphan behind under the old name.
203    let chats = project_root.join("chats");
204    let desired = slug_for(&doc);
205
206    if let Some(existing) = find_folder_for(&chats, &doc.conversation_id) {
207        let same = existing.file_name().and_then(|f| f.to_str()) == Some(desired.as_str());
208        if !same {
209            let target = chats.join(&desired);
210            if !target.exists() {
211                fs::rename(&existing, &target).map_err(|e| {
212                    format!("cannot rename {} → {}: {}", existing.display(), target.display(), e)
213                })?;
214            }
215        }
216    }
217
218    write_conversation_folder(project_root, &doc, &linked, true)
219}
220
221/// Best-effort sync of the active conversation, for commands that have just
222/// mutated `.fur/`.
223///
224/// Never panics and never blocks the command that called it: a failure to
225/// update `chats/` is a warning, because `.fur/` is still the source of truth
226/// in this phase and the user's write has already succeeded.
227pub fn sync_active() {
228    if crate::security::state::is_locked() {
229        return;
230    }
231
232    let fur_dir = Path::new(".fur");
233    let index_path = fur_dir.join("index.json");
234
235    if !index_path.exists() {
236        return;
237    }
238
239    let Some(content) = crate::security::io::read_text_file(&index_path) else {
240        return;
241    };
242
243    let Ok(index) = serde_json::from_str::<Value>(&content) else {
244        return;
245    };
246
247    let Some(tid) = index["active_thread"].as_str() else {
248        return;
249    };
250
251    if tid.is_empty() {
252        return;
253    }
254
255    if let Err(e) = sync_conversation(Path::new("."), fur_dir, tid) {
256        eprintln!("⚠ chats/ not updated: {}", e);
257    }
258}
259
260fn same_file(a: &Path, b: &Path) -> bool {
261    match (fs::canonicalize(a), fs::canonicalize(b)) {
262        (Ok(x), Ok(y)) => x == y,
263        _ => false,
264    }
265}
266
267/// Folder the active conversation's files belong in, creating it if needed.
268///
269/// `chat` needs this *before* the message exists, so it is derived from the
270/// thread metadata rather than by looking for a folder that may not be there
271/// yet.
272pub fn active_folder(project_root: &Path) -> Option<PathBuf> {
273    let fur_dir = project_root.join(".fur");
274
275    let index: Value = read_json(&fur_dir.join("index.json")).ok()?;
276    let tid = index["active_thread"].as_str()?;
277
278    if tid.is_empty() {
279        return None;
280    }
281
282    let doc = document_from_thread(&fur_dir, tid).ok()?;
283    let chats = project_root.join("chats");
284
285    // Honour an existing folder even if the title has since changed.
286    let folder = find_folder_for(&chats, &doc.conversation_id)
287        .unwrap_or_else(|| chats.join(slug_for(&doc)));
288
289    fs::create_dir_all(&folder).ok()?;
290
291    Some(folder)
292}
293
294/// Top-level `chats/*.md` files that are already present inside a conversation
295/// folder. Rebuild only walks `chats/<slug>/`, so these are invisible to it
296/// while still being encrypted by `lock` — orphans in every sense.
297pub fn find_orphans(project_root: &Path) -> Vec<PathBuf> {
298    let chats = project_root.join("chats");
299
300    let Ok(entries) = fs::read_dir(&chats) else {
301        return Vec::new();
302    };
303
304    let mut adopted: Vec<String> = Vec::new();
305
306    for entry in entries.flatten() {
307        let dir = entry.path();
308        if !dir.is_dir() {
309            continue;
310        }
311        if let Ok(files) = fs::read_dir(&dir) {
312            for f in files.flatten() {
313                if let Some(name) = f.path().file_name().and_then(|n| n.to_str()) {
314                    adopted.push(name.to_string());
315                }
316            }
317        }
318    }
319
320    let Ok(entries) = fs::read_dir(&chats) else {
321        return Vec::new();
322    };
323
324    entries
325        .flatten()
326        .map(|e| e.path())
327        .filter(|p| p.is_file())
328        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
329        .filter(|p| {
330            p.file_name()
331                .and_then(|n| n.to_str())
332                .map(|n| adopted.iter().any(|a| a == n))
333                .unwrap_or(false)
334        })
335        .collect()
336}
337
338/// Locate an existing conversation folder by its id suffix, so a rename can
339/// find the folder even though the slug has changed.
340fn find_folder_for(chats: &Path, conversation_id: &str) -> Option<PathBuf> {
341    let suffix = format!("-{}", short(conversation_id));
342
343    let entries = fs::read_dir(chats).ok()?;
344
345    for entry in entries.flatten() {
346        let path = entry.path();
347        if !path.is_dir() {
348            continue;
349        }
350
351        let name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
352        if name.ends_with(&suffix) || name == short(conversation_id) {
353            return Some(path);
354        }
355    }
356
357    None
358}
359
360/// Folder name for a conversation: readable title slug, disambiguated by the
361/// short id so two conversations with the same title cannot collide.
362pub fn slug_for(doc: &FurDocument) -> String {
363    let base = slugify(&doc.title);
364    let short = short(&doc.conversation_id);
365
366    if base.is_empty() {
367        return short.to_string();
368    }
369
370    format!("{}-{}", base, short)
371}
372
373fn slugify(raw: &str) -> String {
374    let mut out = String::new();
375    let mut last_dash = true; // suppress a leading dash
376
377    for c in raw.chars() {
378        if c.is_ascii_alphanumeric() {
379            out.push(c.to_ascii_lowercase());
380            last_dash = false;
381        } else if !last_dash {
382            out.push('-');
383            last_dash = true;
384        }
385
386        if out.len() >= 60 {
387            break;
388        }
389    }
390
391    out.trim_matches('-').to_string()
392}
393
394fn short(id: &str) -> &str {
395    if id.len() >= 8 {
396        &id[..8]
397    } else {
398        id
399    }
400}
401
402/// `.fur` stores attachment paths either absolute or relative to project root.
403fn resolve_relative(raw: &str) -> PathBuf {
404    let p = PathBuf::from(raw);
405    if p.is_absolute() {
406        p
407    } else {
408        PathBuf::from(".").join(raw)
409    }
410}
411
412fn hash_file(path: &Path) -> Option<String> {
413    let resolved = resolve_relative(path.to_str()?);
414    let bytes = fs::read(&resolved).ok()?;
415
416    let mut hasher = Sha256::new();
417    hasher.update(&bytes);
418
419    Some(format!("{:x}", hasher.finalize()))
420}
421
422fn read_json(path: &Path) -> Result<Value, String> {
423    let content = crate::security::io::read_text_file(path)
424        .ok_or_else(|| format!("cannot read {}", path.display()))?;
425
426    serde_json::from_str(&content).map_err(|e| format!("invalid JSON in {}: {}", path.display(), e))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::schema::document::FurDocument;
433
434    fn doc_titled(title: &str, id: &str) -> FurDocument {
435        FurDocument::new(id, title, "2026-08-08T18:11:40-05:00")
436    }
437
438    #[test]
439    fn slug_is_readable_and_disambiguated() {
440        let doc = doc_titled(
441            "Schema Proposal Changes FUR",
442            "8f0c4a2e-1b3d-4f5a-9c7e-2d8b6a1f0e33",
443        );
444        assert_eq!(slug_for(&doc), "schema-proposal-changes-fur-8f0c4a2e");
445    }
446
447    #[test]
448    fn slug_collapses_punctuation_and_spaces() {
449        let doc = doc_titled("GPT-5   Experiments!! (v2)", "aabbccddeeff");
450        assert_eq!(slug_for(&doc), "gpt-5-experiments-v2-aabbccdd");
451    }
452
453    #[test]
454    fn untitled_conversations_fall_back_to_the_id() {
455        let doc = doc_titled("!!!", "aabbccddeeff");
456        assert_eq!(slug_for(&doc), "aabbccdd");
457    }
458
459    #[test]
460    fn message_body_comes_from_text() {
461        let msg = serde_json::json!({
462            "id": "m1",
463            "avatar": "andrew",
464            "timestamp": "2026-08-08T18:11:40-05:00",
465            "text": "Symbolic regression tests using KAN",
466            "markdown": null,
467            "attachment": null
468        });
469
470        let out = message_from_json("m1", &msg);
471
472        assert_eq!(out.avatar, "andrew");
473        assert_eq!(out.body, "Symbolic regression tests using KAN");
474        assert!(out.link.is_none());
475    }
476
477    #[test]
478    fn markdown_attachment_becomes_a_link_with_basename_only() {
479        let msg = serde_json::json!({
480            "id": "m2",
481            "avatar": "gpt5",
482            "timestamp": "2026-08-08T18:11:57-05:00",
483            "text": null,
484            "markdown": "chats/CHAT-20260808-181158.md",
485            "markdown_meta": { "hash": "9f2c", "size": 12, "filename": "CHAT-20260808-181158.md" },
486            "attachment": null
487        });
488
489        let out = message_from_json("m2", &msg);
490
491        assert_eq!(out.link.as_deref(), Some("CHAT-20260808-181158.md"));
492        assert_eq!(out.sha256.as_deref(), Some("9f2c"));
493        assert!(out.body.is_empty());
494    }
495}