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