Skip to main content

fur_cli/schema/
document.rs

1//! Canonical FUR conversation document: serialize / parse.
2//!
3//! `.fur/` is operational state. This module defines the durable format: a
4//! conversation is a Markdown file that a person can read and a machine can
5//! parse, and `tests/roundtrip.rs` proves the trip is lossless.
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//!
20//! # Lineage (schema 2)
21//!
22//! `parents` and `children` record edges to *other conversations*, by
23//! `conversation_id`. Together they form a directed graph across the archive:
24//! a methodology discussion informs a forecast, which informs a revision.
25//!
26//! Three properties are deliberate, and each has a reason:
27//!
28//! - **Edges are ids, never folder slugs.** `bridge::slug_for` embeds the
29//!   title, and `sync_conversation` renames the folder when the title changes.
30//!   A slug-based edge would break on the first rename, and would not survive
31//!   registry import at all.
32//! - **Dangling edges are legal.** A conversation imported from a registry can
33//!   reference conversations the importer has never seen. An unresolvable edge
34//!   is *partial knowledge*, not corruption — unlike a missing `link=` file,
35//!   which is a broken document.
36//! - **Cycles are legal here, and handled by traversal.** Two people can
37//!   independently publish A→B and B→A; the cycle only exists once both are in
38//!   one archive. Rejecting it at parse time would make that archive
39//!   unrepresentable. Every walker carries a visited set — which it needs for
40//!   diamonds regardless.
41//!
42//! Self-reference *is* rejected: it is never meaningful and always a mistake.
43
44use std::fmt::Write as _;
45
46/// Version of the *document* format. Deliberately an integer, and deliberately
47/// separate from `schema::SCHEMA_VERSION` (which versions the `.fur/` JSON).
48/// Integers also avoid the lexicographic trap of comparing "0.10" to "0.3".
49///
50/// 1 → 2: added `parents` / `children`, and inline flow sequences in front
51/// matter. A schema-1 build rejects unknown front-matter keys outright, so
52/// documents carrying lineage must announce themselves as 2.
53pub const FUR_DOC_SCHEMA: u32 = 2;
54
55/// Oldest document version this build still reads.
56pub const FUR_DOC_SCHEMA_MIN: u32 = 1;
57
58const MARKER_OPEN: &str = "<!-- fur:msg";
59const MARKER_CLOSE: &str = "-->";
60
61/// One message in a conversation.
62///
63/// `body` is the inline text. When `link` is set the body lives in a sibling
64/// file and `body` is normally empty — the two are not mutually exclusive, but
65/// a linked message with inline text is treated as a caption.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct FurMessage {
68    pub id: String,
69    pub avatar: String,
70    pub ts: String,
71    /// Long-form file, relative to the conversation folder.
72    pub link: Option<String>,
73    /// SHA-256 of the linked file, for drift detection and repair.
74    pub sha256: Option<String>,
75    /// Image attachment, relative to the conversation folder.
76    pub img: Option<String>,
77    pub body: String,
78}
79
80impl FurMessage {
81    pub fn new(id: impl Into<String>, avatar: impl Into<String>, ts: impl Into<String>) -> Self {
82        FurMessage {
83            id: id.into(),
84            avatar: avatar.into(),
85            ts: ts.into(),
86            link: None,
87            sha256: None,
88            img: None,
89            body: String::new(),
90        }
91    }
92
93    pub fn with_body(mut self, body: impl Into<String>) -> Self {
94        self.body = body.into();
95        self
96    }
97
98    pub fn with_link(mut self, link: impl Into<String>, sha256: Option<String>) -> Self {
99        self.link = Some(link.into());
100        self.sha256 = sha256;
101        self
102    }
103}
104
105/// A whole conversation: front matter plus an ordered list of messages.
106///
107/// Messages are flat and their order is positional — the order they appear in
108/// the document *is* the sequence. Structure between conversations lives in
109/// `parents` / `children`; there is no structure within one.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct FurDocument {
112    pub schema: u32,
113    pub conversation_id: String,
114    pub title: String,
115    pub created_at: String,
116    pub updated_at: Option<String>,
117    pub tags: Vec<String>,
118    /// Conversations this one draws on, by `conversation_id`.
119    pub parents: Vec<String>,
120    /// Conversations that draw on this one, by `conversation_id`.
121    pub children: Vec<String>,
122    pub messages: Vec<FurMessage>,
123}
124
125impl FurDocument {
126    pub fn new(
127        conversation_id: impl Into<String>,
128        title: impl Into<String>,
129        created_at: impl Into<String>,
130    ) -> Self {
131        FurDocument {
132            schema: FUR_DOC_SCHEMA,
133            conversation_id: conversation_id.into(),
134            title: title.into(),
135            created_at: created_at.into(),
136            updated_at: None,
137            tags: Vec::new(),
138            parents: Vec::new(),
139            children: Vec::new(),
140            messages: Vec::new(),
141        }
142    }
143
144    /// Version this document must be written as.
145    ///
146    /// A schema-1 document with no lineage stays schema 1, so archives written
147    /// before this change re-serialize byte-identically. The moment it gains an
148    /// edge it becomes schema 2, because a schema-1 reader would reject the new
149    /// keys as unknown rather than reporting a version it cannot handle.
150    pub fn effective_schema(&self) -> u32 {
151        if self.parents.is_empty() && self.children.is_empty() {
152            self.schema
153        } else {
154            self.schema.max(2)
155        }
156    }
157
158    /// Check the invariants `parse` enforces, for code that builds documents in
159    /// memory rather than reading them.
160    ///
161    /// `serialize` does not validate — it is infallible by design — so a
162    /// command that mutates lineage should call this before writing.
163    pub fn validate(&self) -> Result<(), String> {
164        check_refs(&self.parents, &self.conversation_id, "parents")?;
165        check_refs(&self.children, &self.conversation_id, "children")?;
166
167        let mut seen: Vec<&str> = Vec::with_capacity(self.messages.len());
168        for msg in &self.messages {
169            if msg.id.is_empty() {
170                return Err("message has an empty id".to_string());
171            }
172            if seen.contains(&msg.id.as_str()) {
173                return Err(format!("duplicate message id '{}'", msg.id));
174            }
175            seen.push(&msg.id);
176        }
177
178        Ok(())
179    }
180}
181
182// ======================================================
183//  LINEAGE HELPERS
184// ======================================================
185
186/// Reject the things that are never meaningful, whatever the source.
187///
188/// Dangling and cyclic references pass: see the module docs for why.
189fn check_refs(refs: &[String], conversation_id: &str, field: &str) -> Result<(), String> {
190    for reference in refs {
191        if reference.trim().is_empty() {
192            return Err(format!("empty conversation reference in '{}'", field));
193        }
194        if reference == conversation_id {
195            return Err(format!(
196                "conversation {} lists itself in '{}'",
197                conversation_id, field
198            ));
199        }
200    }
201    Ok(())
202}
203
204/// Sort and de-duplicate, so the same edge set always produces the same bytes.
205///
206/// viceroy: this is what makes concurrent edits merge cleanly. Two people
207/// adding different parents to the same document produce a Git conflict whose
208/// resolution is a union, in an order neither of them chose.
209fn canonical_refs(refs: &[String]) -> Vec<String> {
210    let mut out: Vec<String> = refs.iter().map(|r| r.trim().to_string()).collect();
211    out.sort();
212    out.dedup();
213    out
214}
215
216// ======================================================
217//  SERIALIZE
218// ======================================================
219
220/// Render a document to its canonical text form.
221///
222/// Canonical means byte-stable: `serialize(parse(serialize(d))) == serialize(d)`.
223/// That property is what `tests/roundtrip.rs` checks, and it is the whole point
224/// of the storage inversion.
225pub fn serialize(doc: &FurDocument) -> String {
226    let mut out = String::new();
227
228    out.push_str("---\n");
229    let _ = writeln!(out, "fur_schema: {}", doc.effective_schema());
230    let _ = writeln!(out, "conversation_id: {}", yaml_scalar(&doc.conversation_id));
231    let _ = writeln!(out, "title: {}", yaml_scalar(&doc.title));
232    let _ = writeln!(out, "created_at: {}", yaml_scalar(&doc.created_at));
233    if let Some(updated) = &doc.updated_at {
234        let _ = writeln!(out, "updated_at: {}", yaml_scalar(updated));
235    }
236    write_scalar_list(&mut out, "tags", &doc.tags);
237    write_scalar_list(&mut out, "parents", &canonical_refs(&doc.parents));
238    write_scalar_list(&mut out, "children", &canonical_refs(&doc.children));
239    out.push_str("---\n");
240
241    for msg in &doc.messages {
242        out.push('\n');
243        out.push_str(&render_marker(msg));
244        out.push('\n');
245
246        let body = msg.body.trim_matches('\n');
247        if !body.is_empty() {
248            out.push('\n');
249            out.push_str(&escape_body(body));
250            out.push('\n');
251        }
252    }
253
254    out
255}
256
257/// Empty lists render inline as `[]`; non-empty ones as a block sequence.
258///
259/// The keys are always emitted, empty or not. They are part of the format's
260/// visible surface: someone hand-editing a `convo.md` should be able to see
261/// that lineage exists and fill it in without consulting documentation.
262fn write_scalar_list(out: &mut String, key: &str, values: &[String]) {
263    if values.is_empty() {
264        let _ = writeln!(out, "{}: []", key);
265        return;
266    }
267
268    let _ = writeln!(out, "{}:", key);
269    for value in values {
270        let _ = writeln!(out, "  - {}", yaml_scalar(value));
271    }
272}
273
274fn render_marker(msg: &FurMessage) -> String {
275    let mut s = String::from(MARKER_OPEN);
276    let _ = write!(s, " id={}", attr_value(&msg.id));
277    let _ = write!(s, " avatar={}", attr_value(&msg.avatar));
278    let _ = write!(s, " ts={}", attr_value(&msg.ts));
279    if let Some(link) = &msg.link {
280        let _ = write!(s, " link={}", attr_value(link));
281    }
282    if let Some(hash) = &msg.sha256 {
283        let _ = write!(s, " sha256={}", attr_value(hash));
284    }
285    if let Some(img) = &msg.img {
286        let _ = write!(s, " img={}", attr_value(img));
287    }
288    s.push(' ');
289    s.push_str(MARKER_CLOSE);
290    s
291}
292
293/// Quote an attribute value only when it must be quoted, so the common case
294/// stays readable and the output stays deterministic.
295fn attr_value(raw: &str) -> String {
296    let needs_quotes = raw.is_empty()
297        || raw
298            .chars()
299            .any(|c| c.is_whitespace() || c == '"' || c == '\\' || c == '=');
300
301    if !needs_quotes {
302        return raw.to_string();
303    }
304
305    let mut out = String::with_capacity(raw.len() + 2);
306    out.push('"');
307    for c in raw.chars() {
308        if c == '"' || c == '\\' {
309            out.push('\\');
310        }
311        out.push(c);
312    }
313    out.push('"');
314    out
315}
316
317/// Same idea for the YAML front matter. Always-quote would also be
318/// deterministic, but bare scalars read better in Explorer and Obsidian.
319fn yaml_scalar(raw: &str) -> String {
320    let safe = !raw.is_empty()
321        && !raw.starts_with(' ')
322        && !raw.ends_with(' ')
323        && raw
324            .chars()
325            .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.' | '+' | ':' | '/'))
326        && !raw.contains(": ")
327        && !raw.starts_with('-')
328        && !raw.starts_with('[');
329
330    if safe {
331        return raw.to_string();
332    }
333
334    let mut out = String::with_capacity(raw.len() + 2);
335    out.push('"');
336    for c in raw.chars() {
337        if c == '"' || c == '\\' {
338            out.push('\\');
339        }
340        out.push(c);
341    }
342    out.push('"');
343    out
344}
345
346/// A body line that looks like a marker would silently split the message in
347/// two on the next read. Escaping is reversible: one backslash is added on
348/// write, one is removed on read.
349fn escape_body(body: &str) -> String {
350    body.lines()
351        .map(|line| {
352            if is_escapable_marker_line(line) {
353                format!("\\{}", line)
354            } else {
355                line.to_string()
356            }
357        })
358        .collect::<Vec<_>>()
359        .join("\n")
360}
361
362fn unescape_body(body: &str) -> String {
363    body.lines()
364        .map(|line| {
365            if let Some(rest) = line.strip_prefix('\\') {
366                if is_escapable_marker_line(rest) {
367                    return rest.to_string();
368                }
369            }
370            line.to_string()
371        })
372        .collect::<Vec<_>>()
373        .join("\n")
374}
375
376/// True for `<!-- fur:msg ...` and for any already-escaped run of backslashes
377/// in front of it, so escaping can nest without losing information.
378fn is_escapable_marker_line(line: &str) -> bool {
379    let stripped = line.trim_start_matches('\\');
380    stripped.starts_with(MARKER_OPEN)
381}
382
383// ======================================================
384//  PARSE
385// ======================================================
386
387/// Which block sequence the front-matter parser is currently inside.
388///
389/// viceroy: this replaces the `in_tags` boolean, which could only ever track
390/// one list. Three keys now take sequences.
391#[derive(Clone, Copy, PartialEq, Eq)]
392enum ListKey {
393    Tags,
394    Parents,
395    Children,
396}
397
398impl ListKey {
399    fn from_key(key: &str) -> Option<Self> {
400        match key {
401            "tags" => Some(ListKey::Tags),
402            "parents" => Some(ListKey::Parents),
403            "children" => Some(ListKey::Children),
404            _ => None,
405        }
406    }
407
408    fn name(self) -> &'static str {
409        match self {
410            ListKey::Tags => "tags",
411            ListKey::Parents => "parents",
412            ListKey::Children => "children",
413        }
414    }
415}
416
417/// Parse a canonical document. Errors are strings so this can sit under the
418/// existing `eprintln!` style without dragging in an error crate.
419pub fn parse(text: &str) -> Result<FurDocument, String> {
420    let lines: Vec<&str> = text.lines().collect();
421
422    let (front_matter, body_start) = split_front_matter(&lines)?;
423    let mut doc = parse_front_matter(&front_matter)?;
424    doc.messages = parse_messages(&lines[body_start..])?;
425
426    // Canonicalise on read as well as on write, so a hand-edited document with
427    // duplicate or unsorted edges still satisfies `parse(serialize(d)) == d`.
428    doc.parents = canonical_refs(&doc.parents);
429    doc.children = canonical_refs(&doc.children);
430
431    doc.validate()?;
432
433    Ok(doc)
434}
435
436/// Returns the front-matter lines and the index of the first body line.
437fn split_front_matter<'a>(lines: &[&'a str]) -> Result<(Vec<&'a str>, usize), String> {
438    if lines.first().map(|l| l.trim_end()) != Some("---") {
439        return Err("missing front matter: document must start with '---'".to_string());
440    }
441
442    for (i, line) in lines.iter().enumerate().skip(1) {
443        if line.trim_end() == "---" {
444            return Ok((lines[1..i].to_vec(), i + 1));
445        }
446    }
447
448    Err("unterminated front matter: no closing '---'".to_string())
449}
450
451fn parse_front_matter(lines: &[&str]) -> Result<FurDocument, String> {
452    let mut schema: Option<u32> = None;
453    let mut conversation_id: Option<String> = None;
454    let mut title: Option<String> = None;
455    let mut created_at: Option<String> = None;
456    let mut updated_at: Option<String> = None;
457    let mut tags: Vec<String> = Vec::new();
458    let mut parents: Vec<String> = Vec::new();
459    let mut children: Vec<String> = Vec::new();
460    let mut current_list: Option<ListKey> = None;
461
462    for line in lines {
463        if line.trim().is_empty() {
464            continue;
465        }
466
467        if let Some(item) = line.trim_start().strip_prefix("- ") {
468            let Some(list) = current_list else {
469                return Err(format!("unexpected list item in front matter: {}", line));
470            };
471            let value = unquote_scalar(item.trim());
472            match list {
473                ListKey::Tags => tags.push(value),
474                ListKey::Parents => parents.push(value),
475                ListKey::Children => children.push(value),
476            }
477            continue;
478        }
479
480        let Some((key, value)) = line.split_once(':') else {
481            return Err(format!("malformed front matter line: {}", line));
482        };
483
484        let key = key.trim();
485        let value = value.trim();
486        current_list = None;
487
488        if let Some(list) = ListKey::from_key(key) {
489            let target = match list {
490                ListKey::Tags => &mut tags,
491                ListKey::Parents => &mut parents,
492                ListKey::Children => &mut children,
493            };
494
495            if value.is_empty() {
496                // A block sequence follows on the next lines.
497                current_list = Some(list);
498            } else if value.starts_with('[') && value.ends_with(']') {
499                *target = parse_flow_sequence(&value[1..value.len() - 1], list.name())?;
500            } else {
501                return Err(format!("unsupported value for '{}': '{}'", list.name(), value));
502            }
503            continue;
504        }
505
506        match key {
507            "fur_schema" => {
508                schema = Some(
509                    value
510                        .parse::<u32>()
511                        .map_err(|_| format!("fur_schema must be an integer, got '{}'", value))?,
512                );
513            }
514            "conversation_id" => conversation_id = Some(unquote_scalar(value)),
515            "title" => title = Some(unquote_scalar(value)),
516            "created_at" => created_at = Some(unquote_scalar(value)),
517            "updated_at" => updated_at = Some(unquote_scalar(value)),
518            _ => return Err(format!("unknown front matter key: '{}'", key)),
519        }
520    }
521
522    let schema = schema.ok_or("front matter missing 'fur_schema'")?;
523    if schema > FUR_DOC_SCHEMA {
524        return Err(format!(
525            "document schema {} is newer than this build supports ({})",
526            schema, FUR_DOC_SCHEMA
527        ));
528    }
529    if schema < FUR_DOC_SCHEMA_MIN {
530        return Err(format!(
531            "document schema {} is older than this build supports ({})",
532            schema, FUR_DOC_SCHEMA_MIN
533        ));
534    }
535
536    Ok(FurDocument {
537        schema,
538        conversation_id: conversation_id.ok_or("front matter missing 'conversation_id'")?,
539        title: title.ok_or("front matter missing 'title'")?,
540        created_at: created_at.ok_or("front matter missing 'created_at'")?,
541        updated_at,
542        tags,
543        parents,
544        children,
545        messages: Vec::new(),
546    })
547}
548
549/// Parse the inside of a `[a, b, "c d"]` flow sequence.
550///
551/// viceroy: schema 1 accepted only the literal `[]` and errored on anything
552/// else inline. Lineage lists are short and read far better on one line in a
553/// diff, so the real thing is parsed now — for `tags` too, which previously
554/// forced block style the moment it had a single entry.
555fn parse_flow_sequence(inner: &str, field: &str) -> Result<Vec<String>, String> {
556    let trimmed = inner.trim();
557    if trimmed.is_empty() {
558        return Ok(Vec::new());
559    }
560
561    let chars: Vec<char> = trimmed.chars().collect();
562    let mut out = Vec::new();
563    let mut current = String::new();
564    let mut quoted = false;
565    let mut closed_quote = false;
566    let mut i = 0;
567
568    while i < chars.len() {
569        let c = chars[i];
570
571        if quoted {
572            if c == '\\' && i + 1 < chars.len() {
573                current.push(chars[i + 1]);
574                i += 2;
575                continue;
576            }
577            if c == '"' {
578                quoted = false;
579                closed_quote = true;
580                i += 1;
581                continue;
582            }
583            current.push(c);
584            i += 1;
585            continue;
586        }
587
588        match c {
589            '"' if current.trim().is_empty() && !closed_quote => {
590                current.clear();
591                quoted = true;
592                i += 1;
593            }
594            ',' => {
595                out.push(std::mem::take(&mut current).trim().to_string());
596                closed_quote = false;
597                i += 1;
598            }
599            _ => {
600                current.push(c);
601                i += 1;
602            }
603        }
604    }
605
606    if quoted {
607        return Err(format!("unterminated quoted value in '{}'", field));
608    }
609
610    out.push(current.trim().to_string());
611
612    if out.iter().any(|v| v.is_empty()) {
613        return Err(format!("empty entry in '{}' list", field));
614    }
615
616    Ok(out)
617}
618
619fn parse_messages(lines: &[&str]) -> Result<Vec<FurMessage>, String> {
620    let mut messages: Vec<FurMessage> = Vec::new();
621    let mut pending: Option<FurMessage> = None;
622    let mut body: Vec<&str> = Vec::new();
623
624    for line in lines {
625        if is_marker_line(line) {
626            if let Some(mut msg) = pending.take() {
627                msg.body = finish_body(&body);
628                messages.push(msg);
629            }
630            body.clear();
631            pending = Some(parse_marker(line)?);
632            continue;
633        }
634
635        if pending.is_none() {
636            if line.trim().is_empty() {
637                continue;
638            }
639            return Err(format!(
640                "content before the first message marker: {}",
641                line.trim()
642            ));
643        }
644
645        body.push(line);
646    }
647
648    if let Some(mut msg) = pending.take() {
649        msg.body = finish_body(&body);
650        messages.push(msg);
651    }
652
653    Ok(messages)
654}
655
656/// A marker only counts at column zero on its own line. That rule is what lets
657/// `escape_body` be a purely line-level operation.
658fn is_marker_line(line: &str) -> bool {
659    line.starts_with(MARKER_OPEN) && line.trim_end().ends_with(MARKER_CLOSE)
660}
661
662fn finish_body(lines: &[&str]) -> String {
663    let joined = lines.join("\n");
664    unescape_body(joined.trim_matches('\n'))
665}
666
667fn parse_marker(line: &str) -> Result<FurMessage, String> {
668    let trimmed = line.trim_end();
669    let inner = trimmed[MARKER_OPEN.len()..trimmed.len() - MARKER_CLOSE.len()].trim();
670
671    let mut msg = FurMessage::new(String::new(), String::new(), String::new());
672    let mut have_id = false;
673    let mut have_avatar = false;
674    let mut have_ts = false;
675
676    for (key, value) in parse_attributes(inner)? {
677        match key.as_str() {
678            "id" => {
679                msg.id = value;
680                have_id = true;
681            }
682            "avatar" => {
683                msg.avatar = value;
684                have_avatar = true;
685            }
686            "ts" => {
687                msg.ts = value;
688                have_ts = true;
689            }
690            "link" => msg.link = Some(value),
691            "sha256" => msg.sha256 = Some(value),
692            "img" => msg.img = Some(value),
693            other => return Err(format!("unknown marker attribute '{}'", other)),
694        }
695    }
696
697    if !have_id || !have_avatar || !have_ts {
698        return Err(format!(
699            "marker missing required attribute (id, avatar, ts): {}",
700            trimmed
701        ));
702    }
703
704    Ok(msg)
705}
706
707/// Split `key=value key="quoted value"` into pairs. Unquoted values end at the
708/// next whitespace; quoted values support `\"` and `\\`.
709fn parse_attributes(input: &str) -> Result<Vec<(String, String)>, String> {
710    let chars: Vec<char> = input.chars().collect();
711    let mut out = Vec::new();
712    let mut i = 0;
713
714    while i < chars.len() {
715        while i < chars.len() && chars[i].is_whitespace() {
716            i += 1;
717        }
718        if i >= chars.len() {
719            break;
720        }
721
722        let key_start = i;
723        while i < chars.len() && chars[i] != '=' && !chars[i].is_whitespace() {
724            i += 1;
725        }
726        let key: String = chars[key_start..i].iter().collect();
727
728        if i >= chars.len() || chars[i] != '=' {
729            return Err(format!("attribute '{}' is missing '='", key));
730        }
731        i += 1;
732
733        let value = if i < chars.len() && chars[i] == '"' {
734            i += 1;
735            let mut v = String::new();
736            let mut closed = false;
737            while i < chars.len() {
738                if chars[i] == '\\' && i + 1 < chars.len() {
739                    v.push(chars[i + 1]);
740                    i += 2;
741                    continue;
742                }
743                if chars[i] == '"' {
744                    closed = true;
745                    i += 1;
746                    break;
747                }
748                v.push(chars[i]);
749                i += 1;
750            }
751            if !closed {
752                return Err(format!("unterminated quoted value for '{}'", key));
753            }
754            v
755        } else {
756            let value_start = i;
757            while i < chars.len() && !chars[i].is_whitespace() {
758                i += 1;
759            }
760            chars[value_start..i].iter().collect()
761        };
762
763        out.push((key, value));
764    }
765
766    Ok(out)
767}
768
769fn unquote_scalar(raw: &str) -> String {
770    let trimmed = raw.trim();
771    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
772        let inner: Vec<char> = trimmed[1..trimmed.len() - 1].chars().collect();
773        let mut out = String::with_capacity(inner.len());
774        let mut i = 0;
775        while i < inner.len() {
776            if inner[i] == '\\' && i + 1 < inner.len() {
777                out.push(inner[i + 1]);
778                i += 2;
779                continue;
780            }
781            out.push(inner[i]);
782            i += 1;
783        }
784        return out;
785    }
786    trimmed.to_string()
787}
788
789// ======================================================
790//  TESTS
791// ======================================================
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    const A: &str = "aaaaaaaa-1111-4111-8111-111111111111";
798    const B: &str = "bbbbbbbb-2222-4222-8222-222222222222";
799    const C: &str = "cccccccc-3333-4333-8333-333333333333";
800    const D: &str = "dddddddd-4444-4444-8444-444444444444";
801
802    fn sample() -> FurDocument {
803        let mut doc = FurDocument::new(
804            "8f0c4a2e-1b3d-4f5a-9c7e-2d8b6a1f0e33",
805            "Schema Proposal Changes FUR",
806            "2026-08-08T18:11:40-05:00",
807        );
808        doc.updated_at = Some("2026-08-08T19:02:15-05:00".to_string());
809        doc.tags = vec!["schema".to_string(), "architecture".to_string()];
810        doc.messages = vec![
811            FurMessage::new("3a7f9c21", "andrew", "2026-08-08T18:11:40-05:00")
812                .with_body("I think that I may have a better idea..."),
813            FurMessage::new("b2e14d80", "gpt5", "2026-08-08T18:11:57-05:00")
814                .with_link("CHAT-20260808-181158.md", Some("9f2c".to_string())),
815            FurMessage::new("c17aa903", "andrew", "2026-08-08T18:14:02-05:00").with_body("badass"),
816        ];
817        doc
818    }
819
820    fn round_trips(doc: &FurDocument) -> FurDocument {
821        let once = serialize(doc);
822        let parsed = parse(&once).expect("parses");
823        assert_eq!(once, serialize(&parsed), "serialization is not idempotent");
824        parsed
825    }
826
827    #[test]
828    fn round_trip_is_byte_stable() {
829        let parsed = round_trips(&sample());
830        assert_eq!(sample(), parsed);
831    }
832
833    #[test]
834    fn body_containing_a_marker_survives() {
835        let mut doc = FurDocument::new("cid", "Escaping", "2026-08-08T00:00:00Z");
836        let evil = "Here is the format:\n<!-- fur:msg id=fake avatar=x ts=y -->\ndone";
837        doc.messages = vec![FurMessage::new("m1", "andrew", "2026-08-08T00:00:00Z").with_body(evil)];
838
839        let parsed = round_trips(&doc);
840        assert_eq!(parsed.messages.len(), 1);
841        assert_eq!(parsed.messages[0].body, evil);
842    }
843
844    #[test]
845    fn duplicate_ids_are_rejected() {
846        let mut doc = FurDocument::new("cid", "Dupes", "2026-08-08T00:00:00Z");
847        doc.messages = vec![
848            FurMessage::new("same", "andrew", "2026-08-08T00:00:00Z").with_body("one"),
849            FurMessage::new("same", "gpt5", "2026-08-08T00:00:01Z").with_body("two"),
850        ];
851
852        let err = parse(&serialize(&doc)).unwrap_err();
853        assert!(err.contains("duplicate message id"), "got: {}", err);
854    }
855
856    #[test]
857    fn values_with_spaces_round_trip() {
858        let mut doc = FurDocument::new("cid", "Title: with punctuation", "2026-08-08T00:00:00Z");
859        doc.tags = vec!["deep-learning".to_string()];
860        doc.messages = vec![FurMessage::new("m1", "karen from hr", "2026-08-08T00:00:00Z")
861            .with_link("CHAT with spaces.md", None)];
862
863        let parsed = round_trips(&doc);
864        assert_eq!(parsed.title, "Title: with punctuation");
865        assert_eq!(parsed.messages[0].avatar, "karen from hr");
866        assert_eq!(parsed.messages[0].link.as_deref(), Some("CHAT with spaces.md"));
867    }
868
869    #[test]
870    fn missing_front_matter_is_an_error() {
871        assert!(parse("just some markdown\n").is_err());
872    }
873
874    // ---------- lineage ----------
875
876    #[test]
877    fn lineage_round_trips() {
878        let mut doc = FurDocument::new(A, "Business plan", "2026-08-17T00:14:20Z");
879        doc.children = vec![B.to_string(), C.to_string()];
880
881        let parsed = round_trips(&doc);
882        assert_eq!(parsed.children, vec![B.to_string(), C.to_string()]);
883        assert!(parsed.parents.is_empty());
884        assert_eq!(parsed.schema, 2);
885    }
886
887    #[test]
888    fn lineage_is_sorted_and_deduplicated() {
889        let mut doc = FurDocument::new(A, "Unsorted", "2026-08-17T00:14:20Z");
890        doc.parents = vec![C.to_string(), B.to_string(), C.to_string()];
891
892        let parsed = round_trips(&doc);
893        assert_eq!(parsed.parents, vec![B.to_string(), C.to_string()]);
894    }
895
896    #[test]
897    fn self_reference_is_rejected() {
898        let mut doc = FurDocument::new(A, "Ouroboros", "2026-08-17T00:14:20Z");
899        doc.parents = vec![A.to_string()];
900
901        let err = parse(&serialize(&doc)).unwrap_err();
902        assert!(err.contains("lists itself"), "got: {}", err);
903    }
904
905    #[test]
906    fn dangling_references_are_legal() {
907        let mut doc = FurDocument::new(A, "Imported alone", "2026-08-17T00:14:20Z");
908        doc.parents = vec!["ffffffff-0000-4000-8000-000000000000".to_string()];
909
910        let parsed = round_trips(&doc);
911        assert_eq!(parsed.parents.len(), 1);
912    }
913
914    #[test]
915    fn a_cycle_between_two_documents_is_representable() {
916        let mut first = FurDocument::new(A, "First", "2026-08-17T00:14:20Z");
917        first.children = vec![B.to_string()];
918        let mut second = FurDocument::new(B, "Second", "2026-08-17T00:15:20Z");
919        second.children = vec![A.to_string()];
920
921        // Neither document is invalid on its own; the cycle only exists in the
922        // union, and traversal is what has to cope with it.
923        assert_eq!(round_trips(&first).children, vec![B.to_string()]);
924        assert_eq!(round_trips(&second).children, vec![A.to_string()]);
925    }
926
927    #[test]
928    fn a_diamond_is_representable() {
929        let mut top = FurDocument::new(A, "Top", "2026-08-17T00:14:20Z");
930        top.children = vec![B.to_string(), C.to_string()];
931
932        let mut bottom = FurDocument::new(D, "Bottom", "2026-08-17T00:17:20Z");
933        bottom.parents = vec![B.to_string(), C.to_string()];
934
935        assert_eq!(round_trips(&top).children.len(), 2);
936        assert_eq!(round_trips(&bottom).parents.len(), 2);
937    }
938
939    #[test]
940    fn inline_flow_sequences_parse() {
941        let text = format!(
942            "---\nfur_schema: 2\nconversation_id: {}\ntitle: Flow\ncreated_at: 2026-08-17T00:14:20Z\ntags: [research, \"deep learning\"]\nparents: [{}]\nchildren: []\n---\n",
943            A, B
944        );
945
946        let doc = parse(&text).expect("parses");
947        assert_eq!(doc.tags, vec!["research".to_string(), "deep learning".to_string()]);
948        assert_eq!(doc.parents, vec![B.to_string()]);
949        assert!(doc.children.is_empty());
950    }
951
952    #[test]
953    fn block_and_flow_forms_agree() {
954        let flow = format!(
955            "---\nfur_schema: 2\nconversation_id: {}\ntitle: Same\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\nparents: [{}, {}]\nchildren: []\n---\n",
956            A, B, C
957        );
958        let block = format!(
959            "---\nfur_schema: 2\nconversation_id: {}\ntitle: Same\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\nparents:\n  - {}\n  - {}\nchildren: []\n---\n",
960            A, B, C
961        );
962
963        assert_eq!(parse(&flow).unwrap(), parse(&block).unwrap());
964        assert_eq!(serialize(&parse(&flow).unwrap()), serialize(&parse(&block).unwrap()));
965    }
966
967    #[test]
968    fn schema_one_documents_still_parse() {
969        let text = format!(
970            "---\nfur_schema: 1\nconversation_id: {}\ntitle: Old\ncreated_at: 2026-08-08T18:11:40-05:00\ntags: []\n---\n\n<!-- fur:msg id=m1 avatar=andrew ts=2026-08-08T18:11:40-05:00 -->\n\nhello\n",
971            A
972        );
973
974        let doc = parse(&text).expect("parses");
975        assert_eq!(doc.schema, 1);
976        assert!(doc.parents.is_empty());
977        assert_eq!(doc.messages.len(), 1);
978    }
979
980    #[test]
981    fn a_schema_one_document_without_lineage_stays_schema_one() {
982        let mut doc = FurDocument::new(A, "Old", "2026-08-08T18:11:40-05:00");
983        doc.schema = 1;
984        assert!(serialize(&doc).contains("fur_schema: 1"));
985    }
986
987    #[test]
988    fn adding_lineage_upgrades_the_document() {
989        let mut doc = FurDocument::new(A, "Old", "2026-08-08T18:11:40-05:00");
990        doc.schema = 1;
991        doc.parents = vec![B.to_string()];
992        assert!(serialize(&doc).contains("fur_schema: 2"));
993    }
994
995    #[test]
996    fn a_future_schema_is_refused_by_version_not_by_key() {
997        let text = format!(
998            "---\nfur_schema: 3\nconversation_id: {}\ntitle: Future\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\n---\n",
999            A
1000        );
1001
1002        let err = parse(&text).unwrap_err();
1003        assert!(err.contains("newer than this build"), "got: {}", err);
1004    }
1005
1006    #[test]
1007    fn an_empty_reference_is_rejected() {
1008        let text = format!(
1009            "---\nfur_schema: 2\nconversation_id: {}\ntitle: Empty ref\ncreated_at: 2026-08-17T00:14:20Z\ntags: []\nparents: [{}, ]\nchildren: []\n---\n",
1010            A, B
1011        );
1012
1013        let err = parse(&text).unwrap_err();
1014        assert!(err.contains("empty entry"), "got: {}", err);
1015    }
1016}