Skip to main content

fur_cli/schema/
document.rs

1//! Canonical FUR conversation document: serialize / parse.
2//!
3//! Phase A: `.fur/` is still canonical. This module only proves that a
4//! conversation can round-trip losslessly through a human-readable Markdown
5//! document, so that `chats/` can later become the source of truth.
6//!
7//! On-disk layout this format assumes:
8//!
9//! ```text
10//! chats/
11//! └── schema-proposal-changes-fur/
12//!     ├── convo.md                    <- the spine (this format)
13//!     └── CHAT-20260808-181158.md     <- long-form body, referenced by link=
14//! ```
15//!
16//! `link=` is always relative to the conversation folder that holds the spine.
17//! Long-form siblings carry no metadata of their own; the folder is the unit of
18//! self-containment.
19
20use std::fmt::Write as _;
21
22/// Version of the *document* format. Deliberately an integer, and deliberately
23/// separate from `schema::SCHEMA_VERSION` (which versions the `.fur/` JSON).
24/// Integers also avoid the lexicographic trap of comparing "0.10" to "0.3".
25pub const FUR_DOC_SCHEMA: u32 = 1;
26
27const MARKER_OPEN: &str = "<!-- fur:msg";
28const MARKER_CLOSE: &str = "-->";
29
30/// One message in a conversation.
31///
32/// `body` is the inline text. When `link` is set the body lives in a sibling
33/// file and `body` is normally empty — the two are not mutually exclusive, but
34/// a linked message with inline text is treated as a caption.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct FurMessage {
37    pub id: String,
38    pub avatar: String,
39    pub ts: String,
40    /// Long-form file, relative to the conversation folder.
41    pub link: Option<String>,
42    /// SHA-256 of the linked file, for drift detection and repair.
43    pub sha256: Option<String>,
44    /// Image attachment, relative to the conversation folder.
45    pub img: Option<String>,
46    pub body: String,
47}
48
49impl FurMessage {
50    pub fn new(id: impl Into<String>, avatar: impl Into<String>, ts: impl Into<String>) -> Self {
51        FurMessage {
52            id: id.into(),
53            avatar: avatar.into(),
54            ts: ts.into(),
55            link: None,
56            sha256: None,
57            img: None,
58            body: String::new(),
59        }
60    }
61
62    pub fn with_body(mut self, body: impl Into<String>) -> Self {
63        self.body = body.into();
64        self
65    }
66
67    pub fn with_link(mut self, link: impl Into<String>, sha256: Option<String>) -> Self {
68        self.link = Some(link.into());
69        self.sha256 = sha256;
70        self
71    }
72}
73
74/// A whole conversation: front matter plus an ordered list of messages.
75///
76/// There is no `parent` field yet — absence means the conversation is flat.
77/// When branching is switched on, `parent=<id>` appears on messages that have
78/// one and every document written today stays valid.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct FurDocument {
81    pub schema: u32,
82    pub conversation_id: String,
83    pub title: String,
84    pub created_at: String,
85    pub updated_at: Option<String>,
86    pub tags: Vec<String>,
87    pub messages: Vec<FurMessage>,
88}
89
90impl FurDocument {
91    pub fn new(
92        conversation_id: impl Into<String>,
93        title: impl Into<String>,
94        created_at: impl Into<String>,
95    ) -> Self {
96        FurDocument {
97            schema: FUR_DOC_SCHEMA,
98            conversation_id: conversation_id.into(),
99            title: title.into(),
100            created_at: created_at.into(),
101            updated_at: None,
102            tags: Vec::new(),
103            messages: Vec::new(),
104        }
105    }
106}
107
108// ======================================================
109//  SERIALIZE
110// ======================================================
111
112/// Render a document to its canonical text form.
113///
114/// Canonical means byte-stable: `serialize(parse(serialize(d))) == serialize(d)`.
115/// That property is what `tests/roundtrip.rs` checks, and it is the whole point
116/// of Phase A.
117pub fn serialize(doc: &FurDocument) -> String {
118    let mut out = String::new();
119
120    out.push_str("---\n");
121    let _ = writeln!(out, "fur_schema: {}", doc.schema);
122    let _ = writeln!(out, "conversation_id: {}", yaml_scalar(&doc.conversation_id));
123    let _ = writeln!(out, "title: {}", yaml_scalar(&doc.title));
124    let _ = writeln!(out, "created_at: {}", yaml_scalar(&doc.created_at));
125    if let Some(updated) = &doc.updated_at {
126        let _ = writeln!(out, "updated_at: {}", yaml_scalar(updated));
127    }
128    if doc.tags.is_empty() {
129        out.push_str("tags: []\n");
130    } else {
131        out.push_str("tags:\n");
132        for tag in &doc.tags {
133            let _ = writeln!(out, "  - {}", yaml_scalar(tag));
134        }
135    }
136    out.push_str("---\n");
137
138    for msg in &doc.messages {
139        out.push('\n');
140        out.push_str(&render_marker(msg));
141        out.push('\n');
142
143        let body = msg.body.trim_matches('\n');
144        if !body.is_empty() {
145            out.push('\n');
146            out.push_str(&escape_body(body));
147            out.push('\n');
148        }
149    }
150
151    out
152}
153
154fn render_marker(msg: &FurMessage) -> String {
155    let mut s = String::from(MARKER_OPEN);
156    let _ = write!(s, " id={}", attr_value(&msg.id));
157    let _ = write!(s, " avatar={}", attr_value(&msg.avatar));
158    let _ = write!(s, " ts={}", attr_value(&msg.ts));
159    if let Some(link) = &msg.link {
160        let _ = write!(s, " link={}", attr_value(link));
161    }
162    if let Some(hash) = &msg.sha256 {
163        let _ = write!(s, " sha256={}", attr_value(hash));
164    }
165    if let Some(img) = &msg.img {
166        let _ = write!(s, " img={}", attr_value(img));
167    }
168    s.push(' ');
169    s.push_str(MARKER_CLOSE);
170    s
171}
172
173/// Quote an attribute value only when it must be quoted, so the common case
174/// stays readable and the output stays deterministic.
175fn attr_value(raw: &str) -> String {
176    let needs_quotes = raw.is_empty()
177        || raw
178            .chars()
179            .any(|c| c.is_whitespace() || c == '"' || c == '\\' || c == '=');
180
181    if !needs_quotes {
182        return raw.to_string();
183    }
184
185    let mut out = String::with_capacity(raw.len() + 2);
186    out.push('"');
187    for c in raw.chars() {
188        if c == '"' || c == '\\' {
189            out.push('\\');
190        }
191        out.push(c);
192    }
193    out.push('"');
194    out
195}
196
197/// Same idea for the YAML front matter. Always-quote would also be
198/// deterministic, but bare scalars read better in Explorer and Obsidian.
199fn yaml_scalar(raw: &str) -> String {
200    let safe = !raw.is_empty()
201        && !raw.starts_with(' ')
202        && !raw.ends_with(' ')
203        && raw
204            .chars()
205            .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '+' | ':' | '/'))
206        && !raw.contains(": ")
207        && !raw.starts_with('-');
208
209    if safe {
210        return raw.to_string();
211    }
212
213    let mut out = String::with_capacity(raw.len() + 2);
214    out.push('"');
215    for c in raw.chars() {
216        if c == '"' || c == '\\' {
217            out.push('\\');
218        }
219        out.push(c);
220    }
221    out.push('"');
222    out
223}
224
225/// A body line that looks like a marker would silently split the message in
226/// two on the next read. Escaping is reversible: one backslash is added on
227/// write, one is removed on read.
228fn escape_body(body: &str) -> String {
229    body.lines()
230        .map(|line| {
231            if is_escapable_marker_line(line) {
232                format!("\\{}", line)
233            } else {
234                line.to_string()
235            }
236        })
237        .collect::<Vec<_>>()
238        .join("\n")
239}
240
241fn unescape_body(body: &str) -> String {
242    body.lines()
243        .map(|line| {
244            if let Some(rest) = line.strip_prefix('\\') {
245                if is_escapable_marker_line(rest) {
246                    return rest.to_string();
247                }
248            }
249            line.to_string()
250        })
251        .collect::<Vec<_>>()
252        .join("\n")
253}
254
255/// True for `<!-- fur:msg ...` and for any already-escaped run of backslashes
256/// in front of it, so escaping can nest without losing information.
257fn is_escapable_marker_line(line: &str) -> bool {
258    let stripped = line.trim_start_matches('\\');
259    stripped.starts_with(MARKER_OPEN)
260}
261
262// ======================================================
263//  PARSE
264// ======================================================
265
266/// Parse a canonical document. Errors are strings so this can sit under the
267/// existing `eprintln!` style without dragging in an error crate.
268pub fn parse(text: &str) -> Result<FurDocument, String> {
269    let lines: Vec<&str> = text.lines().collect();
270
271    let (front_matter, body_start) = split_front_matter(&lines)?;
272    let mut doc = parse_front_matter(&front_matter)?;
273    doc.messages = parse_messages(&lines[body_start..])?;
274
275    let mut seen: Vec<&str> = Vec::with_capacity(doc.messages.len());
276    for msg in &doc.messages {
277        if msg.id.is_empty() {
278            return Err("message marker has an empty id".to_string());
279        }
280        if seen.contains(&msg.id.as_str()) {
281            return Err(format!("duplicate message id '{}'", msg.id));
282        }
283        seen.push(&msg.id);
284    }
285
286    Ok(doc)
287}
288
289/// Returns the front-matter lines and the index of the first body line.
290fn split_front_matter<'a>(lines: &[&'a str]) -> Result<(Vec<&'a str>, usize), String> {
291    if lines.first().map(|l| l.trim_end()) != Some("---") {
292        return Err("missing front matter: document must start with '---'".to_string());
293    }
294
295    for (i, line) in lines.iter().enumerate().skip(1) {
296        if line.trim_end() == "---" {
297            return Ok((lines[1..i].to_vec(), i + 1));
298        }
299    }
300
301    Err("unterminated front matter: no closing '---'".to_string())
302}
303
304fn parse_front_matter(lines: &[&str]) -> Result<FurDocument, String> {
305    let mut schema: Option<u32> = None;
306    let mut conversation_id: Option<String> = None;
307    let mut title: Option<String> = None;
308    let mut created_at: Option<String> = None;
309    let mut updated_at: Option<String> = None;
310    let mut tags: Vec<String> = Vec::new();
311    let mut in_tags = false;
312
313    for line in lines {
314        if line.trim().is_empty() {
315            continue;
316        }
317
318        if let Some(item) = line.trim_start().strip_prefix("- ") {
319            if !in_tags {
320                return Err(format!("unexpected list item in front matter: {}", line));
321            }
322            tags.push(unquote_scalar(item.trim()));
323            continue;
324        }
325
326        let Some((key, value)) = line.split_once(':') else {
327            return Err(format!("malformed front matter line: {}", line));
328        };
329
330        let key = key.trim();
331        let value = value.trim();
332        in_tags = false;
333
334        match key {
335            "fur_schema" => {
336                schema = Some(
337                    value
338                        .parse::<u32>()
339                        .map_err(|_| format!("fur_schema must be an integer, got '{}'", value))?,
340                );
341            }
342            "conversation_id" => conversation_id = Some(unquote_scalar(value)),
343            "title" => title = Some(unquote_scalar(value)),
344            "created_at" => created_at = Some(unquote_scalar(value)),
345            "updated_at" => updated_at = Some(unquote_scalar(value)),
346            "tags" => {
347                if value == "[]" || value.is_empty() {
348                    in_tags = value.is_empty();
349                } else {
350                    return Err(format!("unsupported inline tags value: '{}'", value));
351                }
352            }
353            _ => return Err(format!("unknown front matter key: '{}'", key)),
354        }
355    }
356
357    let schema = schema.ok_or("front matter missing 'fur_schema'")?;
358    if schema > FUR_DOC_SCHEMA {
359        return Err(format!(
360            "document schema {} is newer than this build supports ({})",
361            schema, FUR_DOC_SCHEMA
362        ));
363    }
364
365    Ok(FurDocument {
366        schema,
367        conversation_id: conversation_id.ok_or("front matter missing 'conversation_id'")?,
368        title: title.ok_or("front matter missing 'title'")?,
369        created_at: created_at.ok_or("front matter missing 'created_at'")?,
370        updated_at,
371        tags,
372        messages: Vec::new(),
373    })
374}
375
376fn parse_messages(lines: &[&str]) -> Result<Vec<FurMessage>, String> {
377    let mut messages: Vec<FurMessage> = Vec::new();
378    let mut pending: Option<FurMessage> = None;
379    let mut body: Vec<&str> = Vec::new();
380
381    for line in lines {
382        if is_marker_line(line) {
383            if let Some(mut msg) = pending.take() {
384                msg.body = finish_body(&body);
385                messages.push(msg);
386            }
387            body.clear();
388            pending = Some(parse_marker(line)?);
389            continue;
390        }
391
392        if pending.is_none() {
393            if line.trim().is_empty() {
394                continue;
395            }
396            return Err(format!(
397                "content before the first message marker: {}",
398                line.trim()
399            ));
400        }
401
402        body.push(line);
403    }
404
405    if let Some(mut msg) = pending.take() {
406        msg.body = finish_body(&body);
407        messages.push(msg);
408    }
409
410    Ok(messages)
411}
412
413/// A marker only counts at column zero on its own line. That rule is what lets
414/// `escape_body` be a purely line-level operation.
415fn is_marker_line(line: &str) -> bool {
416    line.starts_with(MARKER_OPEN) && line.trim_end().ends_with(MARKER_CLOSE)
417}
418
419fn finish_body(lines: &[&str]) -> String {
420    let joined = lines.join("\n");
421    unescape_body(joined.trim_matches('\n'))
422}
423
424fn parse_marker(line: &str) -> Result<FurMessage, String> {
425    let trimmed = line.trim_end();
426    let inner = trimmed[MARKER_OPEN.len()..trimmed.len() - MARKER_CLOSE.len()].trim();
427
428    let mut msg = FurMessage::new(String::new(), String::new(), String::new());
429    let mut have_id = false;
430    let mut have_avatar = false;
431    let mut have_ts = false;
432
433    for (key, value) in parse_attributes(inner)? {
434        match key.as_str() {
435            "id" => {
436                msg.id = value;
437                have_id = true;
438            }
439            "avatar" => {
440                msg.avatar = value;
441                have_avatar = true;
442            }
443            "ts" => {
444                msg.ts = value;
445                have_ts = true;
446            }
447            "link" => msg.link = Some(value),
448            "sha256" => msg.sha256 = Some(value),
449            "img" => msg.img = Some(value),
450            other => return Err(format!("unknown marker attribute '{}'", other)),
451        }
452    }
453
454    if !have_id || !have_avatar || !have_ts {
455        return Err(format!(
456            "marker missing required attribute (id, avatar, ts): {}",
457            trimmed
458        ));
459    }
460
461    Ok(msg)
462}
463
464/// Split `key=value key="quoted value"` into pairs. Unquoted values end at the
465/// next whitespace; quoted values support `\"` and `\\`.
466fn parse_attributes(input: &str) -> Result<Vec<(String, String)>, String> {
467    let chars: Vec<char> = input.chars().collect();
468    let mut out = Vec::new();
469    let mut i = 0;
470
471    while i < chars.len() {
472        while i < chars.len() && chars[i].is_whitespace() {
473            i += 1;
474        }
475        if i >= chars.len() {
476            break;
477        }
478
479        let key_start = i;
480        while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
481            i += 1;
482        }
483        let key: String = chars[key_start..i].iter().collect();
484
485        if i >= chars.len() || chars[i] != '=' {
486            return Err(format!("attribute '{}' is missing '='", key));
487        }
488        i += 1;
489
490        let value = if i < chars.len() && chars[i] == '"' {
491            i += 1;
492            let mut v = String::new();
493            let mut closed = false;
494            while i < chars.len() {
495                if chars[i] == '\\' && i + 1 < chars.len() {
496                    v.push(chars[i + 1]);
497                    i += 2;
498                    continue;
499                }
500                if chars[i] == '"' {
501                    closed = true;
502                    i += 1;
503                    break;
504                }
505                v.push(chars[i]);
506                i += 1;
507            }
508            if !closed {
509                return Err(format!("unterminated quoted value for '{}'", key));
510            }
511            v
512        } else {
513            let value_start = i;
514            while i < chars.len() && !chars[i].is_whitespace() {
515                i += 1;
516            }
517            chars[value_start..i].iter().collect()
518        };
519
520        out.push((key, value));
521    }
522
523    Ok(out)
524}
525
526fn unquote_scalar(raw: &str) -> String {
527    let trimmed = raw.trim();
528    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
529        let inner: Vec<char> = trimmed[1..trimmed.len() - 1].chars().collect();
530        let mut out = String::with_capacity(inner.len());
531        let mut i = 0;
532        while i < inner.len() {
533            if inner[i] == '\\' && i + 1 < inner.len() {
534                out.push(inner[i + 1]);
535                i += 2;
536                continue;
537            }
538            out.push(inner[i]);
539            i += 1;
540        }
541        return out;
542    }
543    trimmed.to_string()
544}
545
546// ======================================================
547//  TESTS
548// ======================================================
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    fn sample() -> FurDocument {
555        let mut doc = FurDocument::new(
556            "8f0c4a2e-1b3d-4f5a-9c7e-2d8b6a1f0e33",
557            "Schema Proposal Changes FUR",
558            "2026-08-08T18:11:40-05:00",
559        );
560        doc.updated_at = Some("2026-08-08T19:02:15-05:00".to_string());
561        doc.tags = vec!["schema".to_string(), "architecture".to_string()];
562        doc.messages = vec![
563            FurMessage::new("3a7f9c21", "andrew", "2026-08-08T18:11:40-05:00")
564                .with_body("I think that I may have a better idea..."),
565            FurMessage::new("b2e14d80", "gpt5", "2026-08-08T18:11:57-05:00")
566                .with_link("CHAT-20260808-181158.md", Some("9f2c".to_string())),
567            FurMessage::new("c17aa903", "andrew", "2026-08-08T18:14:02-05:00").with_body("badass"),
568        ];
569        doc
570    }
571
572    #[test]
573    fn round_trip_is_byte_stable() {
574        let once = serialize(&sample());
575        let parsed = parse(&once).expect("parses");
576        assert_eq!(once, serialize(&parsed));
577        assert_eq!(sample(), parsed);
578    }
579
580    #[test]
581    fn body_containing_a_marker_survives() {
582        let mut doc = FurDocument::new("cid", "Escaping", "2026-08-08T00:00:00Z");
583        let evil = "Here is the format:\n<!-- fur:msg id=fake avatar=x ts=y -->\ndone";
584        doc.messages = vec![FurMessage::new("m1", "andrew", "2026-08-08T00:00:00Z").with_body(evil)];
585
586        let text = serialize(&doc);
587        let parsed = parse(&text).expect("parses");
588
589        assert_eq!(parsed.messages.len(), 1);
590        assert_eq!(parsed.messages[0].body, evil);
591        assert_eq!(text, serialize(&parsed));
592    }
593
594    #[test]
595    fn duplicate_ids_are_rejected() {
596        let mut doc = FurDocument::new("cid", "Dupes", "2026-08-08T00:00:00Z");
597        doc.messages = vec![
598            FurMessage::new("same", "andrew", "2026-08-08T00:00:00Z").with_body("one"),
599            FurMessage::new("same", "gpt5", "2026-08-08T00:00:01Z").with_body("two"),
600        ];
601
602        let err = parse(&serialize(&doc)).unwrap_err();
603        assert!(err.contains("duplicate message id"), "got: {}", err);
604    }
605
606    #[test]
607    fn values_with_spaces_round_trip() {
608        let mut doc = FurDocument::new("cid", "Title: with punctuation", "2026-08-08T00:00:00Z");
609        doc.tags = vec!["deep-learning".to_string()];
610        doc.messages = vec![FurMessage::new("m1", "karen from hr", "2026-08-08T00:00:00Z")
611            .with_link("CHAT with spaces.md", None)];
612
613        let text = serialize(&doc);
614        let parsed = parse(&text).expect("parses");
615
616        assert_eq!(parsed.title, "Title: with punctuation");
617        assert_eq!(parsed.messages[0].avatar, "karen from hr");
618        assert_eq!(parsed.messages[0].link.as_deref(), Some("CHAT with spaces.md"));
619        assert_eq!(text, serialize(&parsed));
620    }
621
622    #[test]
623    fn missing_front_matter_is_an_error() {
624        assert!(parse("just some markdown\n").is_err());
625    }
626}