Skip to main content

dbmd_core/
parser.rs

1//! `parser` — read and write db.md markdown files.
2//!
3//! Parses the YAML frontmatter block, the markdown body, wiki-links, standard
4//! markdown links, `##` sections, and the structured sections of the `DB.md`
5//! config file. Also the atomic writer that round-trips a file while
6//! preserving the operator-edited body verbatim and emitting frontmatter in
7//! canonical key order.
8//!
9//! Strict on required fields, lenient on unknowns: any frontmatter key the
10//! spec doesn't recognize is preserved in [`Frontmatter::extra`] as ambient
11//! context and round-tripped untouched.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use chrono::{DateTime, FixedOffset};
17use serde_norway::{Mapping, Value};
18
19/// The two canonical layer folder names. A path is "content" / a wiki-link is
20/// "full-path" only when it resolves under one of these.
21const LAYER_DIRS: [&str; 2] = ["sources", "records"];
22
23/// Errors produced while parsing a markdown file or the `DB.md` config.
24#[derive(Debug, thiserror::Error)]
25pub enum ParseError {
26    /// The frontmatter block was not valid YAML. Maps to validate code
27    /// `FM_MALFORMED_YAML`.
28    #[error("malformed YAML frontmatter in {file}: {source}")]
29    MalformedYaml {
30        /// The file whose frontmatter failed to parse.
31        file: PathBuf,
32        /// The underlying YAML error.
33        source: serde_norway::Error,
34    },
35
36    /// The file has no `---`-delimited frontmatter block at its very start.
37    #[error("missing frontmatter block in {file}")]
38    MissingFrontmatter {
39        /// The offending file.
40        file: PathBuf,
41    },
42
43    /// A required field was absent. Maps to validate code `FM_MISSING_TYPE`
44    /// (for `type`) and the per-type required-field codes.
45    #[error("missing required field '{key}' in {file}")]
46    MissingField {
47        /// The file missing the field.
48        file: PathBuf,
49        /// The required key.
50        key: String,
51    },
52
53    /// A timestamp field was not ISO-8601 / RFC3339. Maps to `FM_BAD_TIMESTAMP`.
54    #[error("bad timestamp in field '{key}' of {file}: {value}")]
55    BadTimestamp {
56        /// The file.
57        file: PathBuf,
58        /// The frontmatter key.
59        key: String,
60        /// The unparseable value.
61        value: String,
62    },
63
64    /// An I/O error reading the file.
65    #[error(transparent)]
66    Io(#[from] std::io::Error),
67}
68
69/// The parsed YAML frontmatter of a db.md file.
70///
71/// The universal-contract fields are typed accessors; everything else lands in
72/// [`extra`](Frontmatter::extra) as ambient context (unknown-field passthrough)
73/// and is round-tripped verbatim. The atomic writer re-emits keys in canonical
74/// order: `type`, `id`, `created`, `updated`, `summary` first, then
75/// type-specific fields, then `status` / `tags`.
76#[derive(Debug, Clone, Default, PartialEq)]
77pub struct Frontmatter {
78    /// `type` — required on content files; the primary query key.
79    pub type_: Option<String>,
80    /// `meta-type` — records-only; the epistemic class `fact`/`operational`/
81    /// `conclusion`. Absent ⇒ `fact` (the effective default is applied by the
82    /// index/query layer for record-layer files; sources carry none).
83    pub meta_type: Option<String>,
84    /// `id` — optional; derived from the file path when absent.
85    pub id: Option<String>,
86    /// `created` — RFC3339; required and auto-set on content-file create.
87    pub created: Option<DateTime<FixedOffset>>,
88    /// `updated` — RFC3339; required and auto-maintained on content files.
89    pub updated: Option<DateTime<FixedOffset>>,
90    /// `summary` — the one-line catalog line; required on every content file.
91    pub summary: Option<String>,
92    /// `status` — optional lifecycle state.
93    pub status: Option<String>,
94    /// `tags` — optional flat list of short scalar labels.
95    pub tags: Vec<String>,
96    /// All other frontmatter keys (type-specific + custom), preserved verbatim
97    /// in insertion-stable sorted order. Wiki-link-valued fields keep their raw
98    /// YAML form here; [`Frontmatter::link_fields`] surfaces them as
99    /// [`WikiLink`]s.
100    pub extra: BTreeMap<String, Value>,
101}
102
103/// Does `s` contain a run of at least `min` consecutive ASCII digits? A cheap
104/// guard so [`quote_oversized_integers`] only does real work when an oversized
105/// literal is even possible (`i64::MAX` is 19 digits, `u64::MAX` is 20).
106fn has_long_digit_run(s: &str, min: usize) -> bool {
107    let mut run = 0usize;
108    for b in s.bytes() {
109        if b.is_ascii_digit() {
110            run += 1;
111            if run >= min {
112                return true;
113            }
114        } else {
115            run = 0;
116        }
117    }
118    false
119}
120
121/// True if `s` is a bare decimal integer literal whose magnitude exceeds the
122/// `i64`/`u64` range `serde_norway` can represent losslessly — exactly the
123/// literals it either rejects (`(u64::MAX, u128::MAX]`) or silently truncates to
124/// `f64` (`> u128::MAX`). A canonical (no leading zero) decimal only, so an
125/// octal/leading-zero/typed scalar is never reinterpreted.
126fn is_oversized_int_literal(s: &str) -> bool {
127    let t = s.trim();
128    if t.is_empty() {
129        return false;
130    }
131    let (neg, body) = match t.strip_prefix('-') {
132        Some(b) => (true, b),
133        None => (false, t.strip_prefix('+').unwrap_or(t)),
134    };
135    if body.is_empty() || !body.bytes().all(|b| b.is_ascii_digit() || b == b'_') {
136        return false;
137    }
138    let digits: String = body
139        .bytes()
140        .filter(|b| *b != b'_')
141        .map(|b| b as char)
142        .collect();
143    if digits.is_empty() {
144        return false; // all underscores
145    }
146    // Leading-zero decimals (`007`) are version-ambiguous (octal vs int vs
147    // string); never touch them.
148    if digits.len() > 1 && digits.starts_with('0') {
149        return false;
150    }
151    let canon = if neg { format!("-{digits}") } else { digits };
152    // Fits i64 / u64 → handled losslessly; leave untouched.
153    if canon.parse::<i64>().is_ok() || (!neg && canon.parse::<u64>().is_ok()) {
154        return false;
155    }
156    true
157}
158
159/// Byte index where the scalar VALUE begins on a simple block line
160/// (`key: <value>`, `- <value>`, or `- key: <value>`), or `None` when the line
161/// bears no inline value (a bare `key:` / lone `-` / indent-only line).
162fn scalar_value_start(content: &str) -> Option<usize> {
163    let mut base = content.len() - content.trim_start().len();
164    let mut rest = &content[base..];
165    // Consume leading `- ` block-sequence markers (possibly nested: `- - x`).
166    while let Some(after) = rest.strip_prefix("- ") {
167        base += rest.len() - after.len();
168        let trimmed = after.trim_start_matches(' ');
169        base += after.len() - trimmed.len();
170        rest = trimmed;
171    }
172    if rest.is_empty() || rest == "-" {
173        return None;
174    }
175    // `key: value` — first `:` followed by a space/tab introduces the value.
176    if let Some(colon) = rest.find(':') {
177        let after = &rest[colon + 1..];
178        if after.starts_with(' ') || after.starts_with('\t') {
179            let val = after.trim_start_matches([' ', '\t']);
180            return Some(base + colon + 1 + (after.len() - val.len()));
181        }
182        if after.is_empty() {
183            return None; // `key:` with the value on following (block) lines
184        }
185    }
186    // A bare sequence-item scalar: the value is the whole remainder.
187    Some(base)
188}
189
190/// True if `content` introduces a YAML block scalar (`key: |`, `- >2`, …): the
191/// value region begins with a `|` or `>` indicator. Its body must be skipped by
192/// [`quote_oversized_integers`] so a digit line inside literal text is untouched.
193fn introduces_block_scalar(content: &str) -> bool {
194    match scalar_value_start(content) {
195        Some(start) => {
196            let v = content[start..].trim_start();
197            v.starts_with('|') || v.starts_with('>')
198        }
199        None => false,
200    }
201}
202
203/// Quote an oversized bare-integer value on a single block line, returning the
204/// rewritten line, or `None` if the line carries no such value.
205///
206/// Handles two value shapes:
207/// - a bare scalar value (`key: <int>`, `- <int>`, `- key: <int>`), and
208/// - a single-line flow collection value (`key: [ … ]` / `key: { … }`) holding
209///   one or more oversized integer literals (possibly mixed with in-range ints,
210///   strings, and nested flow collections) — see [`quote_oversized_ints_in_flow`].
211///
212/// In both cases only the offending integer scalar(s) are single-quoted; every
213/// other byte is preserved exactly.
214fn quote_int_value_in_line(content: &str) -> Option<String> {
215    let value_start = scalar_value_start(content)?;
216    let region = &content[value_start..];
217    let value = region.trim_end();
218
219    // Single-line flow collection: scan inside it for oversized int literals.
220    // (A bare scalar never starts with `[`/`{`, so these arms are disjoint.)
221    if value.starts_with('[') || value.starts_with('{') {
222        let trailing = &region[value.len()..];
223        let rewritten = quote_oversized_ints_in_flow(value)?;
224        return Some(format!(
225            "{}{}{}",
226            &content[..value_start],
227            rewritten,
228            trailing
229        ));
230    }
231
232    if !is_oversized_int_literal(value) {
233        return None;
234    }
235    // A pure digit literal contains no `'`, so single-quoting needs no escaping.
236    let trailing = &region[value.len()..];
237    Some(format!(
238        "{}'{}'{}",
239        &content[..value_start],
240        value,
241        trailing
242    ))
243}
244
245/// Scan a single-line YAML flow collection (`[ … ]` / `{ … }`) and single-quote
246/// each oversized bare-integer literal it contains, returning the rewritten flow
247/// text, or `None` when it holds no such literal (so the caller can leave the
248/// line untouched and `changed` stays false for an unaffected file).
249///
250/// The flow grammar is tokenized by its structural characters — `[ ] { } , :` —
251/// at the top level: text between two structural characters (and outside any
252/// single/double quoted scalar) is one plain scalar. A plain scalar whose trimmed
253/// form is an oversized canonical decimal integer (per [`is_oversized_int_literal`])
254/// is wrapped in single quotes; everything else — in-range ints, quoted strings,
255/// floats, booleans, nested collections, the structural punctuation and all
256/// surrounding whitespace — is emitted verbatim. Nested collections and multiple
257/// literals on one line are handled by the same single left-to-right pass.
258fn quote_oversized_ints_in_flow(flow: &str) -> Option<String> {
259    let mut out = String::with_capacity(flow.len() + 2);
260    let mut changed = false;
261    // Byte offset where the current plain-scalar token began (None ⇒ not inside
262    // a plain scalar, e.g. just after a structural char or inside a quote).
263    let mut scalar_start: Option<usize> = None;
264    let bytes = flow.as_bytes();
265    let mut i = 0usize;
266
267    // Flush the plain scalar spanning `[start, end)`: quote it iff it is an
268    // oversized integer literal, otherwise copy it through verbatim.
269    fn flush(out: &mut String, flow: &str, start: usize, end: usize, changed: &mut bool) {
270        let raw = &flow[start..end];
271        let trimmed = raw.trim();
272        if !trimmed.is_empty() && is_oversized_int_literal(trimmed) {
273            // Preserve the token's incidental leading/trailing whitespace; only
274            // the literal itself is quoted. A pure-digit literal contains no `'`,
275            // so single-quoting needs no escaping.
276            let lead = &raw[..raw.len() - raw.trim_start().len()];
277            let tail = &raw[raw.trim_end().len()..];
278            out.push_str(lead);
279            out.push('\'');
280            out.push_str(trimmed);
281            out.push('\'');
282            out.push_str(tail);
283            *changed = true;
284        } else {
285            out.push_str(raw);
286        }
287    }
288
289    while i < bytes.len() {
290        let b = bytes[i];
291        match b {
292            // Quoted scalars: copy through verbatim, skipping their contents so a
293            // structural char or digit run inside a string is never reinterpreted.
294            b'\'' | b'"' => {
295                if let Some(start) = scalar_start.take() {
296                    flush(&mut out, flow, start, i, &mut changed);
297                }
298                let quote = b;
299                let str_start = i;
300                i += 1;
301                while i < bytes.len() {
302                    if bytes[i] == quote {
303                        // A doubled single-quote (`''`) is an escaped quote inside
304                        // a single-quoted YAML scalar, not the closing delimiter.
305                        if quote == b'\'' && i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
306                            i += 2;
307                            continue;
308                        }
309                        // A backslash-escaped quote inside a double-quoted scalar
310                        // does not close it.
311                        if quote == b'"' && bytes[i - 1] == b'\\' {
312                            i += 1;
313                            continue;
314                        }
315                        i += 1;
316                        break;
317                    }
318                    i += 1;
319                }
320                out.push_str(&flow[str_start..i]);
321            }
322            // Structural characters end the current plain scalar and are copied
323            // through. `:` separates a flow-mapping key from its value; `,`
324            // separates entries; brackets/braces open or close a (possibly
325            // nested) collection.
326            b'[' | b']' | b'{' | b'}' | b',' | b':' => {
327                if let Some(start) = scalar_start.take() {
328                    flush(&mut out, flow, start, i, &mut changed);
329                }
330                out.push(b as char);
331                i += 1;
332            }
333            _ => {
334                if scalar_start.is_none() {
335                    scalar_start = Some(i);
336                }
337                i += 1;
338            }
339        }
340    }
341    if let Some(start) = scalar_start.take() {
342        flush(&mut out, flow, start, bytes.len(), &mut changed);
343    }
344
345    if changed {
346        Some(out)
347    } else {
348        None
349    }
350}
351
352/// Pre-quote bare integer literals beyond the `i64`/`u64` range so they parse as
353/// STRING scalars and round-trip verbatim.
354///
355/// `serde_norway` (no arbitrary-precision) cannot represent such an integer: it
356/// rejects `(u64::MAX, u128::MAX]` as a hard parse error and silently truncates
357/// `> u128::MAX` to `f64` (`999…9` → `1e39` on the next re-emit) — corrupting an
358/// imported numeric ID and breaking the SPEC guarantee that unknown fields
359/// round-trip byte-for-byte. Quoting them up front makes them string-valued (the
360/// type narrows from number to string, but no data is destroyed).
361///
362/// Conservative: only a canonical decimal integer beyond `i64`/`u64` is quoted —
363/// whether it appears as a bare value (`key: <int>` / `- <int>` / `- key: <int>`)
364/// or as a scalar inside a single-line flow collection (`key: [ … ]` /
365/// `key: { … }`, including nested collections and mixed/multiple literals); block
366/// scalars are tracked and never touched; anything already in range, quoted, or
367/// not a bare integer is left exactly as written.
368fn quote_oversized_integers(yaml: &str) -> std::borrow::Cow<'_, str> {
369    if !has_long_digit_run(yaml, 19) {
370        return std::borrow::Cow::Borrowed(yaml);
371    }
372    let mut out = String::with_capacity(yaml.len());
373    let mut changed = false;
374    let mut block_indent: Option<usize> = None;
375    for line in yaml.split_inclusive('\n') {
376        let content = line.trim_end_matches(['\r', '\n']);
377        let term = &line[content.len()..];
378        let indent = content.len() - content.trim_start().len();
379
380        // Inside a block scalar: emit verbatim until a non-blank line dedents to
381        // at or before the introducer's key indent.
382        if let Some(key_indent) = block_indent {
383            if content.trim().is_empty() || indent > key_indent {
384                out.push_str(line);
385                continue;
386            }
387            block_indent = None; // block ended; process this line normally
388        }
389        if introduces_block_scalar(content) {
390            block_indent = Some(indent);
391            out.push_str(line);
392            continue;
393        }
394        match quote_int_value_in_line(content) {
395            Some(rewritten) => {
396                out.push_str(&rewritten);
397                out.push_str(term);
398                changed = true;
399            }
400            None => out.push_str(line),
401        }
402    }
403    if changed {
404        std::borrow::Cow::Owned(out)
405    } else {
406        std::borrow::Cow::Borrowed(yaml)
407    }
408}
409
410impl Frontmatter {
411    /// Parse a YAML frontmatter block (the text between the opening and closing
412    /// `---` fences, exclusive) into a [`Frontmatter`].
413    ///
414    /// Lenient on unknown keys (they go to [`extra`](Frontmatter::extra));
415    /// returns [`ParseError::MalformedYaml`] only on YAML that doesn't parse.
416    pub fn parse(yaml: &str, file: &Path) -> Result<Self, ParseError> {
417        // An empty (or whitespace-only) frontmatter block is a valid, empty
418        // mapping — not a YAML error.
419        let value: Value = if yaml.trim().is_empty() {
420            Value::Mapping(Mapping::new())
421        } else {
422            // Preserve integer literals beyond i64/u64 range: serde_norway would
423            // otherwise reject `(u64,u128]` or silently truncate `>u128` to f64,
424            // corrupting imported numeric IDs. Quoting them up front makes them
425            // round-trip verbatim as strings.
426            let prepared = quote_oversized_integers(yaml);
427            serde_norway::from_str(&prepared).map_err(|source| ParseError::MalformedYaml {
428                file: file.to_path_buf(),
429                source,
430            })?
431        };
432
433        // Top-level frontmatter must be a mapping. A scalar or sequence at the
434        // top level is malformed for our purposes; surface it as such.
435        let map = match value {
436            Value::Mapping(m) => m,
437            Value::Null => Mapping::new(),
438            other => {
439                // serde_norway::Error has no public constructor, so let the
440                // deserializer decide: a value that coerces to a Mapping (e.g. a
441                // YAML-tagged mapping `!tag\n k: v`, where the tag is ambient) is
442                // accepted as that mapping; a genuine scalar or sequence top
443                // level fails to coerce and IS the malformed case. (Using a
444                // match here, not `expect_err`, avoids a panic on the
445                // tagged-mapping case, which deserializes to a Mapping just
446                // fine.)
447                match serde_norway::from_value::<Mapping>(other) {
448                    Ok(m) => m,
449                    Err(source) => {
450                        return Err(ParseError::MalformedYaml {
451                            file: file.to_path_buf(),
452                            source,
453                        });
454                    }
455                }
456            }
457        };
458
459        let mut fm = Frontmatter::default();
460        for (k, v) in map {
461            let key = match k.as_str() {
462                Some(s) => s.to_string(),
463                // Non-string keys (`2026:`, `true:`, `3.14:`) are unusual but
464                // valid YAML; per SPEC § "Unknown fields pass through" they must
465                // not be corrupted on re-emit. Stringify them through the YAML
466                // scalar emitter — `2026`, `true`, `3.14` — NOT the Rust `Debug`
467                // formatter (which produced `Number(2026)`, `Bool(true)`, …), so
468                // the key text survives. `extra` is `String`-keyed, so on the
469                // write side the key re-emits as a quoted-string key carrying that
470                // text (e.g. `'2026':`) — the type narrows from number to string,
471                // but the data is no longer destroyed and ordinary string keys are
472                // wholly unaffected.
473                None => yaml_scalar_key(&k),
474            };
475            match key.as_str() {
476                // Coerce scalar values rather than `v.as_str()` (which is None
477                // for Number/Bool/Null). A bare scalar that YAML reads as a
478                // non-string — `summary: 2026`, `id: 100`, `status: 0` — would
479                // otherwise be set to None AND dropped (it is a matched arm, so
480                // the raw value never reaches `extra`), and `to_yaml` then omits
481                // the None field, so `dbmd format` (read_file -> write_file)
482                // silently deletes the line from disk. `scalar_string` mirrors
483                // the coercion `validate`/`store` already apply to these fields,
484                // so a numeric/bool-looking scalar is preserved as its string
485                // form and round-trips instead of being destroyed.
486                //
487                // A sequence/mapping value on a universal key (`status: [a, b]`,
488                // a nested-mapping `summary:`) is NOT a valid scalar; rather than
489                // let the matched arm consume-and-drop it (silent data loss on
490                // the next re-emit), `scalar_string` returns None and we fall
491                // through to preserving the raw value in `extra` so `to_yaml`
492                // re-emits it verbatim. The universal accessors stay None (the
493                // value was never a valid scalar for that field), but the
494                // operator's bytes are never destroyed.
495                "type" => match scalar_string(&v) {
496                    Some(s) => fm.type_ = Some(s),
497                    None => {
498                        fm.extra.insert(key, v);
499                    }
500                },
501                "meta-type" => match scalar_string(&v) {
502                    Some(s) => fm.meta_type = Some(s),
503                    None => {
504                        fm.extra.insert(key, v);
505                    }
506                },
507                "id" => match scalar_string(&v) {
508                    Some(s) => fm.id = Some(s),
509                    None => {
510                        fm.extra.insert(key, v);
511                    }
512                },
513                // Same preserve-don't-destroy rule as the scalar keys above,
514                // applied to the two typed ones. A value that is not RFC3339
515                // has nowhere to live in the typed `Option<DateTime>`, so it
516                // rides in `extra` verbatim and `to_yaml` re-emits it byte-for-
517                // byte; the typed accessor stays None because there is no
518                // timestamp to offer.
519                //
520                // The READ path is deliberately tolerant while `set` (the write
521                // path) stays strict: a store is whatever it already is —
522                // date-only stamps are the single most common legacy spelling
523                // in migrated stores — and refusing to PARSE one made every
524                // file-touching command (`format`, `fm`, `link`, `rename`)
525                // unusable on exactly the imperfect stores that most need them.
526                // Reporting the defect is `validate`'s job, and it still does:
527                // `FM_BAD_TIMESTAMP` is raised from the raw YAML value, never
528                // from this parse (validate.rs — `is_iso8601` over
529                // `scalar_string`), so leniency here costs no enforcement.
530                "created" => match parse_timestamp(&v, "created", file) {
531                    Ok(ts) => fm.created = ts,
532                    Err(_) => {
533                        fm.extra.insert(key, v);
534                    }
535                },
536                "updated" => match parse_timestamp(&v, "updated", file) {
537                    Ok(ts) => fm.updated = ts,
538                    Err(_) => {
539                        fm.extra.insert(key, v);
540                    }
541                },
542                "summary" => match scalar_string(&v) {
543                    Some(s) => fm.summary = Some(s),
544                    None => {
545                        fm.extra.insert(key, v);
546                    }
547                },
548                "status" => match scalar_string(&v) {
549                    Some(s) => fm.status = Some(s),
550                    None => {
551                        fm.extra.insert(key, v);
552                    }
553                },
554                "tags" => match parse_tags_preserving(&v) {
555                    Ok(tags) => fm.tags = tags,
556                    // A `tags` value with a non-scalar item (`tags: [[vip]]`,
557                    // `tags: [a, [b]]`) is preserved verbatim in `extra` rather
558                    // than silently filtered down / erased on re-emit. The typed
559                    // `tags` vec stays empty (no valid scalar list was present),
560                    // so `to_yaml` won't ALSO emit a `tags:` from the vec.
561                    Err(raw) => {
562                        fm.extra.insert(key, raw);
563                    }
564                },
565                _ => {
566                    fm.extra.insert(key, v);
567                }
568            }
569        }
570
571        // Disambiguate the one YAML shape `serde_norway` cannot tell apart on its
572        // own: an *inline scalar* wiki-link `field: [[x]]` and a *genuine 2D
573        // array* `field:`\n`- - x` BOTH parse to the identical
574        // `Seq[ Seq[String("x")] ]`. The parsed `Value` has lost which one the
575        // source wrote, but the source text has not — so we resolve it here, the
576        // only place the original spelling is still visible. For every `extra`
577        // key the source wrote in the inline `[[…]]` form, store the canonical
578        // quoted scalar `String("[[x]]")` instead of the ambiguous nested
579        // sequence. `to_yaml`/`canonicalize_extra_value` then emit it inline and
580        // round-trip it (SPEC § Linking, `company: [[…]]`), while a real nested
581        // array — which never appears in inline-link source form — stays a
582        // sequence and is preserved verbatim rather than silently retyped.
583        for key in inline_scalar_link_keys(yaml) {
584            if let Some(value) = fm.extra.get_mut(&key) {
585                // The parsed value of an inline `key: [[x]]` is the one-element
586                // outer `Seq[ Seq[String(x)] ]`; `unquoted_inline_link` reads the
587                // inner `Seq[String(x)]`, so unwrap the lone outer item first.
588                if let Value::Sequence(items) = value {
589                    if items.len() == 1 {
590                        if let Some(link) = unquoted_inline_link(&items[0]) {
591                            *value = Value::String(wiki_link_literal(&link));
592                        }
593                    }
594                }
595            }
596        }
597
598        Ok(fm)
599    }
600
601    /// Serialize the frontmatter back to a YAML block (no `---` fences) in
602    /// canonical key order. Round-trips [`extra`](Frontmatter::extra) verbatim.
603    pub fn to_yaml(&self) -> String {
604        // Build an order-preserving mapping in canonical key order:
605        //   type, meta-type, id, created, updated, summary  (universal head)
606        //   <type-specific extra, BTreeMap-sorted>
607        //   status, tags                          (universal tail)
608        // serde_norway::Mapping preserves insertion order, so one serialize call
609        // emits the block in exactly this order with correct YAML quoting.
610        let mut map = Mapping::new();
611
612        if let Some(t) = &self.type_ {
613            map.insert(Value::String("type".into()), Value::String(t.clone()));
614        }
615        if let Some(mt) = &self.meta_type {
616            map.insert(Value::String("meta-type".into()), Value::String(mt.clone()));
617        }
618        if let Some(id) = &self.id {
619            map.insert(Value::String("id".into()), Value::String(id.clone()));
620        }
621        if let Some(created) = &self.created {
622            map.insert(
623                Value::String("created".into()),
624                Value::String(created.to_rfc3339()),
625            );
626        }
627        if let Some(updated) = &self.updated {
628            map.insert(
629                Value::String("updated".into()),
630                Value::String(updated.to_rfc3339()),
631            );
632        }
633        if let Some(summary) = &self.summary {
634            map.insert(
635                Value::String("summary".into()),
636                Value::String(summary.clone()),
637            );
638        }
639
640        // Type-specific + custom fields, in BTreeMap (sorted) order. Each value
641        // is canonicalized so a wiki-link round-trips to the form the writer and
642        // `dbmd validate` agree on — critically, the SPEC-canonical *unquoted*
643        // scalar `field: [[x]]` (which YAML parses to a nested `Seq[Seq[String]]`)
644        // is re-emitted as a quoted scalar `'[[x]]'` instead of the bracket-less
645        // block sequence `- - x` that a verbatim re-emit would produce and that
646        // destroys the link. See [`canonicalize_extra_value`].
647        for (k, v) in &self.extra {
648            map.insert(Value::String(k.clone()), canonicalize_extra_value(v));
649        }
650
651        if let Some(status) = &self.status {
652            map.insert(
653                Value::String("status".into()),
654                Value::String(status.clone()),
655            );
656        }
657        if !self.tags.is_empty() {
658            map.insert(
659                Value::String("tags".into()),
660                Value::Sequence(self.tags.iter().cloned().map(Value::String).collect()),
661            );
662        }
663
664        if map.is_empty() {
665            return String::new();
666        }
667        serde_norway::to_string(&Value::Mapping(map)).unwrap_or_default()
668    }
669
670    /// True if the file is content (under `sources/` or `records/`)
671    /// and not an `index.md`. Used by validate to decide which files require a
672    /// `summary`. Meta files (`DB.md`, `index.md`, `log.md`) return false.
673    pub fn is_content_file(path: &Path) -> bool {
674        // index.md is a meta file at every level, never content.
675        if path.file_name().and_then(|n| n.to_str()) == Some("index.md") {
676            return false;
677        }
678        // Content iff some path component is one of the two layer dirs. This
679        // works for both store-relative (`sources/emails/x.md`) and absolute
680        // (`/home/db/sources/emails/x.md`) paths. DB.md / log.md sit at the
681        // root, under no layer, so they fall through to false.
682        path.components().any(|c| {
683            c.as_os_str()
684                .to_str()
685                .is_some_and(|s| LAYER_DIRS.contains(&s))
686        })
687    }
688
689    /// Resolve the file's effective `id`: the explicit `id` field if present,
690    /// otherwise derived from the store-relative path (filename without `.md`).
691    pub fn effective_id(&self, store_relative_path: &Path) -> String {
692        if let Some(id) = &self.id {
693            if !id.is_empty() {
694                return id.clone();
695            }
696        }
697        // Derived id = filename without the `.md` extension.
698        store_relative_path
699            .file_stem()
700            .and_then(|s| s.to_str())
701            .unwrap_or_default()
702            .to_string()
703    }
704
705    /// The effective `meta-type` for a record: the declared value, or `fact`
706    /// when absent. Records only — sources carry no meta-type; callers apply
707    /// this only to record-layer files.
708    pub fn effective_meta_type(&self) -> &str {
709        self.meta_type.as_deref().unwrap_or("fact")
710    }
711
712    /// Read a single frontmatter key as a raw YAML [`Value`], looking in the
713    /// typed fields first and then [`extra`](Frontmatter::extra).
714    pub fn get(&self, key: &str) -> Option<Value> {
715        match key {
716            "type" => self.type_.clone().map(Value::String),
717            "meta-type" => self.meta_type.clone().map(Value::String),
718            "id" => self.id.clone().map(Value::String),
719            "created" => self.created.map(|d| Value::String(d.to_rfc3339())),
720            "updated" => self.updated.map(|d| Value::String(d.to_rfc3339())),
721            "summary" => self.summary.clone().map(Value::String),
722            "status" => self.status.clone().map(Value::String),
723            "tags" => {
724                if self.tags.is_empty() {
725                    None
726                } else {
727                    Some(Value::Sequence(
728                        self.tags.iter().cloned().map(Value::String).collect(),
729                    ))
730                }
731            }
732            _ => self.extra.get(key).cloned(),
733        }
734    }
735
736    /// Set a single frontmatter key from a string value, routing universal-
737    /// contract keys to their typed fields and everything else to
738    /// [`extra`](Frontmatter::extra). Used by `dbmd fm set`.
739    pub fn set(&mut self, key: &str, value: &str) -> Result<(), ParseError> {
740        match key {
741            "type" => self.type_ = Some(value.to_string()),
742            "meta-type" => self.meta_type = Some(value.to_string()),
743            "id" => self.id = Some(value.to_string()),
744            "created" => {
745                self.created = Some(parse_rfc3339(value, "created", Path::new("<fm set>"))?)
746            }
747            "updated" => {
748                self.updated = Some(parse_rfc3339(value, "updated", Path::new("<fm set>"))?)
749            }
750            "summary" => self.summary = Some(value.to_string()),
751            "status" => self.status = Some(value.to_string()),
752            "tags" => {
753                // Accept either a YAML flow list (`[a, b]`) or a single scalar
754                // tag. Anything that parses to a sequence becomes the tag list;
755                // otherwise the whole string is one tag.
756                self.tags = match serde_norway::from_str::<Value>(value) {
757                    Ok(Value::Sequence(seq)) => parse_tags(&Value::Sequence(seq)),
758                    _ => vec![value.to_string()],
759                };
760            }
761            _ => {
762                // A custom / type-specific field. The value is a scalar string by
763                // default, but the spec's list-valued link fields (e.g.
764                // `meeting.attendees`, SPEC § Linking) must serialize as a YAML
765                // block sequence of quoted wiki-links — never the flow-form string
766                // `"[[[a]], [[b]]]"`, which `dbmd validate` rejects as
767                // `WIKI_LINK_FLOW_FORM_LIST`. When the value parses as a YAML
768                // sequence whose every item is a clean single wiki-link, store the
769                // canonical sequence so `to_yaml` emits block form. Everything else
770                // — plain text, and a single inline `[[x]]` (which YAML reads as a
771                // nested `Seq[Seq[String]]`, not a list of link strings) — stays a
772                // verbatim scalar string, preserving the prior behavior.
773                let stored = parse_link_list_value(value)
774                    .unwrap_or_else(|| Value::String(value.to_string()));
775                self.extra.insert(key.to_string(), stored);
776            }
777        }
778        Ok(())
779    }
780
781    /// Extract every frontmatter field whose value is a wiki-link (scalar
782    /// inline form or a block-sequence list), pairing each with its key. The
783    /// validate engine checks these against `(link)` schema annotations.
784    pub fn link_fields(&self) -> Vec<(String, WikiLink)> {
785        let mut out = Vec::new();
786        // `summary` may carry navigational wiki-links (spec encourages it).
787        if let Some(summary) = &self.summary {
788            for link in extract_wiki_links(summary, Path::new("")) {
789                out.push(("summary".to_string(), link));
790            }
791        }
792        // Every type-specific / custom field: a scalar wiki-link or a list of
793        // wiki-links, in either the quoted (`"[[x]]"`) or the canonical unquoted
794        // (`[[x]]`) form. See [`links_in_field_value`] for the YAML shapes.
795        for (key, value) in &self.extra {
796            for link in links_in_field_value(value) {
797                out.push((key.clone(), link));
798            }
799        }
800        out
801    }
802}
803
804/// A wiki-link reference inside the store: `[[target]]` or `[[target|display]]`.
805///
806/// `target` is always recorded as written; [`is_full_path`](WikiLink::is_full_path)
807/// flags whether it's a full store-relative path (the doctrine) versus a
808/// short-form (a validation error).
809#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct WikiLink {
811    /// The link target as written, without the `[[ ]]` and without `|display`.
812    pub target: String,
813    /// The optional `|display` text override.
814    pub display: Option<String>,
815    /// True when `target` is a full store-relative path (contains a `/` and
816    /// resolves under a known layer); false for short-form targets like
817    /// `sarah-chen` — which validate reports as `WIKI_LINK_SHORT_FORM`.
818    pub is_full_path: bool,
819    /// True when `target` carries a trailing `.md` extension — validate warns
820    /// `WIKI_LINK_HAS_EXTENSION`; the canonical writers emit the bare form.
821    pub has_md_extension: bool,
822    /// Where the link appears: `(file, line, col)`, 1-based line and column.
823    pub location: (PathBuf, u32, u32),
824}
825
826/// A standard markdown link `[text](url)` — an external reference, kept in a
827/// stream separate from [`WikiLink`] so external targets are visible to the
828/// toolkit without being conflated with in-store edges. Not graph-validated.
829#[derive(Debug, Clone, PartialEq, Eq)]
830pub struct MarkdownLink {
831    /// The link text inside `[ ]`.
832    pub text: String,
833    /// The URL or path inside `( )`.
834    pub url: String,
835    /// Where the link appears: `(file, line, col)`, 1-based.
836    pub location: (PathBuf, u32, u32),
837}
838
839/// A `##`/`###` section of a markdown body: the heading text plus the byte
840/// slice of the body it spans (heading line through the line before the next
841/// heading of equal-or-shallower depth).
842#[derive(Debug, Clone, PartialEq, Eq)]
843pub struct Section {
844    /// The heading text (without the leading `#`s).
845    pub heading: String,
846    /// Heading depth (number of leading `#`s).
847    pub level: u8,
848    /// The 1-based line where the heading appears.
849    pub line: u32,
850    /// The section body, from the heading line to the next sibling-or-shallower
851    /// heading (exclusive), as a slice of the original body.
852    pub body: String,
853}
854
855/// The parsed structured content of a store's `DB.md` config file.
856///
857/// All four parts are optional in the source; absent parts fall back to spec
858/// defaults. Produced by [`parse_db_md`].
859#[derive(Debug, Clone, Default, PartialEq)]
860pub struct Config {
861    /// Body of the `## Agent instructions` section — free-form prose passed to
862    /// the agent's system prompt.
863    pub agent_instructions: Option<String>,
864    /// `## Policies` → `### Frozen pages`: store-relative paths the toolkit
865    /// refuses to write (`POLICY_FROZEN_PAGE`).
866    pub frozen_pages: Vec<PathBuf>,
867    /// `## Policies` → `### Ignored types`: type names the curator never
868    /// synthesizes (still readable as ambient context).
869    pub ignored_types: Vec<String>,
870    /// `## Schemas` → one entry per `### <type>` sub-section.
871    pub schemas: BTreeMap<String, Schema>,
872    /// `## Folders` → optional per-folder display + description, surfaced in the
873    /// root + layer `index.md` rollups. Agent-authored; the tool never invents a
874    /// folder's description (absent ⇒ the rollup shows counts only). Keyed by the
875    /// store-relative, unix-slash folder path (e.g. `records/contacts`).
876    pub folders: BTreeMap<String, FolderMeta>,
877}
878
879/// Agent-authored display + description for one type-folder, declared in
880/// `DB.md ## Folders` and surfaced in the root/layer `index.md` rollups. Both
881/// fields are optional: `display` overrides the rollup's derived folder name
882/// (for casing the tool can't guess, e.g. acronyms like HubSpot); `description`
883/// is the one-line "what's in here" the rollup shows. The tool only ever
884/// *surfaces* these — it never composes a folder description from the folder's
885/// contents (that would be the tool inventing the curator's judgment).
886#[derive(Debug, Clone, Default, PartialEq, Eq)]
887pub struct FolderMeta {
888    /// Display-name override (absent ⇒ derived from the folder basename).
889    pub display: Option<String>,
890    /// One-line folder description shown in the rollup (absent ⇒ counts only).
891    pub description: Option<String>,
892}
893
894impl Config {
895    /// The `### Frozen pages` entry that matches a store-relative `target`, if
896    /// any. The **single** frozen-page matcher every write surface must funnel
897    /// through so the policy is enforced identically on `write` / `fm set` /
898    /// `fm init` / `link` / `rename` / `format`.
899    ///
900    /// Comparison is normalized so a policy line and a write target match
901    /// regardless of incidental spelling differences:
902    /// - `/` path separators on every OS,
903    /// - a single leading `./` dropped,
904    /// - a trailing `.md` dropped on **both** sides — `parse_db_md` stores
905    ///   frozen entries verbatim, so an operator who writes the natural
906    ///   extensionless spelling (`records/decisions/q1`) must protect the file
907    ///   (`records/decisions/q1.md`) exactly as the `.md` spelling does.
908    ///
909    /// Returns the matched config entry verbatim (its original spelling) so the
910    /// caller can name it in the `POLICY_FROZEN_PAGE` refusal.
911    pub fn frozen_match(&self, target: &Path) -> Option<PathBuf> {
912        let want = normalize_frozen_path(target);
913        self.frozen_pages
914            .iter()
915            .find(|frozen| {
916                let pat = normalize_frozen_path(frozen);
917                // A literal entry matches by exact normalized equality; an entry
918                // carrying a `*`/`**` glob matches by segment-wise glob so a
919                // pattern like `records/decisions/*` actually protects the
920                // concrete files under it instead of silently failing open.
921                if pat.contains('*') {
922                    frozen_glob_matches(&pat, &want)
923                } else {
924                    pat == want
925                }
926            })
927            .cloned()
928    }
929
930    /// True if `target` (store-relative) is a frozen page. Convenience wrapper
931    /// over [`Config::frozen_match`] for callers that only need presence.
932    pub fn is_frozen(&self, target: &Path) -> bool {
933        self.frozen_match(target).is_some()
934    }
935}
936
937/// Normalize a path for frozen-page comparison: `/` separators, a leading `./`
938/// or `/` dropped, and a trailing `.md` dropped. Both the policy entry and the
939/// write target pass through this before equality/glob, so the match is
940/// separator-, `./`-, leading-`/`-, and `.md`-insensitive. Without the leading
941/// `/` drop, an operator who wrote `/records/decisions/q1.md` normalized to a
942/// path that never equals the target's `records/decisions/q1`, silently failing
943/// the freeze OPEN.
944fn normalize_frozen_path(p: &Path) -> String {
945    use std::path::Component;
946    // Keep only the `Normal` path segments, dropping `RootDir`/`Prefix` (a
947    // leading `/` or drive prefix) and `CurDir` (`.`). This is what makes a
948    // leading-slash entry (`/records/decisions/q1.md`) normalize to the same
949    // `records/decisions/q1` as the store-relative target, instead of the
950    // doubled-`//` prefix `Path::components` + naive join produced — which never
951    // equalled the target and silently failed the freeze OPEN.
952    let unix: String = p
953        .components()
954        .filter_map(|c| match c {
955            Component::Normal(s) => s.to_str(),
956            _ => None,
957        })
958        .collect::<Vec<_>>()
959        .join("/");
960    unix.strip_suffix(".md").unwrap_or(&unix).to_string()
961}
962
963/// Match a normalized frozen-page glob `pat` against a normalized target `path`,
964/// segment by segment. `*` matches any run of characters *within a single path
965/// segment* (never crossing `/`); `**` as a whole segment matches zero or more
966/// whole segments. Both sides are already `normalize_frozen_path`-normalized, so
967/// this only deals with `/`-joined segment text. Keeps the substrate dependency-
968/// free (no glob crate) while making `records/decisions/*` actually freeze the
969/// files beneath it instead of failing open.
970fn frozen_glob_matches(pat: &str, path: &str) -> bool {
971    // Collapse runs of consecutive `**` segments into a single `**` before
972    // matching: `**/**` matches exactly the same set of paths as `**`, so the
973    // duplicates carry no semantics — they only multiply the number of
974    // (star-index, path-index) splits the matcher must consider. Dropping them
975    // up front is the first half of keeping the match polynomial (the
976    // two-pointer matcher below is the second); without it, a DB.md bullet like
977    // `**/**/…/zzz` against a deep non-matching target made the old recursive
978    // matcher explore exponentially many splits and hang the entire write path.
979    let pat_segs: Vec<&str> = collapse_double_stars(pat.split('/'));
980    let path_segs: Vec<&str> = path.split('/').collect();
981    glob_segments(&pat_segs, &path_segs)
982}
983
984/// Drop every `**` segment that immediately follows another `**`, leaving at most
985/// one `**` per run. Consecutive `**` are semantically identical to a single `**`
986/// (each matches "zero or more whole segments"), so this never changes the set of
987/// matched paths — it only removes the redundant pattern positions that otherwise
988/// fuel catastrophic backtracking.
989fn collapse_double_stars<'a>(segs: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
990    let mut out: Vec<&str> = Vec::new();
991    for seg in segs {
992        if seg == "**" && out.last() == Some(&"**") {
993            continue;
994        }
995        out.push(seg);
996    }
997    out
998}
999
1000/// Segment matcher for [`frozen_glob_matches`]. `**` consumes any number of path
1001/// segments; every other pattern segment must match exactly one path segment
1002/// (with `*` wildcards inside it).
1003///
1004/// Implemented as the classic linear wildcard match: a single forward scan with a
1005/// remembered "last `**`" backtrack point, never the two-way recursion the old
1006/// version used. The old `glob_segments(rest, path)` OR `glob_segments(pat,
1007/// &path[1..])` recursion had no memoization, so N consecutive `**` against a
1008/// deep target that ultimately fails to match explored an exponential number of
1009/// (star-index, path-index) splits — one DB.md frozen-page bullet could hang the
1010/// store's whole write path. This greedy scan with backtrack is O(pat × path) in
1011/// the worst case while matching exactly the same set of paths.
1012fn glob_segments(pat: &[&str], path: &[&str]) -> bool {
1013    let mut pi = 0usize; // cursor into pattern segments
1014    let mut si = 0usize; // cursor into path segments
1015                         // Backtrack point: where in the pattern the last `**` sat, and the path
1016                         // position to resume from if a later literal mismatch forces the `**` to
1017                         // swallow one more segment. `None` until we have seen a `**`.
1018    let mut star_pi: Option<usize> = None;
1019    let mut star_si = 0usize;
1020
1021    while si < path.len() {
1022        if pi < pat.len() && pat[pi] == "**" {
1023            // Record this `**` as the resume point and tentatively let it match
1024            // zero segments (advance past it). If a later segment fails, we come
1025            // back here and let the `**` swallow one more path segment.
1026            star_pi = Some(pi);
1027            star_si = si;
1028            pi += 1;
1029        } else if pi < pat.len() && glob_segment_text(pat[pi], path[si]) {
1030            // Ordinary segment match: advance both cursors.
1031            pi += 1;
1032            si += 1;
1033        } else if let Some(sp) = star_pi {
1034            // Mismatch (or pattern exhausted) but an earlier `**` can absorb more:
1035            // resume just after that `**`, having it consume one extra segment.
1036            pi = sp + 1;
1037            star_si += 1;
1038            si = star_si;
1039        } else {
1040            // Mismatch with no `**` to fall back on.
1041            return false;
1042        }
1043    }
1044
1045    // Path consumed; any trailing pattern must be all `**` (each matching zero
1046    // segments) for a full match.
1047    while pi < pat.len() && pat[pi] == "**" {
1048        pi += 1;
1049    }
1050    pi == pat.len()
1051}
1052
1053/// Match a single glob segment against a single path segment. `*` matches any
1054/// run of characters within the segment; all other characters are literal.
1055fn glob_segment_text(pat: &str, seg: &str) -> bool {
1056    if !pat.contains('*') {
1057        return pat == seg;
1058    }
1059    // Split on `*` into literal fragments. The first fragment must be a prefix,
1060    // the last a suffix, and the middle fragments must appear in order.
1061    let parts: Vec<&str> = pat.split('*').collect();
1062    let mut pos = 0usize;
1063    for (idx, part) in parts.iter().enumerate() {
1064        if part.is_empty() {
1065            continue;
1066        }
1067        if idx == 0 {
1068            // Leading literal must be a prefix.
1069            if !seg[pos..].starts_with(part) {
1070                return false;
1071            }
1072            pos += part.len();
1073        } else if idx == parts.len() - 1 {
1074            // Trailing literal must be a suffix at or after the current cursor.
1075            return seg[pos..].ends_with(part);
1076        } else {
1077            // Interior literal: find it at or after the cursor.
1078            match seg[pos..].find(part) {
1079                Some(off) => pos += off + part.len(),
1080                None => return false,
1081            }
1082        }
1083    }
1084    true
1085}
1086
1087/// A user-declared type schema parsed from a `DB.md` `### <type>` sub-section.
1088/// The store's `## Schemas` is the **only** source of schema enforcement — the
1089/// toolkit ships no built-in or implicit per-type schema (see SPEC § Schemas).
1090#[derive(Debug, Clone, Default, PartialEq)]
1091pub struct Schema {
1092    /// One [`FieldSpec`] per bulleted field line, in source order.
1093    pub fields: Vec<FieldSpec>,
1094    /// `- unique: <field>[, <field> …]` directives — each inner vec is one
1095    /// uniqueness constraint over the listed field(s) (compound when >1). Two
1096    /// records of this type whose listed values collide warn as
1097    /// `DUP_UNIQUE_KEY`.
1098    pub unique_keys: Vec<Vec<String>>,
1099    /// `- summary_template: <template>` directive — the `{field}` interpolation
1100    /// pattern `dbmd fm init` / `dbmd write` use to compose a default `summary`
1101    /// for this type. `None` falls back to the body's first paragraph.
1102    pub summary_template: Option<String>,
1103    /// `- shard: by-date | flat` directive — whether records of this type are
1104    /// date-sharded on disk (`records/<type>/<YYYY>/<MM>/…`) or kept flat.
1105    /// `None` = no directive declared, so the store's built-in default for the
1106    /// type applies ([`crate::store::Store::type_shards`]); `Some(true)` forces
1107    /// date-sharding (e.g. a custom event type the toolkit has no built-in for);
1108    /// `Some(false)` forces flat. This is the v0.2 generic-model way to declare
1109    /// sharding — the toolkit ships no implicit per-type behavior beyond the
1110    /// example-type defaults.
1111    pub shard: Option<bool>,
1112}
1113
1114/// One field declaration inside a [`Schema`]: `- <name> (<modifiers>)`.
1115///
1116/// Modifiers are comma-separated inside the parens; this captures the
1117/// recognized ones as typed fields and stashes anything unrecognized in
1118/// [`unknown_modifiers`](FieldSpec::unknown_modifiers) (surfaced as `Info`).
1119#[derive(Debug, Clone, Default, PartialEq)]
1120pub struct FieldSpec {
1121    /// The field name.
1122    pub name: String,
1123    /// `required` modifier present.
1124    pub required: bool,
1125    /// The shape modifier (`string`/`int`/`bool`/`date`/`email`/`currency`/
1126    /// `url`), if any.
1127    pub shape: Option<Shape>,
1128    /// `link to <prefix>/` — the store-relative prefix a wiki-link target must
1129    /// start with. The trailing slash is required in the source syntax.
1130    pub link_prefix: Option<PathBuf>,
1131    /// `default <value>` — the value written when the field is absent.
1132    pub default: Option<Value>,
1133    /// `enum: <v1>, <v2>, ...` — the allowed values (must be the last modifier
1134    /// on the line because of its own commas).
1135    pub enum_values: Option<Vec<String>>,
1136    /// Any modifiers not in the recognized vocabulary, preserved verbatim;
1137    /// validate surfaces these as `Info`, never errors.
1138    pub unknown_modifiers: Vec<String>,
1139}
1140
1141/// A recognized shape modifier for a schema field. Validate enforces the
1142/// corresponding value shape (`SCHEMA_SHAPE_MISMATCH` on violation).
1143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1144pub enum Shape {
1145    /// Any scalar string.
1146    String,
1147    /// Integer.
1148    Int,
1149    /// Boolean.
1150    Bool,
1151    /// RFC3339 / ISO-8601 date.
1152    Date,
1153    /// `<local>@<domain>` email address.
1154    Email,
1155    /// A currency amount.
1156    Currency,
1157    /// A URL.
1158    Url,
1159}
1160
1161/// The result of splitting a raw file into its frontmatter block and body.
1162///
1163/// `body` is the verbatim remainder after the closing `---` fence — the writer
1164/// preserves it byte-for-byte so operator edits are never reflowed.
1165#[derive(Debug, Clone, PartialEq, Eq)]
1166pub struct ParsedFile {
1167    /// The raw frontmatter YAML (between the fences, exclusive of them).
1168    pub frontmatter_yaml: String,
1169    /// The verbatim body (everything after the closing `---`).
1170    pub body: String,
1171}
1172
1173/// Split a file's full text into its frontmatter block and body. The
1174/// frontmatter block must be the very first thing in the file, delimited by
1175/// `---` on its own line at start and end. Returns
1176/// [`ParseError::MissingFrontmatter`] if absent.
1177pub fn split_frontmatter(text: &str, file: &Path) -> Result<ParsedFile, ParseError> {
1178    // Tolerate a single leading UTF-8 BOM (U+FEFF) before the opening fence,
1179    // matching `store::frontmatter_block` and `index::extract_frontmatter_block`
1180    // which already strip it. Without this, a BOM-prefixed file (common from
1181    // Windows / exported markdown dropped into `sources/`) gets walked and
1182    // indexed by `dbmd index` yet hard-fails every write/edit surface that
1183    // routes through `read_file` (`fm get/set`, `format`, `link`, `write`). The
1184    // BOM is dropped from the emitted body so the canonical writer never carries
1185    // it forward.
1186    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1187
1188    // The opening fence must be the very first line: `---`, no leading
1189    // whitespace, nothing before it. Trailing whitespace on the fence line is
1190    // tolerated via `trim_end()` (which strips spaces/tabs as well as CR/LF) so
1191    // this matches `index::extract_frontmatter_block` and
1192    // `validate::split_frontmatter`, both of which use `trim_end()`. Without this
1193    // agreement a fence written `--- ` (a single trailing space — invisible in an
1194    // editor, easily produced by hand edits or exporters) was indexed and
1195    // validated clean by those scanners yet hard-failed every write/edit surface
1196    // routed through `read_file` (`fm get/set`, `format`, `link`, `write`) — the
1197    // same cross-scanner drift class already fixed for the UTF-8 BOM above.
1198    let mut lines = text.split_inclusive('\n');
1199    let first = lines.next().unwrap_or("");
1200    if first.trim_end() != "---" {
1201        return Err(ParseError::MissingFrontmatter {
1202            file: file.to_path_buf(),
1203        });
1204    }
1205
1206    // Scan for the closing fence line. Track byte offsets so we can slice the
1207    // YAML (between fences, exclusive) and the body (verbatim, after the
1208    // closing fence's line terminator).
1209    let opening_len = first.len();
1210    let mut offset = opening_len;
1211    for line in lines {
1212        if line.trim_end() == "---" {
1213            let yaml = &text[opening_len..offset];
1214            let body_start = offset + line.len();
1215            let body = &text[body_start..];
1216            return Ok(ParsedFile {
1217                frontmatter_yaml: yaml.to_string(),
1218                body: body.to_string(),
1219            });
1220        }
1221        offset += line.len();
1222    }
1223
1224    // Opening fence present but no closing fence: malformed frontmatter block.
1225    Err(ParseError::MissingFrontmatter {
1226        file: file.to_path_buf(),
1227    })
1228}
1229
1230/// Read a file from disk and parse it into typed [`Frontmatter`] plus the
1231/// verbatim body string.
1232pub fn read_file(path: &Path) -> Result<(Frontmatter, String), ParseError> {
1233    let text = std::fs::read_to_string(path)?;
1234    let parsed = split_frontmatter(&text, path)?;
1235    let fm = Frontmatter::parse(&parsed.frontmatter_yaml, path)?;
1236    Ok((fm, parsed.body))
1237}
1238
1239/// Atomically write a markdown file from frontmatter + body: emit the
1240/// frontmatter in canonical key order, then the body verbatim, via a
1241/// temp-file-rename so a reader never sees a half-written file. Preserves the
1242/// operator-edited body exactly as given.
1243pub fn write_file(path: &Path, frontmatter: &Frontmatter, body: &str) -> Result<(), ParseError> {
1244    let contents = render_file(frontmatter, body);
1245
1246    // One durable, atomic write for all primary data (see `crate::fsx`):
1247    // temp-file + fsync + rename + parent-fsync. Content records are primary
1248    // data, so they get the durable path (unlike the rebuildable index).
1249    crate::fsx::write_atomic(path, contents.as_bytes())?;
1250    Ok(())
1251}
1252
1253/// Atomically create a markdown file from frontmatter + body, refusing with
1254/// [`std::io::ErrorKind::AlreadyExists`] if the destination already exists.
1255///
1256/// This is the create-new sibling of [`write_file`]: same canonical rendering
1257/// and durable temp-file path, but backed by [`crate::fsx::write_atomic_new`] so
1258/// two concurrent creators for the same path cannot both succeed.
1259pub fn write_file_new(
1260    path: &Path,
1261    frontmatter: &Frontmatter,
1262    body: &str,
1263) -> Result<(), ParseError> {
1264    let contents = render_file(frontmatter, body);
1265    crate::fsx::write_atomic_new(path, contents.as_bytes())?;
1266    Ok(())
1267}
1268
1269fn render_file(frontmatter: &Frontmatter, body: &str) -> String {
1270    let yaml = frontmatter.to_yaml();
1271    // `to_yaml` already terminates each block with a newline. Compose the file
1272    // as: opening fence, frontmatter YAML, closing fence, then body verbatim.
1273    let mut contents = String::with_capacity(yaml.len() + body.len() + 8);
1274    contents.push_str("---\n");
1275    contents.push_str(&yaml);
1276    contents.push_str("---\n");
1277    contents.push_str(body);
1278    contents
1279}
1280
1281/// Extract every wiki-link from a body (and inline frontmatter), returning the
1282/// structured [`WikiLink`] stream with short-form / `.md`-extension flags and
1283/// `(file, line, col)` locations set.
1284pub fn extract_wiki_links(body: &str, file: &Path) -> Vec<WikiLink> {
1285    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1286    let re = RE.get_or_init(|| {
1287        // [[target]] or [[target|display]]; target/display exclude brackets and
1288        // (for target) the `|` separator so nested forms don't over-match.
1289        regex::Regex::new(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]*))?\]\]").expect("valid wiki-link regex")
1290    });
1291
1292    let mut out = Vec::new();
1293    for (line_idx, line) in body.lines().enumerate() {
1294        // Running (byte, char) cursor: derive each match's column in ONE linear
1295        // pass over the line instead of recomputing it from the line start per
1296        // match. `captures_iter` yields non-overlapping matches in increasing
1297        // byte order, so advancing the char count by the gap since the previous
1298        // match keeps the whole line O(line_len) rather than O(matches × len).
1299        let mut cursor = ColCursor::new();
1300        for caps in re.captures_iter(line) {
1301            let whole = caps.get(0).expect("group 0 always present");
1302            let col = cursor.column_at(line, whole.start());
1303            let target = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
1304            let display = caps.get(2).map(|m| m.as_str().to_string());
1305            out.push(WikiLink {
1306                is_full_path: target_is_full_path(&target),
1307                has_md_extension: target_has_md_extension(&target),
1308                target,
1309                display,
1310                location: (file.to_path_buf(), (line_idx as u32) + 1, col),
1311            });
1312        }
1313    }
1314    out
1315}
1316
1317/// Extract every standard markdown link `[text](url)` from a body into a
1318/// separate stream, kept distinct from wiki-links.
1319pub fn extract_markdown_links(body: &str, file: &Path) -> Vec<MarkdownLink> {
1320    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
1321    let re = RE.get_or_init(|| {
1322        // [text](url). `text` excludes brackets so a wiki-link `[[x]]` (which
1323        // has `]]`, not `](`) never matches; `url` excludes `)` and whitespace.
1324        regex::Regex::new(r"\[([^\[\]]*)\]\(([^)\s]*)\)").expect("valid markdown-link regex")
1325    });
1326
1327    let mut out = Vec::new();
1328    for (line_idx, line) in body.lines().enumerate() {
1329        // One linear column cursor per line (see `extract_wiki_links`): avoids the
1330        // O(matches × line_len) recompute on a link-dense line.
1331        let mut cursor = ColCursor::new();
1332        for caps in re.captures_iter(line) {
1333            let whole = caps.get(0).expect("group 0 always present");
1334            let col = cursor.column_at(line, whole.start());
1335            out.push(MarkdownLink {
1336                text: caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string(),
1337                url: caps.get(2).map(|m| m.as_str()).unwrap_or("").to_string(),
1338                location: (file.to_path_buf(), (line_idx as u32) + 1, col),
1339            });
1340        }
1341    }
1342    out
1343}
1344
1345/// Detect the frontmatter wiki-link-list mis-encoding: a wiki-link *list*
1346/// written so YAML parses it as nested sequences instead of a clean list of
1347/// strings. Returns the offending keys so validate can emit
1348/// `WIKI_LINK_FLOW_FORM_LIST`.
1349///
1350/// The subtlety is that `[[x]]` is YAML for "a list containing `[x]`", so the
1351/// shapes nest:
1352///
1353/// - **Scalar inline** `company: [[records/x]]` → `Seq[ Seq[String] ]`
1354///   (double-nested). This is the spec's scalar wiki-link form — NOT flagged.
1355/// - **Flow list** `attendees: [[[a]], [[b]]]` → `Seq[ Seq[Seq[String]], … ]`
1356///   (triple-nested). The list mis-encoding — flagged.
1357/// - **Unquoted block list** (`- [[a]]` per line) → also triple-nested, so it
1358///   is flagged too; the canonical list form must quote each item
1359///   (`- "[[a]]"`), which parses to a clean `Seq[String, …]` and is NOT flagged.
1360///
1361/// So the discriminator is nesting depth: a *list* mis-encoding has at least one
1362/// item that is itself a sequence-of-sequences, whereas a scalar inline link's
1363/// single item is a sequence-of-scalars.
1364pub fn detect_flow_form_link_lists(frontmatter_yaml: &str) -> Vec<String> {
1365    let value: Value = match serde_norway::from_str(frontmatter_yaml) {
1366        Ok(v) => v,
1367        // Malformed YAML is FM_MALFORMED_YAML's job, not ours; report nothing.
1368        Err(_) => return Vec::new(),
1369    };
1370    let Value::Mapping(map) = value else {
1371        return Vec::new();
1372    };
1373
1374    let mut out = Vec::new();
1375    for (k, v) in &map {
1376        if let Value::Sequence(items) = v {
1377            // Triple-nesting: some outer item is a sequence that itself holds a
1378            // sequence. Scalar inline `[[x]]` is only double-nested, so it
1379            // never matches.
1380            let is_link_list = items.iter().any(|item| match item {
1381                Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
1382                _ => false,
1383            });
1384            if is_link_list {
1385                if let Some(key) = k.as_str() {
1386                    out.push(key.to_string());
1387                }
1388            }
1389        }
1390    }
1391    out
1392}
1393
1394/// Extract the `##`/`###` sections of a markdown body into a flat list with
1395/// body slices.
1396pub fn extract_sections(body: &str) -> Vec<Section> {
1397    // Keep each line's start so we can slice the body verbatim (exact newlines).
1398    let lines: Vec<&str> = body.split_inclusive('\n').collect();
1399
1400    // First pass: classify heading levels (0 = not a heading), honoring fenced
1401    // code blocks so a `## x` inside a ``` fence is not treated as a heading.
1402    let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
1403    let mut fence: Option<(u8, usize)> = None;
1404    for line in &lines {
1405        let content = line.trim_end_matches(['\n', '\r']);
1406        if let Some(f) = fence {
1407            if is_closing_fence(content, f) {
1408                fence = None;
1409            }
1410            levels.push(0);
1411            continue;
1412        }
1413        if let Some(opened) = opening_fence(content) {
1414            fence = Some(opened);
1415            levels.push(0);
1416            continue;
1417        }
1418        levels.push(heading_level(content));
1419    }
1420
1421    // Second pass: emit `##`+ headings; each section body runs from its heading
1422    // line to the next heading at an equal-or-shallower level (exclusive).
1423    let mut sections = Vec::new();
1424    for (i, &lvl) in levels.iter().enumerate() {
1425        if lvl < 2 {
1426            continue;
1427        }
1428        let heading_line = lines[i].trim_end_matches(['\n', '\r']);
1429        let heading = heading_text(heading_line, lvl);
1430
1431        let mut end = lines.len();
1432        for (j, &other) in levels.iter().enumerate().skip(i + 1) {
1433            if other != 0 && other <= lvl {
1434                end = j;
1435                break;
1436            }
1437        }
1438
1439        sections.push(Section {
1440            heading,
1441            level: lvl,
1442            line: (i + 1) as u32,
1443            body: lines[i..end].concat(),
1444        });
1445    }
1446    sections
1447}
1448
1449/// Extract the `##`/`###` sections of a **whole file** (frontmatter + body),
1450/// returning each [`Section`] with `line` numbered against the *source file*,
1451/// not the body.
1452///
1453/// [`extract_sections`] numbers headings 1-based within the body it is handed —
1454/// the right frame for callers that already track the frontmatter offset
1455/// (`validate` adds `fm_end_line`). But the single-file views (`dbmd sections`,
1456/// `dbmd outline`) present `Section::line` as a source line an agent can jump to;
1457/// because every db.md file opens with a frontmatter block, the body-relative
1458/// number is off by the block's length (`opening fence + frontmatter lines +
1459/// closing fence`) for every file. This helper does the offset once, in the
1460/// parser, so those surfaces report true file lines. A file with no leading
1461/// frontmatter block is treated as all-body (offset 0), so the function never
1462/// fails just because a file lacks frontmatter.
1463pub fn extract_sections_in_file(text: &str) -> Vec<Section> {
1464    // Tolerate a leading BOM the same way `split_frontmatter` does, so the line
1465    // count and the body slice agree with the read path.
1466    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1467
1468    // Find the body and how many source lines precede it. The body begins right
1469    // after the closing fence; the number of lines consumed by the frontmatter
1470    // block (both fences + the YAML between) is the offset to add to each
1471    // body-relative heading line.
1472    let (body, offset) = match split_frontmatter(text, Path::new("<sections>")) {
1473        Ok(parsed) => {
1474            // Lines before the body = total lines in `text` minus lines in body.
1475            let total_lines = count_lines(text);
1476            let body_lines = count_lines(&parsed.body);
1477            (parsed.body, total_lines.saturating_sub(body_lines))
1478        }
1479        // No frontmatter block: the whole text is body, no offset.
1480        Err(_) => (text.to_string(), 0),
1481    };
1482
1483    let mut sections = extract_sections(&body);
1484    for s in &mut sections {
1485        s.line += offset;
1486    }
1487    sections
1488}
1489
1490/// Count the number of lines a string spans for line-number offsetting: one line
1491/// per `\n`, plus one more for a final line with no trailing newline. An empty
1492/// string is zero lines.
1493fn count_lines(s: &str) -> u32 {
1494    if s.is_empty() {
1495        return 0;
1496    }
1497    let newlines = s.bytes().filter(|&b| b == b'\n').count() as u32;
1498    if s.ends_with('\n') {
1499        newlines
1500    } else {
1501        newlines + 1
1502    }
1503}
1504
1505/// Parse a store's `DB.md` file into a [`Config`]: the `## Agent instructions`
1506/// prose, `## Policies` (`### Frozen pages` + `### Ignored types`), and
1507/// `## Schemas` (`### <type>` field-bullet blocks). Unrecognized sections are
1508/// ignored; absent sections leave their [`Config`] fields at default.
1509pub fn parse_db_md(text: &str, file: &Path) -> Result<Config, ParseError> {
1510    // The structured sections live in the body (after frontmatter). DB.md must
1511    // still start with a valid `---` block (`type: db-md`); if it's missing we
1512    // surface MissingFrontmatter like any other file.
1513    let parsed = split_frontmatter(text, file)?;
1514    let _frontmatter = Frontmatter::parse(&parsed.frontmatter_yaml, file)?;
1515    let sections = extract_sections(&parsed.body);
1516
1517    let mut config = Config::default();
1518    // Track which H2 region each H3 belongs to as we walk the flat list.
1519    let mut current_h2: Option<String> = None;
1520
1521    for section in &sections {
1522        match section.level {
1523            2 => {
1524                let name = section.heading.trim().to_ascii_lowercase();
1525                current_h2 = Some(name.clone());
1526                if name == "agent instructions" {
1527                    let prose = section_prose(&section.body);
1528                    if !prose.is_empty() {
1529                        config.agent_instructions = Some(prose);
1530                    }
1531                } else if name == "folders" {
1532                    // `## Folders` carries its bullets directly under the H2 (no
1533                    // `### <type>` sub-sections), like `## Agent instructions`.
1534                    for b in bullet_lines(&section.body) {
1535                        if let Some((path, meta)) = parse_folder_bullet(&b) {
1536                            config.folders.insert(path, meta);
1537                        }
1538                    }
1539                }
1540            }
1541            3 => {
1542                let h2 = current_h2.as_deref().unwrap_or("");
1543                let h3 = section.heading.trim().to_ascii_lowercase();
1544                match (h2, h3.as_str()) {
1545                    ("policies", "frozen pages") => {
1546                        config.frozen_pages = bullet_lines(&section.body)
1547                            .into_iter()
1548                            .map(|b| PathBuf::from(extract_path_bullet(&b)))
1549                            .collect();
1550                    }
1551                    ("policies", "ignored types") => {
1552                        config.ignored_types = bullet_lines(&section.body)
1553                            .into_iter()
1554                            .flat_map(|b| extract_type_list_bullet(&b))
1555                            .collect();
1556                    }
1557                    ("schemas", _) => {
1558                        // The H3 heading text (as written) is the type name.
1559                        let type_name = section.heading.trim().to_string();
1560                        let mut schema = Schema::default();
1561                        for b in bullet_lines(&section.body) {
1562                            match parse_schema_bullet(&b) {
1563                                SchemaBullet::Field(f) => schema.fields.push(f),
1564                                SchemaBullet::Unique(k) if !k.is_empty() => {
1565                                    schema.unique_keys.push(k)
1566                                }
1567                                SchemaBullet::SummaryTemplate(t) if !t.is_empty() => {
1568                                    schema.summary_template = Some(t)
1569                                }
1570                                SchemaBullet::Shard(Some(b)) => schema.shard = Some(b),
1571                                // Empty `unique:`/`summary_template:`, or a `shard:`
1572                                // with an unrecognized value — ignored.
1573                                SchemaBullet::Unique(_)
1574                                | SchemaBullet::SummaryTemplate(_)
1575                                | SchemaBullet::Shard(None) => {}
1576                            }
1577                        }
1578                        config.schemas.insert(type_name, schema);
1579                    }
1580                    _ => {}
1581                }
1582            }
1583            _ => {}
1584        }
1585    }
1586
1587    Ok(config)
1588}
1589
1590/// One parsed bullet inside a `### <type>` schema block: an ordinary field, or a
1591/// reserved directive (`unique:` / `summary_template:` / `shard:`). The names
1592/// `unique`, `summary_template`, and `shard` are reserved and cannot be used as
1593/// field names.
1594#[derive(Debug)]
1595enum SchemaBullet {
1596    /// An ordinary `- <name> (<modifiers>)` field.
1597    Field(FieldSpec),
1598    /// `- unique: <field>[, <field> …]` — a (possibly compound) uniqueness key.
1599    Unique(Vec<String>),
1600    /// `- summary_template: <template>` — the default-`summary` pattern.
1601    SummaryTemplate(String),
1602    /// `- shard: by-date | flat` — date-shard records of this type, or keep them
1603    /// flat. `None` = an unrecognized value, ignored like an unknown modifier.
1604    Shard(Option<bool>),
1605}
1606
1607/// Classify one `## Schemas` bullet as a directive or a field. The directive
1608/// forms are `- unique: a, b, …` and `- summary_template: …`; the keyword check
1609/// guards against false positives — a field like `- status (enum: a, b)` has a
1610/// `(` before any `:`, so its head isn't a bare reserved keyword and it parses
1611/// as a [`FieldSpec`].
1612fn parse_schema_bullet(bullet_line: &str) -> SchemaBullet {
1613    let line = bullet_line.trim();
1614    let line = line
1615        .strip_prefix("- ")
1616        .or_else(|| line.strip_prefix("* "))
1617        .or_else(|| line.strip_prefix("+ "))
1618        .or_else(|| line.strip_prefix('-'))
1619        .unwrap_or(line)
1620        .trim();
1621
1622    if let Some((head, rest)) = line.split_once(':') {
1623        match head.trim().to_ascii_lowercase().as_str() {
1624            "unique" => {
1625                let fields = rest
1626                    .split(',')
1627                    .map(|f| f.trim().to_string())
1628                    .filter(|f| !f.is_empty())
1629                    .collect();
1630                return SchemaBullet::Unique(fields);
1631            }
1632            "summary_template" => {
1633                return SchemaBullet::SummaryTemplate(rest.trim().to_string());
1634            }
1635            "shard" => {
1636                // `by-date` (synonyms: date/sharded/true) enables date-sharding;
1637                // `flat` (none/false) forces flat; anything else is ignored.
1638                let v = match rest.trim().to_ascii_lowercase().as_str() {
1639                    "by-date" | "date" | "sharded" | "true" => Some(true),
1640                    "flat" | "none" | "false" => Some(false),
1641                    _ => None,
1642                };
1643                return SchemaBullet::Shard(v);
1644            }
1645            _ => {}
1646        }
1647    }
1648
1649    SchemaBullet::Field(parse_field_spec(bullet_line))
1650}
1651
1652/// Parse one `## Folders` bullet — `- <path>[|<display>] — <description>` — into
1653/// the folder path (store-relative, unix-slash, no trailing slash) and its
1654/// [`FolderMeta`]. The optional `|<display>` overrides the rollup's derived
1655/// folder name (mirroring the wiki-link `|display` convention); the text after
1656/// the first em-dash (`—`), or ` - `, is the description. Backticks around the
1657/// path are tolerated (matching the `### Frozen pages` spelling). Returns `None`
1658/// for a bullet with no usable path.
1659fn parse_folder_bullet(bullet_line: &str) -> Option<(String, FolderMeta)> {
1660    let line = bullet_line.trim();
1661    let line = line
1662        .strip_prefix("- ")
1663        .or_else(|| line.strip_prefix("* "))
1664        .or_else(|| line.strip_prefix("+ "))
1665        .or_else(|| line.strip_prefix('-'))
1666        .unwrap_or(line)
1667        .trim();
1668
1669    // Split off the description at the first em-dash (preferred, matching the
1670    // rollup's own ` — ` separator) or a ` - ` fallback.
1671    let (pathspec, description) = match line.find('—') {
1672        Some(i) => (line[..i].trim(), Some(line[i + '—'.len_utf8()..].trim())),
1673        None => match line.find(" - ") {
1674            Some(i) => (line[..i].trim(), Some(line[i + 3..].trim())),
1675            None => (line, None),
1676        },
1677    };
1678
1679    // Optional `|display` override lives on the path side.
1680    let (path_raw, display) = match pathspec.split_once('|') {
1681        Some((p, d)) => (p.trim(), Some(d.trim())),
1682        None => (pathspec, None),
1683    };
1684
1685    // Normalize the path: drop surrounding backticks, a leading `./`, a trailing `/`.
1686    let path = path_raw.trim().trim_matches('`').trim();
1687    let path = path.strip_prefix("./").unwrap_or(path);
1688    let path = path.strip_suffix('/').unwrap_or(path).trim();
1689    if path.is_empty() {
1690        return None;
1691    }
1692
1693    let non_empty = |s: &str| {
1694        let t = s.trim();
1695        (!t.is_empty()).then(|| t.to_string())
1696    };
1697    Some((
1698        path.to_string(),
1699        FolderMeta {
1700            display: display.and_then(non_empty),
1701            description: description.and_then(non_empty),
1702        },
1703    ))
1704}
1705
1706/// Parse a single `## Schemas` field-bullet line — `- <name> (<modifiers>)` —
1707/// into a [`FieldSpec`], capturing recognized modifiers and stashing the rest
1708/// in [`FieldSpec::unknown_modifiers`].
1709pub fn parse_field_spec(bullet_line: &str) -> FieldSpec {
1710    // Strip the leading bullet marker (`- ` / `* ` / `+ `) and surrounding ws.
1711    let line = bullet_line.trim();
1712    let line = line
1713        .strip_prefix("- ")
1714        .or_else(|| line.strip_prefix("* "))
1715        .or_else(|| line.strip_prefix("+ "))
1716        .or_else(|| line.strip_prefix('-'))
1717        .unwrap_or(line)
1718        .trim();
1719
1720    // Split `<name> (<modifiers>)` — the canonical paren form — OR the natural
1721    // mis-spelling `<name>: <modifiers>` (colon instead of parens). The two
1722    // delimiters are interchangeable for the field head; whichever appears FIRST
1723    // wins, so a paren form whose modifiers contain a colon (`status (enum: a,
1724    // b)`) still parses by parens (the `(` precedes the `:`), while a bare
1725    // `title: string, required` parses by colon instead of being swallowed whole
1726    // into the field name with every modifier silently dropped.
1727    let paren = line.find('(');
1728    let colon = line.find(':');
1729    // Choose the head delimiter. The paren form wins when its `(` precedes any
1730    // `:` (so `status (enum: a, b)` parses by parens, the colon being inside the
1731    // modifiers); otherwise a `:` before the paren — or with no paren at all —
1732    // selects the colon form `<name>: <modifiers>`, the natural mis-spelling that
1733    // must NOT be swallowed whole into the field name with every modifier lost.
1734    let use_paren = matches!((paren, colon), (Some(p), c) if c.is_none_or(|c| p < c));
1735    let (name, modifiers) = if use_paren {
1736        let open = paren.expect("use_paren implies a paren");
1737        let name = line[..open].trim().to_string();
1738        let after = &line[open + 1..];
1739        let mods = match after.rfind(')') {
1740            Some(close) => &after[..close],
1741            None => after, // tolerate a missing close paren
1742        };
1743        (name, mods.trim())
1744    } else if let Some(c) = colon {
1745        // Colon form: everything after the first colon is the modifier list,
1746        // parsed identically to the parenthesized modifiers below.
1747        let name = line[..c].trim().to_string();
1748        (name, line[c + 1..].trim())
1749    } else {
1750        // Neither delimiter: a free-form optional field of any shape — name only.
1751        (line.to_string(), "")
1752    };
1753
1754    let mut spec = FieldSpec {
1755        name,
1756        ..FieldSpec::default()
1757    };
1758
1759    if modifiers.is_empty() {
1760        return spec;
1761    }
1762
1763    // Modifiers are comma-separated. `enum` and `default` are special: their own
1764    // values may contain commas, so each is a *greedy* clause that runs from its
1765    // keyword to the start of the next recognized greedy clause (or end of line).
1766    // This lets `default North America, EMEA fallback` keep its comma and lets a
1767    // `default …` written after an `enum …` still be recognized, instead of the
1768    // value being truncated at the first comma or absorbed into the enum list.
1769    let raw: Vec<&str> = modifiers.split(',').collect();
1770    let mut i = 0;
1771    while i < raw.len() {
1772        let token = raw[i].trim();
1773        if token.is_empty() {
1774            i += 1;
1775            continue;
1776        }
1777        let lower = token.to_ascii_lowercase();
1778
1779        if lower == "required" {
1780            spec.required = true;
1781            i += 1;
1782        } else if let Some(shape) = shape_from_str(&lower) {
1783            spec.shape = Some(shape);
1784            i += 1;
1785        } else if let Some(rest) = lower.strip_prefix("link to ") {
1786            // The trailing slash is required in the source; store the prefix
1787            // without it so `Path::starts_with` comparisons are clean.
1788            let prefix = token["link to ".len()..].trim().trim_end_matches('/');
1789            let _ = rest; // lowercase form only used for the keyword match
1790            spec.link_prefix = Some(PathBuf::from(prefix));
1791            i += 1;
1792        } else if token.len() >= "default ".len() && lower.starts_with("default ") {
1793            // Greedy `default <value>`: the value is this token (after the
1794            // keyword) plus every following comma-token up to the next greedy
1795            // clause, rejoined with the commas the split removed — so a comma
1796            // inside the default value is preserved. Original case is kept.
1797            let end = next_greedy_clause(&raw, i + 1);
1798            let mut value = token["default ".len()..].to_string();
1799            for tok in &raw[i + 1..end] {
1800                value.push(',');
1801                value.push_str(tok);
1802            }
1803            spec.default = Some(Value::String(value.trim().to_string()));
1804            i = end;
1805        } else if lower == "enum" || lower.starts_with("enum:") {
1806            // Greedy `enum` (bare `enum, a, b` or `enum: a, b`): the values run
1807            // from here to the next greedy clause (e.g. a trailing `default …`),
1808            // NOT unconditionally to end-of-line — so a `default` after `enum` is
1809            // parsed instead of swallowed as a bogus enum member.
1810            let end = next_greedy_clause(&raw, i + 1);
1811            // Rejoin this clause's tokens (trimmed so the `enum` head sits at the
1812            // start), drop the leading `enum`/`enum:` head, then re-split the
1813            // remainder into values.
1814            let joined = raw[i..end].join(",");
1815            let joined = joined.trim();
1816            let after_kw = match joined.find(':') {
1817                // `enum: a, b` — values follow the colon.
1818                Some(colon) => &joined[colon + 1..],
1819                // bare `enum, a, b` — values follow the keyword itself.
1820                None => joined.get("enum".len()..).unwrap_or(""),
1821            };
1822            let values: Vec<String> = after_kw
1823                .split(',')
1824                .map(|v| v.trim().to_string())
1825                .filter(|v| !v.is_empty())
1826                .collect();
1827            spec.enum_values = Some(values);
1828            i = end;
1829        } else {
1830            // Unrecognized modifier — captured verbatim, surfaced as Info.
1831            spec.unknown_modifiers.push(token.to_string());
1832            i += 1;
1833        }
1834    }
1835
1836    spec
1837}
1838
1839// ── Private helpers ─────────────────────────────────────────────────────────
1840
1841/// Parse a frontmatter timestamp value into a `DateTime<FixedOffset>`. A `null`
1842/// is treated as absent; anything else must be an RFC3339 string.
1843fn parse_timestamp(
1844    value: &Value,
1845    key: &str,
1846    file: &Path,
1847) -> Result<Option<DateTime<FixedOffset>>, ParseError> {
1848    match value {
1849        Value::Null => Ok(None),
1850        Value::String(s) => parse_rfc3339(s, key, file).map(Some),
1851        other => Err(ParseError::BadTimestamp {
1852            file: file.to_path_buf(),
1853            key: key.to_string(),
1854            value: format!("{other:?}"),
1855        }),
1856    }
1857}
1858
1859/// Parse an RFC3339 timestamp string, mapping failure to [`ParseError::BadTimestamp`].
1860fn parse_rfc3339(s: &str, key: &str, file: &Path) -> Result<DateTime<FixedOffset>, ParseError> {
1861    DateTime::parse_from_rfc3339(s.trim()).map_err(|_| ParseError::BadTimestamp {
1862        file: file.to_path_buf(),
1863        key: key.to_string(),
1864        value: s.to_string(),
1865    })
1866}
1867
1868/// Coerce a YAML scalar value to its string form for the universal-contract
1869/// fields (`type`/`id`/`summary`/`status`). Mirrors `validate::scalar_string`
1870/// and `store::yaml_scalar_string` so the four modules agree on one coercion
1871/// rule: a bare numeric/bool scalar (`id: 100`, `summary: 2026`, `status: 0`)
1872/// is preserved as its string form rather than being read as None and silently
1873/// dropped on the next `to_yaml` re-emit. Returns `None` only for genuinely
1874/// non-scalar values (sequences, mappings, null), which were never a valid
1875/// shape for these fields.
1876fn scalar_string(value: &Value) -> Option<String> {
1877    match value {
1878        Value::String(s) => Some(s.clone()),
1879        Value::Number(n) => Some(n.to_string()),
1880        Value::Bool(b) => Some(b.to_string()),
1881        _ => None,
1882    }
1883}
1884
1885/// Read a `tags` value into a flat `Vec<String>`. Accepts a sequence of scalars
1886/// (the canonical form) or a single scalar (coerced to a one-element list).
1887fn parse_tags(value: &Value) -> Vec<String> {
1888    match value {
1889        Value::Sequence(items) => items
1890            .iter()
1891            .filter_map(|v| match v {
1892                Value::String(s) => Some(s.clone()),
1893                Value::Number(n) => Some(n.to_string()),
1894                Value::Bool(b) => Some(b.to_string()),
1895                _ => None,
1896            })
1897            .collect(),
1898        Value::String(s) => vec![s.clone()],
1899        _ => Vec::new(),
1900    }
1901}
1902
1903/// Read a `tags` value into a flat `Vec<String>` **without losing data**: a
1904/// sequence of clean scalars (the canonical form) or a single scalar coerce to a
1905/// string list. Any other shape — a sequence with a non-scalar item
1906/// (`tags: [[vip]]` → `Seq[Seq[String]]`, `tags: [a, [b]]`), or a mapping — is
1907/// rejected as `Err(value.clone())` so the caller preserves the raw value in
1908/// `extra` rather than silently filtering items out / erasing the field on the
1909/// next re-emit. This is the `tags` analog of routing a non-scalar universal
1910/// value to pass-through instead of the destroy path.
1911fn parse_tags_preserving(value: &Value) -> Result<Vec<String>, Value> {
1912    match value {
1913        Value::Sequence(items) => {
1914            let mut out = Vec::with_capacity(items.len());
1915            for item in items {
1916                match item {
1917                    Value::String(s) => out.push(s.clone()),
1918                    Value::Number(n) => out.push(n.to_string()),
1919                    Value::Bool(b) => out.push(b.to_string()),
1920                    // A non-scalar item (nested sequence/mapping/null) means this
1921                    // is not a clean tag list; preserve the whole value verbatim.
1922                    _ => return Err(value.clone()),
1923                }
1924            }
1925            Ok(out)
1926        }
1927        Value::String(s) => Ok(vec![s.clone()]),
1928        Value::Number(n) => Ok(vec![n.to_string()]),
1929        Value::Bool(b) => Ok(vec![b.to_string()]),
1930        // A mapping / null `tags` value is not a list; preserve it verbatim.
1931        _ => Err(value.clone()),
1932    }
1933}
1934
1935/// Render a non-string YAML mapping key as the scalar text YAML would emit for
1936/// it (`2026`, `true`, `3.14`, …), so a numeric/bool/float frontmatter key
1937/// preserves its key *text* on round-trip instead of being rewritten to its Rust
1938/// `Debug` form (`Number(2026)`, `Bool(true)`, `'Null'`). The key re-emits as a
1939/// string-typed key carrying the original text (`'2026':`) — the type narrows to
1940/// string, but the operator's data is no longer corrupted, and ordinary string
1941/// keys are wholly unaffected. Falls back to `Debug` only for a key shape that
1942/// cannot be a scalar (a sequence/mapping key — not expressible in our
1943/// `String`-keyed `extra`), which never occurs in practice.
1944fn yaml_scalar_key(key: &Value) -> String {
1945    match key {
1946        Value::String(s) => s.clone(),
1947        Value::Number(n) => n.to_string(),
1948        Value::Bool(b) => b.to_string(),
1949        Value::Null => "null".to_string(),
1950        // Non-scalar key: not representable as a plain `extra` string key; keep
1951        // the defensive Debug form so nothing panics (unreachable in practice).
1952        other => format!("{other:?}"),
1953    }
1954}
1955
1956/// Parse a single `[[target|display]]` string into a [`WikiLink`] with no
1957/// location, or `None` if the string is not a bare wiki-link. Used for
1958/// frontmatter-valued links where there is no body position to report.
1959fn parse_wiki_link_str(s: &str) -> Option<WikiLink> {
1960    let s = s.trim();
1961    let inner = s.strip_prefix("[[")?.strip_suffix("]]")?;
1962    // Reject anything with further brackets (e.g. the nested flow-form item),
1963    // which is not a clean single wiki-link.
1964    if inner.contains('[') || inner.contains(']') {
1965        return None;
1966    }
1967    let (target, display) = match inner.split_once('|') {
1968        Some((t, d)) => (t.to_string(), Some(d.to_string())),
1969        None => (inner.to_string(), None),
1970    };
1971    Some(WikiLink {
1972        is_full_path: target_is_full_path(&target),
1973        has_md_extension: target_has_md_extension(&target),
1974        target,
1975        display,
1976        location: (PathBuf::new(), 0, 0),
1977    })
1978}
1979
1980/// Extract every wiki-link from a single frontmatter field value, accepting the
1981/// two canonical forms the spec defines (SPEC § Linking):
1982///
1983/// - a **scalar** wiki-link field, in either the quoted (`f: "[[x]]"`) or the
1984///   canonical unquoted inline (`f: [[x]]`) form, and
1985/// - a **list** field whose items are quoted wiki-link strings
1986///   (`- "[[x]]"`).
1987///
1988/// YAML eats the brackets of an unquoted `[[x]]`, leaving a flow-list-in-a-list,
1989/// so the parsed [`Value`] shapes are not what one would naively expect:
1990///
1991/// | source                         | parsed `Value`                     | here |
1992/// |--------------------------------|------------------------------------|------|
1993/// | `f: "[[x]]"`       (quoted)    | `String("[[x]]")`                  | link |
1994/// | `f: [[x]]`         (unquoted)  | `Seq[ Seq[String("x")] ]`          | link |
1995/// | `f:`\n`  - "[[x]]"`(quoted)    | `Seq[ String("[[x]]"), … ]`        | link |
1996/// | `f:`\n`  - [[x]]`  (unquoted)  | `Seq[ Seq[Seq[String("x")]], … ]`  | —    |
1997///
1998/// The last row — an *unquoted list* — parses identically to the flow-form list
1999/// `f: [[a], [b]]` and is a mis-encoding the canonical writer never emits;
2000/// `dbmd validate` reports it as `WIKI_LINK_FLOW_FORM_LIST` (see
2001/// [`detect_flow_form_link_lists`]). It is deliberately NOT surfaced here, so an
2002/// edge enumerator only ever sees the valid canonical forms.
2003///
2004/// The unquoted scalar (`Seq[Seq[String]]`, one element) is told apart from a
2005/// plain one-item flow list (`f: [x]` → `Seq[String]`, one fewer nesting level)
2006/// by [`unquoted_inline_link`] requiring its argument to be a `Sequence`.
2007fn links_in_field_value(value: &Value) -> Vec<WikiLink> {
2008    // Quoted scalar: `field: "[[x]]"`.
2009    if let Value::String(s) = value {
2010        return parse_wiki_link_str(s).into_iter().collect();
2011    }
2012    let Value::Sequence(items) = value else {
2013        return Vec::new();
2014    };
2015    // Unquoted scalar inline form `field: [[x]]` → `Seq[ Seq[String(x)] ]`.
2016    // (A quoted single-item list `["[[x]]"]` is `Seq[String]`, so its lone item
2017    // is a `String`, not a `Sequence`, and falls through to the list path below.)
2018    if items.len() == 1 {
2019        if let Some(link) = unquoted_inline_link(&items[0]) {
2020            return vec![link];
2021        }
2022    }
2023    // Otherwise a list of quoted wiki-link strings; non-string items (the
2024    // unquoted-list mis-encoding) are left for validate to flag.
2025    items
2026        .iter()
2027        .filter_map(|item| parse_wiki_link_str(item.as_str()?))
2028        .collect()
2029}
2030
2031/// Canonicalize one `extra` frontmatter value for emission by [`Frontmatter::to_yaml`].
2032///
2033/// The read path ([`Frontmatter::parse`]) stores every unknown key's raw parsed
2034/// [`Value`] verbatim, so a SPEC-canonical *unquoted* inline scalar wiki-link
2035/// (`company: [[records/companies/northstar]]`) lands in `extra` as the nested
2036/// shape YAML produces for it — `Seq[ Seq[String("records/companies/northstar")] ]`.
2037/// Re-emitting that verbatim yields the block sequence
2038///
2039/// ```text
2040/// company:
2041/// - - records/companies/northstar
2042/// ```
2043///
2044/// which has lost the `[[ ]]` brackets entirely: the link is destroyed, and every
2045/// reader (validate, graph, backlinks) stops seeing the edge. This normalizes such
2046/// a value back into the canonical emitted form before it is written:
2047///
2048/// - a **scalar** wiki-link (quoted `String("[[x]]")` or unquoted `Seq[Seq[String]]`,
2049///   one element) → a quoted scalar `Value::String("[[x]]")`, which serde_norway emits
2050///   inline as `'[[x]]'` — the form the finding confirms survives a round-trip and
2051///   that [`links_in_field_value`] reads back as the same scalar link;
2052/// - a **list** of wiki-links (in any spelling [`links_in_field_value`] accepts) →
2053///   a block `Value::Sequence` of quoted-link strings (`- "[[x]]"`), matching the
2054///   `set` write-in path and the canonical list form;
2055/// - everything else → returned verbatim (the common no-op for non-link values).
2056///
2057/// `|display` is preserved in both link branches. This is the single point that
2058/// keeps all three curator-loop writers (`format`, `fm set`, `link`) from
2059/// corrupting a pre-existing canonical link, since they all funnel through
2060/// `to_yaml`.
2061fn canonicalize_extra_value(value: &Value) -> Value {
2062    match value {
2063        // Scalar wiki-link, quoted form: `field: "[[x]]"` → `String("[[x]]")`.
2064        // Re-emit as a quoted scalar so it stays a string (never the brackets-as-
2065        // YAML nested sequence). Non-link strings are returned untouched.
2066        Value::String(s) => match parse_wiki_link_str(s) {
2067            Some(link) => Value::String(wiki_link_literal(&link)),
2068            None => value.clone(),
2069        },
2070        Value::Sequence(items) => {
2071            // NOTE: we deliberately do NOT collapse a one-element
2072            // `Seq[ Seq[String(x)] ]` to the scalar `String("[[x]]")` here. That
2073            // shape is ambiguous — `serde_norway` parses BOTH an inline scalar
2074            // wiki-link `field: [[x]]` AND a genuine 2D array `field:`\n`- - x`
2075            // to exactly that value, so collapsing it silently retyped a real
2076            // nested array (`matrix: [["cell"]]`) into the string `'[[cell]]'`
2077            // and the file stopped round-tripping. The two cases ARE
2078            // distinguishable, but only from the source text, so the genuine
2079            // inline-link case is resolved at parse time
2080            // ([`Frontmatter::parse`] → [`inline_scalar_link_keys`]), where it is
2081            // stored as a `String("[[x]]")` and handled by the arm above. By the
2082            // time a `Seq[Seq[String]]` reaches here it is a real nested array and
2083            // must pass through verbatim (SPEC § "Unknown fields pass through").
2084            // List of wiki-links: re-emit as a block sequence of quoted-link
2085            // strings, the canonical list form `to_yaml` renders block-style and
2086            // `links_in_field_value` accepts. Only canonicalize when *every* item
2087            // is a clean single wiki-link; a list with any non-link item is left
2088            // verbatim so unrelated sequences (and the unquoted-list mis-encoding
2089            // validate flags) are untouched.
2090            let mut links = Vec::with_capacity(items.len());
2091            for item in items {
2092                match link_from_flow_list_item(item) {
2093                    Some(link) => links.push(link),
2094                    None => return value.clone(),
2095                }
2096            }
2097            if links.is_empty() {
2098                return value.clone();
2099            }
2100            Value::Sequence(
2101                links
2102                    .iter()
2103                    .map(|l| Value::String(wiki_link_literal(l)))
2104                    .collect(),
2105            )
2106        }
2107        // Mappings, scalars other than strings, nulls: nothing to canonicalize.
2108        _ => value.clone(),
2109    }
2110}
2111
2112/// Render a [`WikiLink`] back to its `[[target]]` / `[[target|display]]` literal,
2113/// the inner form the canonical writer emits and `links_in_field_value` accepts.
2114fn wiki_link_literal(link: &WikiLink) -> String {
2115    match &link.display {
2116        Some(d) => format!("[[{}|{}]]", link.target, d),
2117        None => format!("[[{}]]", link.target),
2118    }
2119}
2120
2121/// Recognize the inner token of an unquoted scalar `[[x]]`: after YAML strips the
2122/// outer brackets, the inner `[x]` is a single-element sequence `Seq[String(x)]`.
2123/// Reconstructs `[[x]]` (preserving any `|display`) and parses it, or returns
2124/// `None` when `v` is not that shape. Requiring a `Sequence` here is what keeps a
2125/// plain one-item flow list (`field: [x]` → `Seq[String]`, not `Seq[Seq[String]]`)
2126/// from being mistaken for a wiki-link.
2127fn unquoted_inline_link(v: &Value) -> Option<WikiLink> {
2128    let Value::Sequence(items) = v else {
2129        return None;
2130    };
2131    if items.len() != 1 {
2132        return None;
2133    }
2134    let s = items[0].as_str()?;
2135    // A clean unquoted wiki-link has no further brackets inside it.
2136    if s.contains('[') || s.contains(']') {
2137        return None;
2138    }
2139    parse_wiki_link_str(&format!("[[{s}]]"))
2140}
2141
2142/// Scan raw frontmatter YAML for top-level keys whose value is written in the
2143/// **inline scalar wiki-link** form `key: [[target]]` (optionally
2144/// `[[target|display]]`).
2145///
2146/// This is the one disambiguation the parsed [`Value`] cannot supply on its own:
2147/// `serde_norway` parses BOTH
2148///
2149/// ```yaml
2150/// field: [[x]]
2151/// ```
2152///
2153/// and
2154///
2155/// ```yaml
2156/// field:
2157/// - - x
2158/// ```
2159///
2160/// to the identical `Seq[ Seq[String("x")] ]`. Only the source text says which one
2161/// the operator wrote. [`Frontmatter::parse`] calls this and rewrites the inline
2162/// cases to the canonical scalar `String("[[x]]")`, leaving every genuine nested
2163/// array a sequence (preserved verbatim per SPEC § "Unknown fields pass through").
2164///
2165/// Conservative by construction: a key is reported only when, on a single
2166/// top-level (zero-indent) line, the value after the first `:` is *exactly* one
2167/// `[[…]]` token (whitespace and an optional trailing `# comment` aside) with no
2168/// nested brackets inside. A quoted value (`field: "[[x]]"`), a flow list
2169/// (`field: [[a], [b]]`), a block sequence, or any indented/multi-token value is
2170/// left for the normal parse path. Duplicate keys (last-wins in YAML) are handled
2171/// by the caller looking up the final stored value.
2172fn inline_scalar_link_keys(yaml: &str) -> Vec<String> {
2173    let mut keys = Vec::new();
2174    for line in yaml.lines() {
2175        // Only top-level keys: an indented line is a nested mapping/sequence
2176        // entry, never a top-level `key: [[x]]` scalar.
2177        if line.starts_with(' ') || line.starts_with('\t') {
2178            continue;
2179        }
2180        let Some((raw_key, raw_val)) = line.split_once(':') else {
2181            continue;
2182        };
2183        let key = raw_key.trim();
2184        if key.is_empty() {
2185            continue;
2186        }
2187        // Drop a trailing `# comment` (YAML allows one after a plain scalar on the
2188        // same line). A `#` inside the bracketed link target is not a comment, but
2189        // such a target is rejected below anyway (it would not be a clean link).
2190        let val = match raw_val.split_once(" #") {
2191            Some((before, _)) => before.trim(),
2192            None => raw_val.trim(),
2193        };
2194        // The value must be exactly one bracket-delimited `[[…]]` token: starts
2195        // with `[[`, ends with `]]`, and the inner text carries no further
2196        // brackets (which would make it a flow list / nested collection, not a
2197        // single inline wiki-link).
2198        let Some(inner) = val.strip_prefix("[[").and_then(|s| s.strip_suffix("]]")) else {
2199            continue;
2200        };
2201        if inner.contains('[') || inner.contains(']') {
2202            continue;
2203        }
2204        // Confirm it is actually a parseable wiki-link, not e.g. an empty `[[]]`.
2205        if parse_wiki_link_str(val).is_some() {
2206            keys.push(key.to_string());
2207        }
2208    }
2209    keys
2210}
2211
2212/// Decide whether a `dbmd fm set` / `--fm` value string is a **list of
2213/// wiki-links** that should be stored as a YAML block sequence, returning the
2214/// canonical `Value::Sequence` of quoted-link strings when so.
2215///
2216/// The value path of every write surface stringifies its argument; without this
2217/// a required list-of-links field (`meeting.attendees`) was unwritable in valid
2218/// form — passing `[[[a]], [[b]]]` stored a single scalar string that mis-parses
2219/// and trips `WIKI_LINK_FLOW_FORM_LIST` / `WIKI_LINK_BROKEN`. This recognizes the
2220/// two list spellings an agent naturally types and normalizes both to the block
2221/// form the canonical writer emits and `dbmd validate` accepts:
2222///
2223/// - flow list of quoted links — `["[[a]]", "[[b]]"]`
2224/// - flow list of unquoted links — `[[[a]], [[b]]]` (YAML: `Seq[Seq[String], …]`)
2225///
2226/// Returns `None` (⇒ caller stores a verbatim scalar string) for everything that
2227/// is not unambiguously a list of clean wiki-links — plain text, a single inline
2228/// `[[x]]` (YAML reads it as a one-item `Seq[Seq[String]]`, kept scalar so it
2229/// renders inline), an empty list, or a list with any non-link item. A single
2230/// link must stay scalar; only genuine multi-item-or-explicit lists become
2231/// sequences, matching `links_in_field_value`'s acceptance rule so writer and
2232/// validator never disagree.
2233fn parse_link_list_value(value: &str) -> Option<Value> {
2234    let trimmed = value.trim();
2235    // Only a YAML *flow sequence* literal is a list candidate; anything not
2236    // wrapped in `[ … ]` is a scalar (a bare `[[x]]` is wrapped, and handled by
2237    // the single-inline-link guard below).
2238    if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
2239        return None;
2240    }
2241    let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(trimmed) else {
2242        return None;
2243    };
2244    // A single inline `[[x]]` parses to `Seq[ Seq[String(x)] ]` (one item, itself
2245    // a sequence) — that is the unquoted *scalar* form, not a list. Keep it scalar
2246    // so it round-trips to the inline `field: [[x]]` rather than a one-item block
2247    // list. `links_in_field_value` reads it back as a scalar link either way.
2248    if items.len() == 1 && unquoted_inline_link(&items[0]).is_some() {
2249        return None;
2250    }
2251    // Every item must resolve to exactly one clean wiki-link, in any of the flow
2252    // spellings an agent types (see [`link_from_flow_list_item`]).
2253    let mut links = Vec::with_capacity(items.len());
2254    for item in &items {
2255        links.push(link_from_flow_list_item(item)?);
2256    }
2257    if links.is_empty() {
2258        return None;
2259    }
2260    // Normalize to a block sequence of quoted-link strings — the form `to_yaml`
2261    // renders block-style and `links_in_field_value` accepts. `|display` is
2262    // preserved.
2263    let normalized = links
2264        .iter()
2265        .map(|l| Value::String(wiki_link_literal(l)))
2266        .collect();
2267    Some(Value::Sequence(normalized))
2268}
2269
2270/// Recognize one clean wiki-link from a single **item** of a YAML flow sequence,
2271/// across the spellings an agent types for a list. After top-level flow parsing,
2272/// a list item arrives in one of:
2273///
2274/// - quoted — `"[[x]]"` ⇒ `String("[[x]]")`
2275/// - unquoted in a flow list — `[[x]]` inside `[…]` ⇒ `Seq[ Seq[String(x)] ]`
2276///   (one level deeper than a bare unquoted scalar, because the surrounding list
2277///   adds a wrapper); unwrap the single-element wrapper, then read the inline
2278///   `Seq[String(x)]` with [`unquoted_inline_link`].
2279///
2280/// Returns `None` for any item that is not exactly one clean wiki-link, so the
2281/// caller falls back to a scalar string and never fabricates a partial list.
2282fn link_from_flow_list_item(item: &Value) -> Option<WikiLink> {
2283    match item {
2284        Value::String(s) => parse_wiki_link_str(s),
2285        Value::Sequence(inner) => {
2286            // Unquoted list item `[[x]]` → `Seq[ Seq[String(x)] ]`: peel the lone
2287            // wrapper to expose the inline-link shape `Seq[String(x)]`.
2288            //
2289            // Only this triple-nested shape is a wiki-link. We deliberately do
2290            // NOT fall back to `unquoted_inline_link(item)` on the bare double
2291            // nesting `Seq[String(x)]` (a plain one-element string list `[x]`):
2292            // that fallback fabricated a wiki-link out of an ordinary nested
2293            // string list — `groups: [[alpha], [beta]]` (data `[["alpha"],
2294            // ["beta"]]`) was rewritten to `- '[[alpha]]'` / `- '[[beta]]'`,
2295            // silently changing the field's type and manufacturing short-form
2296            // links the tool then flags as `WIKI_LINK_SHORT_FORM`. An unknown
2297            // nested string list must pass through verbatim (SPEC § "Unknown
2298            // fields pass through").
2299            if inner.len() == 1 {
2300                if let Some(link) = unquoted_inline_link(&inner[0]) {
2301                    return Some(link);
2302                }
2303            }
2304            None
2305        }
2306        _ => None,
2307    }
2308}
2309
2310/// A target is a full store-relative path when its first path segment is one of
2311/// the three canonical layer dirs and at least one `/` separator follows. A
2312/// trailing `.md` does not affect this classification.
2313fn target_is_full_path(target: &str) -> bool {
2314    let target = target.trim();
2315    match target.split_once('/') {
2316        Some((head, _rest)) => LAYER_DIRS.contains(&head),
2317        None => false,
2318    }
2319}
2320
2321/// True when the target carries a trailing `.md` extension (validate warns
2322/// `WIKI_LINK_HAS_EXTENSION`).
2323fn target_has_md_extension(target: &str) -> bool {
2324    target.trim().ends_with(".md")
2325}
2326
2327/// A forward-only cursor that yields the 1-based character (Unicode scalar)
2328/// column of successive byte offsets within a single line in ONE linear pass.
2329///
2330/// The previous helper recomputed `line[..offset].chars().count()` from the line
2331/// start for every match, so a line with N matches cost O(N × line_len) — a
2332/// quadratic blowup on a link-dense line. Because regex matches arrive in
2333/// non-decreasing byte order, this cursor advances the char count only across the
2334/// gap since the last queried offset, giving O(line_len) total per line.
2335///
2336/// Offsets MUST be queried in non-decreasing order and must fall on UTF-8
2337/// character boundaries (regex match starts always do).
2338struct ColCursor {
2339    byte: usize,
2340    chars: u32,
2341}
2342
2343impl ColCursor {
2344    fn new() -> Self {
2345        ColCursor { byte: 0, chars: 0 }
2346    }
2347
2348    /// 1-based character column of `byte_offset` in `line`. `byte_offset` must be
2349    /// `>=` every previously queried offset (debug-asserted).
2350    fn column_at(&mut self, line: &str, byte_offset: usize) -> u32 {
2351        debug_assert!(byte_offset >= self.byte, "ColCursor queried out of order");
2352        self.chars += line[self.byte..byte_offset].chars().count() as u32;
2353        self.byte = byte_offset;
2354        self.chars + 1
2355    }
2356}
2357
2358/// Index of the first comma-token in `raw[from..]` that *starts a greedy
2359/// modifier clause* (`enum`, `enum:…`, or `default …`), or `raw.len()` when none
2360/// remain. Used to bound a greedy `default`/`enum` value so it stops at the next
2361/// such clause instead of either truncating at the first comma or swallowing a
2362/// following greedy clause whole.
2363fn next_greedy_clause(raw: &[&str], from: usize) -> usize {
2364    let mut j = from;
2365    while j < raw.len() {
2366        let lower = raw[j].trim().to_ascii_lowercase();
2367        if lower == "enum" || lower.starts_with("enum:") || lower.starts_with("default ") {
2368            return j;
2369        }
2370        j += 1;
2371    }
2372    raw.len()
2373}
2374
2375/// Map a lowercase shape keyword to its [`Shape`].
2376fn shape_from_str(s: &str) -> Option<Shape> {
2377    match s {
2378        "string" => Some(Shape::String),
2379        "int" => Some(Shape::Int),
2380        "bool" => Some(Shape::Bool),
2381        "date" => Some(Shape::Date),
2382        "email" => Some(Shape::Email),
2383        "currency" => Some(Shape::Currency),
2384        "url" => Some(Shape::Url),
2385        _ => None,
2386    }
2387}
2388
2389/// The ATX heading level of a line (number of leading `#`), or 0 if not a
2390/// heading. Up to three leading spaces (CommonMark), requires a space/tab (or
2391/// end-of-line) after the `#` run, caps the run at six.
2392fn heading_level(line: &str) -> u8 {
2393    let indent = line.len() - line.trim_start_matches(' ').len();
2394    if indent > 3 {
2395        return 0;
2396    }
2397    let rest = &line[indent..];
2398    let hashes = rest.len() - rest.trim_start_matches('#').len();
2399    if hashes == 0 || hashes > 6 {
2400        return 0;
2401    }
2402    let after = &rest[hashes..];
2403    if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
2404        hashes as u8
2405    } else {
2406        0
2407    }
2408}
2409
2410/// The heading text after the `#` run, trimmed, with a trailing ATX *closing*
2411/// `#` sequence removed per CommonMark (`## Title ##` → `Title`).
2412///
2413/// CommonMark only treats a trailing run of `#` as a closing sequence when it is
2414/// **preceded by a space or tab** (or the content is empty). A `#` that abuts the
2415/// preceding word is literal heading text: `## C#` → `C#`, `## F#` → `F#`,
2416/// `## issue-123#` → `issue-123#`. The old unconditional `trim_end_matches('#')`
2417/// stripped those, corrupting `dbmd sections`/`outline` heading text and — via
2418/// `parse_db_md` using the heading verbatim as the schema type key — silently
2419/// binding a `### c#` schema to `type: c` instead of `type: c#`.
2420fn heading_text(line: &str, level: u8) -> String {
2421    let indent = line.len() - line.trim_start_matches(' ').len();
2422    let after_hashes = &line[indent + level as usize..];
2423    let trimmed = after_hashes.trim();
2424
2425    // Peel a trailing run of `#`. It is a closing sequence only if what precedes
2426    // it (within `trimmed`) is empty or ends in a space/tab; otherwise the `#`s
2427    // are literal content.
2428    let without_hashes = trimmed.trim_end_matches('#');
2429    if without_hashes.len() == trimmed.len() {
2430        // No trailing `#` at all.
2431        return trimmed.to_string();
2432    }
2433    if without_hashes.is_empty() || without_hashes.ends_with([' ', '\t']) {
2434        // A genuine closing sequence (`## Title ##`, `## ##`): drop it and the
2435        // whitespace before it.
2436        without_hashes.trim_end().to_string()
2437    } else {
2438        // The `#` run abuts content (`## C#`): keep it as literal heading text.
2439        trimmed.to_string()
2440    }
2441}
2442
2443/// If `line` opens a fenced code block, return `(fence byte, run length)`.
2444fn opening_fence(line: &str) -> Option<(u8, usize)> {
2445    let indent = line.len() - line.trim_start_matches(' ').len();
2446    if indent > 3 {
2447        return None;
2448    }
2449    let rest = &line[indent..];
2450    let byte = rest.bytes().next()?;
2451    if byte != b'`' && byte != b'~' {
2452        return None;
2453    }
2454    let run = rest.len() - rest.trim_start_matches(byte as char).len();
2455    if run < 3 {
2456        return None;
2457    }
2458    // A backtick fence's info string may not itself contain a backtick.
2459    if byte == b'`' && rest[run..].contains('`') {
2460        return None;
2461    }
2462    Some((byte, run))
2463}
2464
2465/// True if `line` closes the currently open fence: same char, run at least as
2466/// long, nothing but trailing whitespace after.
2467fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
2468    let (byte, open_len) = fence;
2469    let indent = line.len() - line.trim_start_matches(' ').len();
2470    if indent > 3 {
2471        return false;
2472    }
2473    let rest = &line[indent..];
2474    let run = rest.len() - rest.trim_start_matches(byte as char).len();
2475    if run < open_len {
2476        return false;
2477    }
2478    rest[run..].trim().is_empty()
2479}
2480
2481/// The prose body of a section: everything after the heading line, trimmed.
2482fn section_prose(section_body: &str) -> String {
2483    match section_body.split_once('\n') {
2484        Some((_heading, rest)) => rest.trim().to_string(),
2485        None => String::new(),
2486    }
2487}
2488
2489/// The bullet lines (`-`/`*`/`+`) of a section body, excluding the heading
2490/// line, each returned with its leading whitespace trimmed.
2491fn bullet_lines(section_body: &str) -> Vec<String> {
2492    section_body
2493        .lines()
2494        .skip(1) // the heading line
2495        .map(str::trim)
2496        .filter(|l| l.starts_with("- ") || l.starts_with("* ") || l.starts_with("+ "))
2497        .map(|l| l.to_string())
2498        .collect()
2499}
2500
2501/// Cut a bullet's content at the first comment separator, returning only the
2502/// meaningful prefix. Recognizes the em-dash (` — `), en-dash (` – `), double-
2503/// hyphen (` -- `), and the plain single-ASCII-hyphen (` - `) spellings an
2504/// operator naturally types — without the single-hyphen form, a comment like
2505/// `records/decisions/q3.md - finalized` left the whole line (comment included)
2506/// as the frozen path, so the entry never matched and the freeze failed OPEN.
2507/// A store-relative path never contains a ` - ` (paths are `/`-joined, spaceless),
2508/// so this does not truncate legitimate path text.
2509fn strip_bullet_comment(content: &str) -> &str {
2510    let mut cut = content.len();
2511    for sep in [" — ", " -- ", " – ", " - "] {
2512        if let Some(idx) = content.find(sep) {
2513            cut = cut.min(idx);
2514        }
2515    }
2516    content[..cut].trim()
2517}
2518
2519/// Strip the leading bullet marker, returning the trimmed content after it.
2520fn bullet_content(bullet: &str) -> &str {
2521    let t = bullet.trim();
2522    t.strip_prefix("- ")
2523        .or_else(|| t.strip_prefix("* "))
2524        .or_else(|| t.strip_prefix("+ "))
2525        .unwrap_or(t)
2526        .trim()
2527}
2528
2529/// Extract a store-relative path from a Frozen-pages bullet. The path may be
2530/// wrapped in backticks and followed by an em-dash comment.
2531fn extract_path_bullet(bullet: &str) -> String {
2532    let content = bullet_content(bullet);
2533    // Prefer a backtick-delimited span if present.
2534    if let Some(start) = content.find('`') {
2535        if let Some(end_rel) = content[start + 1..].find('`') {
2536            return content[start + 1..start + 1 + end_rel].trim().to_string();
2537        }
2538    }
2539    // Otherwise take the text up to a comment separator, stripping quotes.
2540    strip_bullet_comment(content)
2541        .trim_matches('"')
2542        .trim_matches('\'')
2543        .trim()
2544        .to_string()
2545}
2546
2547/// Extract a comma-separated type list from an Ignored-types bullet, stripping
2548/// backticks/quotes and any trailing em-dash comment.
2549fn extract_type_list_bullet(bullet: &str) -> Vec<String> {
2550    let content = strip_bullet_comment(bullet_content(bullet));
2551    content
2552        .split(',')
2553        .map(|t| {
2554            t.trim()
2555                .trim_matches('`')
2556                .trim_matches('"')
2557                .trim_matches('\'')
2558                .trim()
2559                .to_string()
2560        })
2561        .filter(|t| !t.is_empty())
2562        .collect()
2563}
2564
2565#[cfg(test)]
2566mod tests {
2567    use super::*;
2568    use std::path::Path;
2569    use tempfile::tempdir;
2570
2571    // ── Config::frozen_match (the single write-surface policy matcher) ───────
2572
2573    #[test]
2574    fn frozen_match_is_md_insensitive_both_directions() {
2575        // A policy entry stored WITHOUT `.md` (the natural extensionless
2576        // spelling `parse_db_md` keeps verbatim) must still match a `.md`
2577        // write target — the regression every write surface had.
2578        let cfg = Config {
2579            frozen_pages: vec![PathBuf::from("records/decisions/q1")],
2580            ..Config::default()
2581        };
2582        assert_eq!(
2583            cfg.frozen_match(Path::new("records/decisions/q1.md")),
2584            Some(PathBuf::from("records/decisions/q1")),
2585            "extensionless policy entry must freeze the .md file"
2586        );
2587        assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
2588
2589        // The symmetric case: a policy entry WITH `.md` matches a bare target.
2590        let cfg = Config {
2591            frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
2592            ..Config::default()
2593        };
2594        assert_eq!(
2595            cfg.frozen_match(Path::new("records/decisions/q1")),
2596            Some(PathBuf::from("records/decisions/q1.md")),
2597        );
2598        // And the same-spelling cases still match.
2599        assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
2600    }
2601
2602    #[test]
2603    fn frozen_match_drops_leading_dot_slash() {
2604        let cfg = Config {
2605            frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
2606            ..Config::default()
2607        };
2608        assert!(cfg.is_frozen(Path::new("./records/decisions/q1.md")));
2609        assert!(cfg.is_frozen(Path::new("./records/decisions/q1")));
2610    }
2611
2612    #[test]
2613    fn frozen_match_returns_none_for_unlisted_and_prefix_paths() {
2614        let cfg = Config {
2615            frozen_pages: vec![PathBuf::from("records/decisions/q1")],
2616            ..Config::default()
2617        };
2618        assert!(cfg
2619            .frozen_match(Path::new("records/decisions/q2.md"))
2620            .is_none());
2621        // A prefix is not a match: `q1` must not freeze `q1-draft`.
2622        assert!(cfg
2623            .frozen_match(Path::new("records/decisions/q1-draft.md"))
2624            .is_none());
2625        assert!(!cfg.is_frozen(Path::new("records/decisions/q11.md")));
2626    }
2627
2628    // ── split_frontmatter ───────────────────────────────────────────────────
2629
2630    #[test]
2631    fn split_frontmatter_separates_yaml_and_verbatim_body() {
2632        let text = "---\ntype: contact\nsummary: x\n---\n# Heading\n\nBody line.\n";
2633        let p = split_frontmatter(text, Path::new("f.md")).unwrap();
2634        assert_eq!(p.frontmatter_yaml, "type: contact\nsummary: x\n");
2635        // Body is everything after the closing fence's newline, byte-for-byte.
2636        assert_eq!(p.body, "# Heading\n\nBody line.\n");
2637    }
2638
2639    #[test]
2640    fn split_frontmatter_preserves_body_without_trailing_newline() {
2641        let text = "---\ntype: x\n---\nno trailing newline";
2642        let p = split_frontmatter(text, Path::new("f.md")).unwrap();
2643        assert_eq!(p.body, "no trailing newline");
2644    }
2645
2646    #[test]
2647    fn split_frontmatter_empty_body_when_nothing_after_fence() {
2648        let text = "---\ntype: x\n---\n";
2649        let p = split_frontmatter(text, Path::new("f.md")).unwrap();
2650        assert_eq!(p.body, "");
2651    }
2652
2653    #[test]
2654    fn split_frontmatter_missing_opening_fence_errors() {
2655        let text = "# No frontmatter here\ntype: x\n";
2656        let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
2657        assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
2658    }
2659
2660    #[test]
2661    fn split_frontmatter_leading_content_before_fence_rejected() {
2662        // The opening fence must be the very first line; a blank line first is
2663        // not allowed.
2664        let text = "\n---\ntype: x\n---\nbody";
2665        let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
2666        assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
2667    }
2668
2669    #[test]
2670    fn split_frontmatter_unterminated_block_errors() {
2671        let text = "---\ntype: x\nsummary: y\n";
2672        let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
2673        assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
2674    }
2675
2676    // ── Frontmatter::parse ───────────────────────────────────────────────────
2677
2678    #[test]
2679    fn parse_populates_typed_fields_and_routes_unknowns_to_extra() {
2680        let yaml = "type: contact\nid: sarah-chen\nsummary: Director of Ops\nstatus: active\ntags: [vip, renewal]\nemail: sarah@northstar.io\nrole: Director";
2681        let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
2682        assert_eq!(fm.type_.as_deref(), Some("contact"));
2683        assert_eq!(fm.id.as_deref(), Some("sarah-chen"));
2684        assert_eq!(fm.summary.as_deref(), Some("Director of Ops"));
2685        assert_eq!(fm.status.as_deref(), Some("active"));
2686        assert_eq!(fm.tags, vec!["vip".to_string(), "renewal".to_string()]);
2687        // Type-specific fields are NOT promoted to typed slots.
2688        assert!(fm.type_.is_some() && !fm.extra.contains_key("type"));
2689        assert!(!fm.extra.contains_key("tags"));
2690        assert_eq!(
2691            fm.extra.get("email").and_then(|v| v.as_str()),
2692            Some("sarah@northstar.io")
2693        );
2694        assert_eq!(
2695            fm.extra.get("role").and_then(|v| v.as_str()),
2696            Some("Director")
2697        );
2698    }
2699
2700    #[test]
2701    fn parse_reads_rfc3339_timestamps() {
2702        let yaml =
2703            "type: email\ncreated: 2026-05-27T08:00:00-07:00\nupdated: 2026-05-28T09:30:00-07:00";
2704        let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
2705        let created = fm.created.expect("created parsed");
2706        // -07:00 offset is 7 * 3600 seconds west.
2707        assert_eq!(created.offset().utc_minus_local(), 7 * 3600);
2708        assert_eq!(created.to_rfc3339(), "2026-05-27T08:00:00-07:00");
2709        assert!(fm.updated.is_some());
2710    }
2711
2712    #[test]
2713    fn parse_preserves_non_rfc3339_timestamp_verbatim() {
2714        // A date-only value is not a full RFC3339 timestamp, so the typed
2715        // accessor stays None — but the READ path must never destroy it or
2716        // refuse the file. It rides in `extra` and round-trips byte-for-byte,
2717        // exactly like a non-scalar `type`/`summary`. `validate` is what
2718        // reports it (FM_BAD_TIMESTAMP, raised from the raw YAML value).
2719        //
2720        // Regression: erroring here made `dbmd format` (and `fm`/`link`/
2721        // `rename`, all of which go through `read_file`) fail outright on any
2722        // store carrying a legacy date-only stamp — the common migrated shape.
2723        let yaml = "type: email\ncreated: 2026-05-27";
2724        let fm = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap();
2725        assert!(
2726            fm.created.is_none(),
2727            "unparseable stamp offers no typed value"
2728        );
2729        assert_eq!(
2730            fm.extra.get("created").and_then(Value::as_str),
2731            Some("2026-05-27"),
2732            "the operator's bytes must survive the read"
2733        );
2734        assert!(
2735            fm.to_yaml().contains("created: 2026-05-27"),
2736            "and must re-emit verbatim; got:\n{}",
2737            fm.to_yaml()
2738        );
2739    }
2740
2741    #[test]
2742    fn set_still_refuses_to_author_a_bad_timestamp() {
2743        // The read/write asymmetry is the point: tolerate what a store already
2744        // contains, never CREATE a malformed value. (`set_timestamp_validates_
2745        // rfc3339` covers the same boundary from the write side.)
2746        let mut fm = Frontmatter::parse("type: email\ncreated: 2026-05-27", Path::new("b.md"))
2747            .expect("read tolerates the legacy stamp");
2748        assert!(matches!(
2749            fm.set("created", "still-not-a-date").unwrap_err(),
2750            ParseError::BadTimestamp { .. }
2751        ));
2752    }
2753
2754    #[test]
2755    fn parse_malformed_yaml_errors() {
2756        // Unclosed flow mapping is invalid YAML.
2757        let yaml = "type: contact\n  bad: : :\n- nope";
2758        let err = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap_err();
2759        assert!(matches!(err, ParseError::MalformedYaml { .. }));
2760    }
2761
2762    #[test]
2763    fn frontmatter_with_yaml_tag_on_mapping_does_not_panic() {
2764        // Regression: a YAML tag on the top-level mapping made the old
2765        // `expect_err` path PANIC, because a tagged mapping deserializes to a
2766        // `Mapping` just fine. It must now be handled — accepted as the inner
2767        // mapping, never a panic.
2768        let fm = Frontmatter::parse("!mytag\ntype: contact\nsummary: hi\n", Path::new("x.md"))
2769            .expect("tagged-mapping frontmatter must parse, not panic");
2770        assert_eq!(fm.type_.as_deref(), Some("contact"));
2771        // A genuine scalar/sequence top level is still malformed (and still
2772        // doesn't panic).
2773        assert!(Frontmatter::parse("- a\n- b\n", Path::new("x.md")).is_err());
2774    }
2775
2776    #[test]
2777    fn parse_empty_block_is_empty_frontmatter() {
2778        let fm = Frontmatter::parse("", Path::new("f.md")).unwrap();
2779        assert_eq!(fm, Frontmatter::default());
2780    }
2781
2782    #[test]
2783    fn parse_scalar_top_level_is_malformed() {
2784        // A bare scalar at the top level is not a frontmatter mapping.
2785        let err = Frontmatter::parse("just a string", Path::new("f.md")).unwrap_err();
2786        assert!(matches!(err, ParseError::MalformedYaml { .. }));
2787    }
2788
2789    // ── to_yaml canonical order ──────────────────────────────────────────────
2790
2791    #[test]
2792    fn to_yaml_emits_canonical_key_order() {
2793        let mut fm = Frontmatter {
2794            type_: Some("contact".into()),
2795            id: Some("sarah-chen".into()),
2796            summary: Some("Director of Ops".into()),
2797            status: Some("active".into()),
2798            tags: vec!["vip".into()],
2799            created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
2800            updated: Some(DateTime::parse_from_rfc3339("2026-05-28T09:30:00-07:00").unwrap()),
2801            ..Default::default()
2802        };
2803        // Two type-specific fields, inserted in NON-alphabetical order to prove
2804        // the writer sorts them (BTreeMap) between the universal head and tail.
2805        fm.extra
2806            .insert("role".into(), Value::String("Director".into()));
2807        fm.extra.insert(
2808            "company".into(),
2809            Value::String("[[records/companies/northstar]]".into()),
2810        );
2811
2812        let yaml = fm.to_yaml();
2813        let keys: Vec<&str> = yaml
2814            .lines()
2815            .filter(|l| !l.starts_with(['-', ' ']) && l.contains(':'))
2816            .map(|l| l.split(':').next().unwrap())
2817            .collect();
2818        assert_eq!(
2819            keys,
2820            vec![
2821                "type", "id", "created", "updated", "summary", // universal head
2822                "company", "role",   // type-specific, sorted
2823                "status", // universal tail
2824                "tags",
2825            ],
2826            "canonical order violated; got:\n{yaml}"
2827        );
2828        // Timestamps round-trip as RFC3339 strings (YAML may quote them).
2829        assert!(
2830            yaml.contains("2026-05-27T08:00:00-07:00"),
2831            "created timestamp missing; got:\n{yaml}"
2832        );
2833        // The value re-parses to the same instant regardless of quoting.
2834        let reparsed = Frontmatter::parse(&yaml, Path::new("rt.md")).unwrap();
2835        assert_eq!(reparsed.created, fm.created);
2836        assert_eq!(reparsed.updated, fm.updated);
2837    }
2838
2839    /// Format v0.4: a minted-form (lowercase ULID) `id` round-trips verbatim
2840    /// through parse → to_yaml → parse and holds its canonical head slot —
2841    /// directly after `type` (and after `meta-type` when one is present),
2842    /// before `created`. Pins the emit order for the id-carrying record shape
2843    /// `dbmd write` produces.
2844    #[test]
2845    fn ulid_id_roundtrips_verbatim_in_head_position() {
2846        let ulid = "01j5qc3v9k4ym8rwbn2tqe6f7d";
2847        let yaml = format!(
2848            "type: profile\nmeta-type: conclusion\nid: {ulid}\ncreated: 2026-05-27T08:00:00-07:00\nupdated: 2026-05-27T08:00:00-07:00\nsummary: x\n"
2849        );
2850        let fm = Frontmatter::parse(&yaml, Path::new("rt.md")).unwrap();
2851        assert_eq!(
2852            fm.id.as_deref(),
2853            Some(ulid),
2854            "id must parse into the typed field"
2855        );
2856
2857        let emitted = fm.to_yaml();
2858        let keys: Vec<&str> = emitted
2859            .lines()
2860            .filter(|l| !l.starts_with(['-', ' ']) && l.contains(':'))
2861            .map(|l| l.split(':').next().unwrap())
2862            .collect();
2863        assert_eq!(
2864            keys,
2865            vec!["type", "meta-type", "id", "created", "updated", "summary"],
2866            "id must sit in the universal head; got:\n{emitted}"
2867        );
2868        assert!(
2869            emitted.contains(&format!("id: {ulid}")),
2870            "ULID must emit unquoted and verbatim; got:\n{emitted}"
2871        );
2872        let reparsed = Frontmatter::parse(&emitted, Path::new("rt.md")).unwrap();
2873        assert_eq!(reparsed.id.as_deref(), Some(ulid));
2874        assert_eq!(reparsed, fm, "round-trip must be lossless");
2875    }
2876
2877    #[test]
2878    fn to_yaml_omits_absent_optional_fields() {
2879        let fm = Frontmatter {
2880            type_: Some("note".into()),
2881            ..Default::default()
2882        };
2883        let yaml = fm.to_yaml();
2884        assert!(yaml.contains("type: note"));
2885        assert!(!yaml.contains("status"));
2886        assert!(!yaml.contains("tags"));
2887        assert!(!yaml.contains("summary"));
2888    }
2889
2890    // ── Regression: non-string scalar universal fields round-trip (finding #1) ─
2891
2892    #[test]
2893    fn regression_parse_preserves_non_string_scalar_universal_fields() {
2894        // A hand/externally-authored file whose universal fields are bare
2895        // scalars YAML reads as Number/Bool — `id: 100`, `summary: 2026`,
2896        // `status: 0`, `type: 42` — must be PRESERVED as their string form, not
2897        // read as None. Before the fix, `v.as_str()` returned None for these and
2898        // the matched arm discarded the value entirely (never reaching `extra`).
2899        let yaml = "type: 42\nid: 100\nsummary: 2026\nstatus: 0";
2900        let fm = Frontmatter::parse(yaml, Path::new("x.md")).unwrap();
2901        assert_eq!(fm.type_.as_deref(), Some("42"), "type scalar dropped");
2902        assert_eq!(fm.id.as_deref(), Some("100"), "id scalar dropped");
2903        assert_eq!(
2904            fm.summary.as_deref(),
2905            Some("2026"),
2906            "summary scalar dropped"
2907        );
2908        assert_eq!(fm.status.as_deref(), Some("0"), "status scalar dropped");
2909        // The values must surface through the public `get` accessor too.
2910        assert_eq!(
2911            fm.get("summary")
2912                .and_then(|v| v.as_str().map(str::to_string)),
2913            Some("2026".to_string())
2914        );
2915    }
2916
2917    #[test]
2918    fn regression_format_round_trip_does_not_delete_numeric_frontmatter() {
2919        // The exact finding-#1 trigger: `dbmd format` is read_file -> write_file.
2920        // A file whose `id`/`summary`/`status` are bare numeric scalars must
2921        // still carry those fields after the canonical re-emit. Before the fix,
2922        // the lines were silently deleted from disk (only `type` survived).
2923        let dir = tempdir().unwrap();
2924        let path = dir.path().join("x.md");
2925        let original = "---\ntype: contact\nid: 100\nsummary: 2026\nstatus: 0\n---\nbody\n";
2926        std::fs::write(&path, original).unwrap();
2927
2928        // Re-emit through the canonical writer, exactly as `dbmd format` does.
2929        let (fm, body) = read_file(&path).unwrap();
2930        write_file(&path, &fm, &body).unwrap();
2931
2932        let after = std::fs::read_to_string(&path).unwrap();
2933        // None of the four fields may vanish; they survive as string scalars.
2934        let reparsed = Frontmatter::parse(
2935            &split_frontmatter(&after, &path).unwrap().frontmatter_yaml,
2936            &path,
2937        )
2938        .unwrap();
2939        assert_eq!(reparsed.type_.as_deref(), Some("contact"));
2940        assert_eq!(reparsed.id.as_deref(), Some("100"), "id deleted by format");
2941        assert_eq!(
2942            reparsed.summary.as_deref(),
2943            Some("2026"),
2944            "summary deleted by format"
2945        );
2946        assert_eq!(
2947            reparsed.status.as_deref(),
2948            Some("0"),
2949            "status deleted by format"
2950        );
2951        // The body is preserved verbatim.
2952        assert_eq!(body, "body\n");
2953    }
2954
2955    #[test]
2956    fn regression_format_round_trip_preserves_oversized_integer_frontmatter() {
2957        // Adversarial review #6: a bare integer literal beyond i64/u64 range must
2958        // survive `dbmd format` (read_file -> write_file) byte-for-byte. Before
2959        // the fix, serde_norway silently truncated `> u128::MAX` to f64 (`999…9`
2960        // -> `1e39`) and hard-rejected `(u64::MAX, u128::MAX]` — corrupting an
2961        // imported numeric ID and breaking the unknown-field round-trip contract.
2962        let dir = tempdir().unwrap();
2963        let path = dir.path().join("x.md");
2964        let big = "999999999999999999999999999999999999999"; // 39 digits, > u128::MAX
2965        let mid = "99999999999999999999"; // 20 digits, in (u64::MAX, u128::MAX]
2966        let original = format!(
2967            "---\ntype: contact\nsummary: x\naccount_number: {big}\nid_num: {mid}\n---\nbody\n"
2968        );
2969        std::fs::write(&path, &original).unwrap();
2970
2971        // Two round-trips: the value must survive verbatim AND be idempotent.
2972        for _ in 0..2 {
2973            let (fm, body) = read_file(&path).expect("oversized-int frontmatter must parse");
2974            write_file(&path, &fm, &body).unwrap();
2975            let after = std::fs::read_to_string(&path).unwrap();
2976            assert!(
2977                after.contains(big),
2978                "39-digit integer corrupted by format:\n{after}"
2979            );
2980            assert!(
2981                after.contains(mid),
2982                "20-digit integer corrupted by format:\n{after}"
2983            );
2984            assert!(
2985                !after.to_lowercase().contains("1e39"),
2986                "integer was truncated to a float:\n{after}"
2987            );
2988            assert_eq!(body, "body\n", "body must be preserved verbatim");
2989        }
2990    }
2991
2992    #[test]
2993    fn oversized_int_literal_detection_is_precise() {
2994        // In range (serde_norway handles losslessly) → never quoted.
2995        for ok in [
2996            "0",
2997            "42",
2998            "-17",
2999            "9223372036854775807",
3000            "18446744073709551615",
3001            "12.5",
3002            "007",
3003            "abc",
3004            "",
3005        ] {
3006            assert!(
3007                !is_oversized_int_literal(ok),
3008                "must NOT be flagged oversized: {ok:?}"
3009            );
3010        }
3011        // Beyond i64/u64 → quoted to preserve the literal.
3012        for big in [
3013            "18446744073709551616",                    // u64::MAX + 1
3014            "99999999999999999999",                    // 20 digits
3015            "999999999999999999999999999999999999999", // 39 digits
3016            "-9999999999999999999999",                 // very negative
3017        ] {
3018            assert!(
3019                is_oversized_int_literal(big),
3020                "must be flagged oversized: {big:?}"
3021            );
3022        }
3023    }
3024
3025    #[test]
3026    fn regression_oversized_int_in_flow_sequence_round_trips() {
3027        // The single-line flow SEQUENCE form regressed: an oversized int inside
3028        // `ids: [123…]` reached serde_norway un-quoted and hard-failed the whole
3029        // block as MalformedYaml (`as u128`), making every read surface
3030        // (format / fm get/set / link / validate) unable to read the file at all.
3031        // It must now parse, preserve the literal verbatim, and be idempotent.
3032        let dir = tempdir().unwrap();
3033        let path = dir.path().join("f.md");
3034        let big = "123456789012345678901234567890"; // 30 digits, > u128::MAX
3035        let original = format!("---\ntype: note\nsummary: x\nids: [{big}]\n---\nbody\n");
3036        std::fs::write(&path, &original).unwrap();
3037
3038        for _ in 0..2 {
3039            let (fm, body) = read_file(&path).expect("flow-sequence oversized int must parse");
3040            // The list value survives in `extra`, holding the literal as a string.
3041            let ids = fm.extra.get("ids").expect("ids field preserved");
3042            assert!(
3043                matches!(ids, Value::Sequence(_)),
3044                "ids should stay a sequence, got: {ids:?}"
3045            );
3046            write_file(&path, &fm, &body).unwrap();
3047            let after = std::fs::read_to_string(&path).unwrap();
3048            assert!(
3049                after.contains(big),
3050                "30-digit integer in flow sequence corrupted by format:\n{after}"
3051            );
3052            assert!(
3053                !after.to_lowercase().contains("1.234"),
3054                "integer was truncated to a float:\n{after}"
3055            );
3056            assert_eq!(body, "body\n", "body must be preserved verbatim");
3057        }
3058    }
3059
3060    #[test]
3061    fn regression_oversized_int_in_flow_mapping_round_trips() {
3062        // The single-line flow MAPPING form regressed identically:
3063        // `meta: {ext: 123…}` hard-failed the block. It must now parse and the
3064        // oversized value must survive verbatim.
3065        let dir = tempdir().unwrap();
3066        let path = dir.path().join("m.md");
3067        let big = "123456789012345678901234567890";
3068        let original = format!("---\ntype: note\nsummary: x\nmeta: {{ext: {big}}}\n---\nbody\n");
3069        std::fs::write(&path, &original).unwrap();
3070
3071        for _ in 0..2 {
3072            let (fm, body) = read_file(&path).expect("flow-mapping oversized int must parse");
3073            let meta = fm.extra.get("meta").expect("meta field preserved");
3074            assert!(
3075                matches!(meta, Value::Mapping(_)),
3076                "meta should stay a mapping, got: {meta:?}"
3077            );
3078            write_file(&path, &fm, &body).unwrap();
3079            let after = std::fs::read_to_string(&path).unwrap();
3080            assert!(
3081                after.contains(big),
3082                "oversized integer in flow mapping corrupted by format:\n{after}"
3083            );
3084            assert_eq!(body, "body\n", "body must be preserved verbatim");
3085        }
3086    }
3087
3088    #[test]
3089    fn regression_oversized_int_in_mixed_flow_collection_round_trips() {
3090        // A flow collection mixing an oversized int with an in-range int and a
3091        // string: only the oversized int is quoted; the in-range int stays a
3092        // number, the string stays a string, and the whole thing parses.
3093        let dir = tempdir().unwrap();
3094        let path = dir.path().join("mix.md");
3095        let big = "123456789012345678901234567890";
3096        let original = format!(
3097            "---\ntype: note\nsummary: x\nvals: [{big}, 42, hello, \"world\"]\n---\nbody\n"
3098        );
3099        std::fs::write(&path, &original).unwrap();
3100
3101        let (fm, body) = read_file(&path).expect("mixed flow collection must parse");
3102        let Value::Sequence(seq) = fm.extra.get("vals").expect("vals preserved") else {
3103            panic!("vals should be a sequence");
3104        };
3105        assert_eq!(seq.len(), 4, "all four entries preserved");
3106        // The oversized literal narrows to a string; the in-range int stays a
3107        // number; the bare and quoted strings stay strings.
3108        assert_eq!(seq[0].as_str(), Some(big), "oversized int -> string");
3109        assert_eq!(seq[1].as_i64(), Some(42), "in-range int stays a number");
3110        assert_eq!(seq[2].as_str(), Some("hello"));
3111        assert_eq!(seq[3].as_str(), Some("world"));
3112
3113        write_file(&path, &fm, &body).unwrap();
3114        let after = std::fs::read_to_string(&path).unwrap();
3115        assert!(after.contains(big), "oversized int lost:\n{after}");
3116        assert_eq!(body, "body\n");
3117    }
3118
3119    #[test]
3120    fn regression_multiple_oversized_ints_in_one_flow_line_round_trip() {
3121        // Two oversized literals on the same flow line — and a nested collection —
3122        // must each be quoted in the single left-to-right pass.
3123        let dir = tempdir().unwrap();
3124        let path = dir.path().join("multi.md");
3125        let a = "99999999999999999999"; // 20 digits
3126        let b = "123456789012345678901234567890"; // 30 digits
3127        let original =
3128            format!("---\ntype: note\nsummary: x\nm: {{a: {a}, nested: [{b}, 7]}}\n---\nbody\n");
3129        std::fs::write(&path, &original).unwrap();
3130
3131        let (fm, body) = read_file(&path).expect("multi oversized flow must parse");
3132        write_file(&path, &fm, &body).unwrap();
3133        let after = std::fs::read_to_string(&path).unwrap();
3134        assert!(after.contains(a), "first oversized int lost:\n{after}");
3135        assert!(after.contains(b), "second oversized int lost:\n{after}");
3136        assert_eq!(body, "body\n");
3137    }
3138
3139    #[test]
3140    fn regression_flow_with_only_in_range_and_strings_is_byte_exact() {
3141        // A flow collection with NO oversized int must round-trip byte-for-byte:
3142        // the pre-quoter must not touch in-range ints, strings, or floats. We
3143        // assert on the prepared-YAML stage so an unaffected line is left as the
3144        // borrowed input (no rewrite, no quoting drift).
3145        let yaml = "type: note\nids: [1, 2, 3]\nmeta: {ext: 42, name: bob}\nf: [1.5, 2.5]\n";
3146        let prepared = quote_oversized_integers(yaml);
3147        assert_eq!(
3148            prepared.as_ref(),
3149            yaml,
3150            "in-range flow collections must be left byte-exact"
3151        );
3152        // And it still parses cleanly with the expected numeric types intact.
3153        let fm = Frontmatter::parse(yaml, Path::new("n.md")).unwrap();
3154        let Value::Sequence(ids) = fm.extra.get("ids").unwrap() else {
3155            panic!("ids should be a sequence");
3156        };
3157        assert_eq!(ids[0].as_i64(), Some(1));
3158    }
3159
3160    #[test]
3161    fn quote_oversized_ints_in_flow_skips_quoted_and_digit_strings() {
3162        // A quoted scalar whose contents happen to be a long digit run must NOT
3163        // be re-quoted or otherwise altered — it is already a string. A flow with
3164        // only such strings yields no change (None).
3165        let flow = "[\"123456789012345678901234567890\", '99999999999999999999']";
3166        assert_eq!(
3167            quote_oversized_ints_in_flow(flow),
3168            None,
3169            "already-quoted digit strings must be left untouched"
3170        );
3171        // A bare oversized int alongside a quoted one: only the bare one is quoted.
3172        let flow2 = "[123456789012345678901234567890, \"already\"]";
3173        let out = quote_oversized_ints_in_flow(flow2).expect("bare int should be quoted");
3174        assert_eq!(out, "['123456789012345678901234567890', \"already\"]");
3175    }
3176
3177    // ── Regression: BOM-prefixed files parse like store/index (finding #19) ────
3178
3179    #[test]
3180    fn regression_split_frontmatter_tolerates_leading_utf8_bom() {
3181        // A BOM-prefixed file (EF BB BF + `---\n...`) is walked and indexed by
3182        // `dbmd index` (store/index strip the BOM) but, before the fix, every
3183        // write/edit surface routed through `read_file` hard-failed with
3184        // MissingFrontmatter. `split_frontmatter` must now strip a single leading
3185        // U+FEFF and emit a BOM-free body.
3186        let text = "\u{feff}---\ntype: note\nsummary: x\n---\nbody\n";
3187        let parsed = split_frontmatter(text, Path::new("note.md")).unwrap();
3188        assert_eq!(parsed.frontmatter_yaml, "type: note\nsummary: x\n");
3189        // Body never carries the BOM forward into the canonical writer.
3190        assert_eq!(parsed.body, "body\n");
3191        assert!(!parsed.body.starts_with('\u{feff}'));
3192    }
3193
3194    #[test]
3195    fn regression_read_file_parses_bom_prefixed_file() {
3196        // End-to-end through the same `read_file` path `dbmd fm get/set`,
3197        // `format`, `link`, and `write` use. Before the fix this returned
3198        // Err(MissingFrontmatter) on a file the catalog had already indexed.
3199        let dir = tempdir().unwrap();
3200        let path = dir.path().join("note.md");
3201        std::fs::write(&path, "\u{feff}---\ntype: note\nsummary: x\n---\nbody\n").unwrap();
3202
3203        let (fm, body) = read_file(&path).expect("BOM-prefixed file must parse");
3204        assert_eq!(fm.type_.as_deref(), Some("note"));
3205        assert_eq!(fm.summary.as_deref(), Some("x"));
3206        assert_eq!(body, "body\n");
3207    }
3208
3209    #[test]
3210    fn to_yaml_preserves_unquoted_scalar_wiki_link_round_trip() {
3211        // Regression (PRIMARY): the SPEC-canonical scalar wiki-link is the
3212        // *unquoted* inline `company: [[records/companies/northstar]]`
3213        // (SPEC § Linking, the worked `contact` example). YAML parses it to the
3214        // nested `Seq[Seq[String]]` shape. Before the fix, `to_yaml` re-emitted
3215        // it block-style as
3216        //     company:
3217        //     - - records/companies/northstar
3218        // — the `[[ ]]` brackets GONE — so a no-op re-emit (`dbmd format`, and
3219        // any `fm set` / `link` write) silently destroyed the link.
3220        let yaml = "type: contact\ncompany: [[records/companies/northstar]]";
3221        let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
3222        // Sanity: `parse` now disambiguates the inline-link source form at read
3223        // time (the genuine `Seq[Seq[String]]` of a 2D array no longer gets
3224        // collapsed at emit), so the inline link is stored as the canonical
3225        // scalar `String("[[x]]")`.
3226        assert_eq!(
3227            fm.extra.get("company").and_then(|v| v.as_str()),
3228            Some("[[records/companies/northstar]]")
3229        );
3230
3231        let out = fm.to_yaml();
3232        // The link must survive as a quoted inline scalar — brackets intact, and
3233        // never the bracket-less block sequence `- - records/...`.
3234        assert!(
3235            out.contains("[[records/companies/northstar]]"),
3236            "canonical writer dropped the wiki-link brackets; got:\n{out}"
3237        );
3238        assert!(
3239            !out.contains("- - "),
3240            "canonical writer emitted a nested block sequence (link corrupted); got:\n{out}"
3241        );
3242
3243        // And it round-trips: re-parsing the emitted YAML still surfaces exactly
3244        // one link with the right target (the edge graph/backlinks rely on).
3245        let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
3246        let fields = reparsed.link_fields();
3247        let links: Vec<(&str, &str, Option<&str>)> = fields
3248            .iter()
3249            .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
3250            .collect();
3251        assert_eq!(
3252            links,
3253            vec![("company", "records/companies/northstar", None)]
3254        );
3255
3256        // A second re-emit is a fixed point — no progressive corruption across
3257        // repeated curator-loop writes.
3258        assert_eq!(
3259            reparsed.to_yaml(),
3260            out,
3261            "to_yaml is not idempotent on links"
3262        );
3263    }
3264
3265    #[test]
3266    fn to_yaml_preserves_unquoted_scalar_link_with_display() {
3267        // The `|display` segment must survive the unquoted-inline round-trip too.
3268        let yaml = "type: contact\ncompany: [[records/companies/northstar|Northstar]]";
3269        let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
3270        let out = fm.to_yaml();
3271        assert!(
3272            out.contains("[[records/companies/northstar|Northstar]]"),
3273            "display segment lost on round-trip; got:\n{out}"
3274        );
3275        let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
3276        let f = reparsed.link_fields();
3277        assert_eq!(f.len(), 1);
3278        assert_eq!(f[0].1.target, "records/companies/northstar");
3279        assert_eq!(f[0].1.display.as_deref(), Some("Northstar"));
3280    }
3281
3282    #[test]
3283    fn to_yaml_does_not_mangle_link_list_or_plain_nested_sequence() {
3284        // A genuine quoted block list of links round-trips as a clean string
3285        // list — never collapsed to a scalar — and a plain nested sequence that
3286        // is NOT a wiki-link is left exactly as written (no false conversion).
3287        let yaml = "type: meeting\nattendees:\n  - \"[[records/contacts/elena]]\"\n  - \"[[records/contacts/sarah]]\"\nmatrix:\n  - - 1\n    - 2";
3288        let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
3289        let out = fm.to_yaml();
3290
3291        // Both attendee links survive as quoted strings.
3292        assert!(out.contains("[[records/contacts/elena]]"), "got:\n{out}");
3293        assert!(out.contains("[[records/contacts/sarah]]"), "got:\n{out}");
3294
3295        let reparsed = Frontmatter::parse(&out, Path::new("m.md")).unwrap();
3296        let fields = reparsed.link_fields();
3297        let attendees: Vec<&str> = fields
3298            .iter()
3299            .filter(|(k, _)| k == "attendees")
3300            .map(|(_, l)| l.target.as_str())
3301            .collect();
3302        assert_eq!(
3303            attendees,
3304            vec!["records/contacts/elena", "records/contacts/sarah"]
3305        );
3306        // The non-link nested sequence is preserved verbatim, not touched.
3307        assert_eq!(reparsed.extra.get("matrix"), fm.extra.get("matrix"));
3308    }
3309
3310    // ── read_file / write_file round-trip ────────────────────────────────────
3311
3312    #[test]
3313    fn write_then_read_roundtrips_and_preserves_body_verbatim() {
3314        let dir = tempdir().unwrap();
3315        let path = dir.path().join("sources/emails/x.md");
3316        let body = "# Subject\n\nHello,\n\nSee [[records/contacts/sarah-chen]].\n";
3317        let mut fm = Frontmatter {
3318            type_: Some("email".into()),
3319            summary: Some("renewal note".into()),
3320            created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
3321            ..Default::default()
3322        };
3323        fm.extra
3324            .insert("from".into(), Value::String("elena@northstar.io".into()));
3325
3326        write_file(&path, &fm, body).unwrap();
3327
3328        let (read_fm, read_body) = read_file(&path).unwrap();
3329        assert_eq!(read_body, body, "body must be preserved byte-for-byte");
3330        assert_eq!(read_fm.type_.as_deref(), Some("email"));
3331        assert_eq!(read_fm.summary.as_deref(), Some("renewal note"));
3332        assert_eq!(
3333            read_fm.extra.get("from").and_then(|v| v.as_str()),
3334            Some("elena@northstar.io")
3335        );
3336        // The on-disk file starts with a fence and ends with the verbatim body.
3337        let raw = std::fs::read_to_string(&path).unwrap();
3338        assert!(raw.starts_with("---\n"));
3339        assert!(raw.ends_with(body));
3340    }
3341
3342    #[test]
3343    fn roundtrip_modify_summary_then_write_changes_only_summary() {
3344        let dir = tempdir().unwrap();
3345        let path = dir.path().join("records/contacts/sarah.md");
3346        let body = "Long-form operator notes about Sarah.\n";
3347        let fm = Frontmatter {
3348            type_: Some("contact".into()),
3349            summary: Some("old summary".into()),
3350            ..Default::default()
3351        };
3352        write_file(&path, &fm, body).unwrap();
3353
3354        // Read → modify summary → write back.
3355        let (mut fm2, body2) = read_file(&path).unwrap();
3356        fm2.summary = Some("new summary".into());
3357        write_file(&path, &fm2, &body2).unwrap();
3358
3359        let (fm3, body3) = read_file(&path).unwrap();
3360        assert_eq!(fm3.summary.as_deref(), Some("new summary"));
3361        assert_eq!(fm3.type_.as_deref(), Some("contact"));
3362        assert_eq!(body3, body, "body unchanged across the round-trip");
3363    }
3364
3365    #[test]
3366    fn roundtrip_preserves_handwritten_unquoted_scalar_wiki_link_on_disk() {
3367        // End-to-end analog of `dbmd format` on the verbatim SPEC worked example:
3368        // a hand-written file carrying the canonical UNQUOTED scalar link
3369        // `company: [[records/companies/northstar]]`, read from disk then written
3370        // back unchanged. Before the fix this no-op re-emit rewrote the on-disk
3371        // value to the bracket-less block sequence `company:\n- - records/...`,
3372        // and every reader (validate/graph/backlinks) then lost the edge.
3373        let dir = tempdir().unwrap();
3374        let path = dir.path().join("records/contacts/sarah-chen.md");
3375        let file = "---\ntype: contact\nid: sarah-chen\nsummary: Director of Ops\ncompany: [[records/companies/northstar]]\n---\n# Sarah Chen\n\nNotes.\n";
3376        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3377        std::fs::write(&path, file).unwrap();
3378
3379        // Read → write back unchanged (the canonical no-op re-emit).
3380        let (fm, body) = read_file(&path).unwrap();
3381        write_file(&path, &fm, &body).unwrap();
3382
3383        // On-disk bytes still carry the bracketed link, never `- - records/...`.
3384        let raw = std::fs::read_to_string(&path).unwrap();
3385        assert!(
3386            raw.contains("[[records/companies/northstar]]"),
3387            "on-disk wiki-link brackets were destroyed; got:\n{raw}"
3388        );
3389        assert!(
3390            !raw.contains("- - "),
3391            "on-disk value became a nested block sequence; got:\n{raw}"
3392        );
3393
3394        // And the edge is still readable after the round-trip.
3395        let (fm2, _) = read_file(&path).unwrap();
3396        let fields = fm2.link_fields();
3397        let links: Vec<(&str, &str)> = fields
3398            .iter()
3399            .map(|(k, l)| (k.as_str(), l.target.as_str()))
3400            .collect();
3401        assert_eq!(links, vec![("company", "records/companies/northstar")]);
3402    }
3403
3404    #[test]
3405    fn write_file_does_not_leave_temp_files_behind() {
3406        let dir = tempdir().unwrap();
3407        let path = dir.path().join("records/x.md");
3408        let fm = Frontmatter {
3409            type_: Some("note".into()),
3410            ..Default::default()
3411        };
3412        write_file(&path, &fm, "body\n").unwrap();
3413        // The directory should contain only the target file, no `.x.md.tmp.*`.
3414        let entries: Vec<String> = std::fs::read_dir(path.parent().unwrap())
3415            .unwrap()
3416            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
3417            .collect();
3418        assert_eq!(entries, vec!["x.md".to_string()]);
3419    }
3420
3421    // ── is_content_file ──────────────────────────────────────────────────────
3422
3423    #[test]
3424    fn is_content_file_recognizes_layers_and_excludes_meta() {
3425        assert!(Frontmatter::is_content_file(Path::new(
3426            "sources/emails/2026-05-22.md"
3427        )));
3428        assert!(Frontmatter::is_content_file(Path::new(
3429            "records/contacts/sarah-chen.md"
3430        )));
3431        // A synthesis profile the agent authored lives under `records/` (the
3432        // old `wiki/` layer is gone, so a `wiki/...` path is NOT content).
3433        assert!(Frontmatter::is_content_file(Path::new(
3434            "records/profiles/sarah-chen.md"
3435        )));
3436        assert!(!Frontmatter::is_content_file(Path::new(
3437            "wiki/people/sarah-chen.md"
3438        )));
3439        // Absolute paths under a layer are still content.
3440        assert!(Frontmatter::is_content_file(Path::new(
3441            "/home/db/records/companies/northstar.md"
3442        )));
3443        // index.md at any level is meta.
3444        assert!(!Frontmatter::is_content_file(Path::new(
3445            "records/contacts/index.md"
3446        )));
3447        assert!(!Frontmatter::is_content_file(Path::new("index.md")));
3448        // Root meta files.
3449        assert!(!Frontmatter::is_content_file(Path::new("DB.md")));
3450        assert!(!Frontmatter::is_content_file(Path::new("log.md")));
3451    }
3452
3453    // ── effective_id ─────────────────────────────────────────────────────────
3454
3455    #[test]
3456    fn effective_id_prefers_explicit_then_derives_from_path() {
3457        let with_id = Frontmatter {
3458            id: Some("explicit-id".into()),
3459            ..Default::default()
3460        };
3461        assert_eq!(
3462            with_id.effective_id(Path::new("records/profiles/sarah-chen.md")),
3463            "explicit-id"
3464        );
3465        let no_id = Frontmatter::default();
3466        assert_eq!(
3467            no_id.effective_id(Path::new("records/profiles/sarah-chen.md")),
3468            "sarah-chen"
3469        );
3470    }
3471
3472    // ── get / set ────────────────────────────────────────────────────────────
3473
3474    #[test]
3475    fn set_routes_universal_and_custom_keys() {
3476        let mut fm = Frontmatter::default();
3477        fm.set("type", "contact").unwrap();
3478        fm.set("summary", "hi").unwrap();
3479        fm.set("company", "[[records/companies/northstar]]")
3480            .unwrap();
3481        assert_eq!(fm.type_.as_deref(), Some("contact"));
3482        assert_eq!(fm.summary.as_deref(), Some("hi"));
3483        // Custom key landed in extra, not a typed slot.
3484        assert_eq!(
3485            fm.extra.get("company").and_then(|v| v.as_str()),
3486            Some("[[records/companies/northstar]]")
3487        );
3488        // get reads from both typed fields and extra.
3489        assert_eq!(
3490            fm.get("type").and_then(|v| v.as_str().map(String::from)),
3491            Some("contact".into())
3492        );
3493        assert_eq!(
3494            fm.get("company").and_then(|v| v.as_str().map(String::from)),
3495            Some("[[records/companies/northstar]]".into())
3496        );
3497        assert!(fm.get("nonexistent").is_none());
3498    }
3499
3500    #[test]
3501    fn set_timestamp_validates_rfc3339() {
3502        let mut fm = Frontmatter::default();
3503        fm.set("created", "2026-05-27T08:00:00-07:00").unwrap();
3504        assert!(fm.created.is_some());
3505        let err = fm.set("updated", "not-a-date").unwrap_err();
3506        assert!(matches!(err, ParseError::BadTimestamp { .. }));
3507    }
3508
3509    // ── extract_wiki_links ───────────────────────────────────────────────────
3510
3511    #[test]
3512    fn extract_wiki_links_flags_full_path_short_form_and_extension() {
3513        let body = "See [[records/contacts/sarah-chen]] and [[sarah-chen]].\nAlso [[records/profiles/sarah-chen.md|Sarah]].\n";
3514        let links = extract_wiki_links(body, Path::new("doc.md"));
3515        assert_eq!(links.len(), 3);
3516
3517        // Full path, no extension, no display.
3518        assert_eq!(links[0].target, "records/contacts/sarah-chen");
3519        assert!(links[0].is_full_path);
3520        assert!(!links[0].has_md_extension);
3521        assert_eq!(links[0].display, None);
3522        assert_eq!(links[0].location.1, 1, "first link on line 1");
3523
3524        // Short form: not a full path.
3525        assert_eq!(links[1].target, "sarah-chen");
3526        assert!(!links[1].is_full_path, "bare target is short-form");
3527
3528        // Full path WITH .md extension and a display override on line 2.
3529        assert_eq!(links[2].target, "records/profiles/sarah-chen.md");
3530        assert!(links[2].is_full_path);
3531        assert!(links[2].has_md_extension);
3532        assert_eq!(links[2].display.as_deref(), Some("Sarah"));
3533        assert_eq!(links[2].location.1, 2);
3534    }
3535
3536    #[test]
3537    fn extract_wiki_links_reports_1_based_column_counting_chars() {
3538        // A multi-byte prefix (é is 2 bytes) must not skew the char column.
3539        let body = "café [[records/x/y]]";
3540        let links = extract_wiki_links(body, Path::new("d.md"));
3541        assert_eq!(links.len(), 1);
3542        // "café " is 5 chars, so the `[[` starts at char column 6 (1-based).
3543        assert_eq!(links[0].location.2, 6);
3544    }
3545
3546    #[test]
3547    fn extract_wiki_links_columns_are_correct_for_multiple_links_on_one_line() {
3548        // Locks the single-pass column cursor (the O(n²)→O(n) fix): each `[[`
3549        // reports the right 1-based CHAR column even with multi-byte prefixes and
3550        // several links per line.
3551        let body = "café [[a]] · [[records/x/y]] end";
3552        let links = extract_wiki_links(body, Path::new("d.md"));
3553        assert_eq!(links.len(), 2);
3554        // "café " = 5 chars → first `[[` at col 6.
3555        assert_eq!(links[0].location.2, 6);
3556        // "café [[a]] · " = 5 + 5 (`[[a]]`) + 3 (` · `, `·` is 1 char) = 13 chars
3557        // → second `[[` at col 14.
3558        assert_eq!(links[1].location.2, 14);
3559    }
3560
3561    #[test]
3562    fn extract_wiki_links_ignores_a_lone_path_without_brackets() {
3563        let links = extract_wiki_links(
3564            "records/contacts/sarah-chen is not a link",
3565            Path::new("d.md"),
3566        );
3567        assert!(links.is_empty());
3568    }
3569
3570    // ── extract_markdown_links ───────────────────────────────────────────────
3571
3572    #[test]
3573    fn extract_markdown_links_captures_external_and_not_wiki_links() {
3574        let body =
3575            "See [the thread](https://x.com/a) and [[records/contacts/sarah-chen]] internally.\n";
3576        let md = extract_markdown_links(body, Path::new("d.md"));
3577        assert_eq!(
3578            md.len(),
3579            1,
3580            "wiki-link must not be captured as a markdown link"
3581        );
3582        assert_eq!(md[0].text, "the thread");
3583        assert_eq!(md[0].url, "https://x.com/a");
3584        assert_eq!(md[0].location.1, 1);
3585
3586        // And the wiki-link extractor must not pick up the markdown link.
3587        let wl = extract_wiki_links(body, Path::new("d.md"));
3588        assert_eq!(wl.len(), 1);
3589        assert_eq!(wl[0].target, "records/contacts/sarah-chen");
3590    }
3591
3592    // ── link_fields ──────────────────────────────────────────────────────────
3593
3594    #[test]
3595    fn link_fields_extracts_scalar_list_and_summary_links() {
3596        // The canonical list form quotes each item so YAML parses it as clean
3597        // strings; a scalar field may be quoted OR written in the canonical
3598        // unquoted inline form `company: [[x]]` (SPEC § Linking).
3599        let yaml = "type: meeting\nsummary: with [[records/contacts/elena]]\ncompany: \"[[records/companies/northstar]]\"\nattendees:\n  - \"[[records/contacts/elena]]\"\n  - \"[[records/contacts/sarah]]\"\nnotes: just plain text";
3600        let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
3601        // Sanity: company really did parse as a scalar string here.
3602        assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_some());
3603        let fields = fm.link_fields();
3604
3605        // company (scalar) once, with the right target.
3606        let company: Vec<&str> = fields
3607            .iter()
3608            .filter(|(k, _)| k == "company")
3609            .map(|(_, l)| l.target.as_str())
3610            .collect();
3611        assert_eq!(company, vec!["records/companies/northstar"]);
3612        // attendees (block list) twice.
3613        let attendees: Vec<&str> = fields
3614            .iter()
3615            .filter(|(k, _)| k == "attendees")
3616            .map(|(_, l)| l.target.as_str())
3617            .collect();
3618        assert_eq!(
3619            attendees,
3620            vec!["records/contacts/elena", "records/contacts/sarah"]
3621        );
3622        // summary link surfaced.
3623        assert_eq!(fields.iter().filter(|(k, _)| k == "summary").count(), 1);
3624        // Plain-text field is not a link.
3625        assert_eq!(fields.iter().filter(|(k, _)| k == "notes").count(), 0);
3626    }
3627
3628    #[test]
3629    fn link_fields_surfaces_canonical_unquoted_scalar_link() {
3630        // Regression: the canonical scalar wiki-link form is the *unquoted*
3631        // inline `company: [[records/companies/northstar]]` (SPEC § Linking).
3632        // YAML parses `[[x]]` as a flow-list-in-a-list (`Seq[Seq[String]]`), so
3633        // a naive `as_str()`-only walk drops it. link_fields() must still
3634        // surface exactly one link with the correct target.
3635        let yaml = "type: meeting\ncompany: [[records/companies/northstar]]";
3636        let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
3637        // Sanity: `parse` disambiguates the inline-link source form at read time,
3638        // storing it as the canonical scalar `String("[[x]]")` (so a genuine
3639        // `Seq[Seq[String]]` 2D array is never collapsed/retyped). link_fields()
3640        // reads either spelling back as the same link.
3641        assert_eq!(
3642            fm.extra.get("company").and_then(|v| v.as_str()),
3643            Some("[[records/companies/northstar]]")
3644        );
3645
3646        let fields = fm.link_fields();
3647        let links: Vec<(&str, &str, Option<&str>)> = fields
3648            .iter()
3649            .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
3650            .collect();
3651        assert_eq!(
3652            links,
3653            vec![("company", "records/companies/northstar", None)]
3654        );
3655
3656        // The `|display` segment survives the unquoted inline form too.
3657        let fm2 = Frontmatter::parse(
3658            "type: meeting\ncompany: [[records/companies/northstar|Northstar]]",
3659            Path::new("m.md"),
3660        )
3661        .unwrap();
3662        let f2 = fm2.link_fields();
3663        assert_eq!(f2.len(), 1);
3664        assert_eq!(f2[0].0, "company");
3665        assert_eq!(f2[0].1.target, "records/companies/northstar");
3666        assert_eq!(f2[0].1.display.as_deref(), Some("Northstar"));
3667    }
3668
3669    #[test]
3670    fn link_fields_ignores_plain_one_item_flow_list() {
3671        // A plain one-item flow list `aliases: [foo]` parses to `Seq[String]`
3672        // — one nesting level shallower than an unquoted `[[foo]]` — and must
3673        // NOT be mistaken for a wiki-link.
3674        let yaml = "type: contact\naliases: [foo]";
3675        let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
3676        assert_eq!(fm.link_fields(), Vec::new());
3677    }
3678
3679    // ── detect_flow_form_link_lists ──────────────────────────────────────────
3680
3681    #[test]
3682    fn detect_flow_form_flags_list_misencodings_not_scalars() {
3683        // The flow-form list mis-encoding (triple-nested) IS flagged; a scalar
3684        // inline wiki-link (double-nested) is NOT.
3685        let bad = "attendees: [[[records/x]], [[records/y]]]\nscalar_inline: [[records/z]]";
3686        let flagged = detect_flow_form_link_lists(bad);
3687        assert_eq!(flagged, vec!["attendees".to_string()]);
3688
3689        // An UNquoted block list is also a mis-encoding (parses triple-nested).
3690        let unquoted_block = "attendees:\n  - [[records/x]]\n  - [[records/y]]";
3691        assert_eq!(
3692            detect_flow_form_link_lists(unquoted_block),
3693            vec!["attendees".to_string()]
3694        );
3695
3696        // The canonical QUOTED block form parses to clean strings — NOT flagged.
3697        let good = "attendees:\n  - \"[[records/x]]\"\n  - \"[[records/y]]\"";
3698        assert!(detect_flow_form_link_lists(good).is_empty());
3699
3700        // A plain scalar list of strings is not flagged.
3701        let plain = "tags: [a, b, c]";
3702        assert!(detect_flow_form_link_lists(plain).is_empty());
3703    }
3704
3705    // ── extract_sections ─────────────────────────────────────────────────────
3706
3707    #[test]
3708    fn extract_sections_levels_nesting_and_boundaries() {
3709        let body = "intro text\n## First\nalpha\n### Sub\nbeta\n## Second\ngamma\n";
3710        let secs = extract_sections(body);
3711        let headings: Vec<(&str, u8)> =
3712            secs.iter().map(|s| (s.heading.as_str(), s.level)).collect();
3713        assert_eq!(headings, vec![("First", 2), ("Sub", 3), ("Second", 2)]);
3714
3715        // "First" (H2) body extends through its H3 child, stopping at "Second".
3716        let first = &secs[0];
3717        assert!(first.body.contains("alpha"));
3718        assert!(first.body.contains("### Sub"));
3719        assert!(first.body.contains("beta"));
3720        assert!(!first.body.contains("Second"));
3721
3722        // "Sub" (H3) stops at the next equal-or-shallower heading ("Second").
3723        let sub = &secs[1];
3724        assert!(sub.body.contains("beta"));
3725        assert!(!sub.body.contains("gamma"));
3726
3727        // 1-based line numbers within the body.
3728        assert_eq!(first.line, 2);
3729        assert_eq!(secs[2].line, 6);
3730    }
3731
3732    #[test]
3733    fn extract_sections_ignores_headings_in_fenced_code() {
3734        let body = "## Real\n```\n## Fake heading in code\n```\nafter\n";
3735        let secs = extract_sections(body);
3736        assert_eq!(secs.len(), 1);
3737        assert_eq!(secs[0].heading, "Real");
3738        // The fenced "## Fake" is part of Real's body, not its own section.
3739        assert!(secs[0].body.contains("## Fake heading in code"));
3740    }
3741
3742    // ── parse_field_spec ─────────────────────────────────────────────────────
3743
3744    #[test]
3745    fn parse_field_spec_required_and_shape() {
3746        let f = parse_field_spec("- email (required, email)");
3747        assert_eq!(f.name, "email");
3748        assert!(f.required);
3749        assert_eq!(f.shape, Some(Shape::Email));
3750        assert!(f.unknown_modifiers.is_empty());
3751    }
3752
3753    #[test]
3754    fn parse_field_spec_link_prefix_strips_trailing_slash() {
3755        let f = parse_field_spec("- company (required, link to records/companies/)");
3756        assert!(f.required);
3757        assert_eq!(f.link_prefix, Some(PathBuf::from("records/companies")));
3758        assert_eq!(f.shape, None);
3759    }
3760
3761    #[test]
3762    fn parse_field_spec_default_preserves_case_and_value() {
3763        let f = parse_field_spec("- currency (default USD)");
3764        assert_eq!(f.name, "currency");
3765        assert_eq!(f.default, Some(Value::String("USD".into())));
3766    }
3767
3768    #[test]
3769    fn parse_field_spec_enum_captures_comma_list_as_last_modifier() {
3770        let f = parse_field_spec("- status (required, enum: open, closed, pending)");
3771        assert!(f.required);
3772        assert_eq!(
3773            f.enum_values,
3774            Some(vec![
3775                "open".to_string(),
3776                "closed".to_string(),
3777                "pending".to_string()
3778            ])
3779        );
3780    }
3781
3782    #[test]
3783    fn parse_field_spec_bare_enum_keyword_is_not_itself_a_value() {
3784        // `enum` with no colon: the values are the remaining tokens; the keyword
3785        // itself must NOT leak in as an allowed value.
3786        let f = parse_field_spec("- status (required, enum, open, closed)");
3787        assert!(f.required);
3788        assert_eq!(
3789            f.enum_values,
3790            Some(vec!["open".to_string(), "closed".to_string()])
3791        );
3792    }
3793
3794    #[test]
3795    fn parse_field_spec_unknown_modifier_is_captured_not_errored() {
3796        let f = parse_field_spec("- weird (required, frobnicate, string)");
3797        assert!(f.required);
3798        assert_eq!(f.shape, Some(Shape::String));
3799        assert_eq!(f.unknown_modifiers, vec!["frobnicate".to_string()]);
3800    }
3801
3802    #[test]
3803    fn parse_field_spec_no_parens_is_freeform_optional() {
3804        let f = parse_field_spec("- nickname");
3805        assert_eq!(f.name, "nickname");
3806        assert!(!f.required);
3807        assert_eq!(f.shape, None);
3808        assert!(f.link_prefix.is_none());
3809        assert!(f.enum_values.is_none());
3810        assert!(f.unknown_modifiers.is_empty());
3811    }
3812
3813    // ── parse_schema_bullet (directives) ─────────────────────────────────────
3814
3815    #[test]
3816    fn schema_bullet_unique_single_field() {
3817        match parse_schema_bullet("- unique: email") {
3818            SchemaBullet::Unique(fields) => assert_eq!(fields, vec!["email".to_string()]),
3819            other => panic!("expected Unique, got {other:?}"),
3820        }
3821    }
3822
3823    #[test]
3824    fn schema_bullet_unique_compound_trims_and_splits() {
3825        match parse_schema_bullet("- unique: date, amount , vendor") {
3826            SchemaBullet::Unique(fields) => assert_eq!(
3827                fields,
3828                vec![
3829                    "date".to_string(),
3830                    "amount".to_string(),
3831                    "vendor".to_string()
3832                ]
3833            ),
3834            other => panic!("expected Unique, got {other:?}"),
3835        }
3836    }
3837
3838    #[test]
3839    fn schema_bullet_summary_template_keeps_braces_and_inner_colons() {
3840        match parse_schema_bullet("- summary_template: {role} at {company} (x: y)") {
3841            SchemaBullet::SummaryTemplate(t) => assert_eq!(t, "{role} at {company} (x: y)"),
3842            other => panic!("expected SummaryTemplate, got {other:?}"),
3843        }
3844    }
3845
3846    #[test]
3847    fn schema_bullet_field_with_enum_modifier_is_not_a_directive() {
3848        // A field whose modifiers contain a colon (`enum:`) parses as a field, not
3849        // a directive — its head has a `(` before any `:`.
3850        match parse_schema_bullet("- status (enum: open, closed)") {
3851            SchemaBullet::Field(f) => {
3852                assert_eq!(f.name, "status");
3853                assert_eq!(
3854                    f.enum_values,
3855                    Some(vec!["open".to_string(), "closed".to_string()])
3856                );
3857            }
3858            other => panic!("expected Field, got {other:?}"),
3859        }
3860    }
3861
3862    #[test]
3863    fn parse_db_md_schema_captures_unique_and_summary_template() {
3864        let db = "---\ntype: db-md\nscope: x\nowner: y\n---\n\n## Schemas\n\n### contact\n- email (required, email)\n- unique: email\n- summary_template: {role} at {company}\n";
3865        let config = parse_db_md(db, Path::new("DB.md")).unwrap();
3866        let s = config.schemas.get("contact").expect("contact schema");
3867        assert_eq!(s.fields.len(), 1, "directives are not parsed as fields");
3868        assert_eq!(s.unique_keys, vec![vec!["email".to_string()]]);
3869        assert_eq!(s.summary_template.as_deref(), Some("{role} at {company}"));
3870    }
3871
3872    #[test]
3873    fn schema_bullet_shard_directive_parses_values() {
3874        assert!(matches!(
3875            parse_schema_bullet("- shard: by-date"),
3876            SchemaBullet::Shard(Some(true))
3877        ));
3878        assert!(matches!(
3879            parse_schema_bullet("- shard: flat"),
3880            SchemaBullet::Shard(Some(false))
3881        ));
3882        // An unrecognized value is ignored (None), like an unknown modifier.
3883        assert!(matches!(
3884            parse_schema_bullet("- shard: weekly"),
3885            SchemaBullet::Shard(None)
3886        ));
3887        // A field whose name has a `(` before any `:` is still a field — the same
3888        // guard that keeps `- status (enum: a, b)` a field, not a directive.
3889        assert!(matches!(
3890            parse_schema_bullet("- shardiness (string)"),
3891            SchemaBullet::Field(_)
3892        ));
3893    }
3894
3895    #[test]
3896    fn parse_db_md_schema_captures_shard_directive() {
3897        let db = "---\ntype: db-md\nscope: x\nowner: y\n---\n\n## Schemas\n\n### shipment\n- carrier (string)\n- shard: by-date\n\n### contact\n- shard: flat\n";
3898        let config = parse_db_md(db, Path::new("DB.md")).unwrap();
3899        let shipment = config.schemas.get("shipment").expect("shipment schema");
3900        assert_eq!(shipment.shard, Some(true));
3901        assert_eq!(
3902            shipment.fields.len(),
3903            1,
3904            "`shard:` is a directive, not a field"
3905        );
3906        assert_eq!(config.schemas.get("contact").unwrap().shard, Some(false));
3907    }
3908
3909    // ── parse_db_md ──────────────────────────────────────────────────────────
3910
3911    const CANONICAL_DB_MD: &str = "---\ntype: db-md\nscope: company\nowner: Sarah Chen\n---\n\n# Acme operations knowledge base\n\nCompany-scale institutional memory for Acme.\n\n## Agent instructions\n\nPrioritize creating `contact` records from new-sender emails. Use British English.\n\n## Policies\n\n### Frozen pages\n- `records/decisions/2026-q1-strategy.md` — finalized, do not modify.\n- `records/synthesis/2026-annual-plan.md` — signed-off plan.\n\n### Ignored types\n- `test`, `temp` — read but never synthesize.\n\n## Schemas\n\n### contact\n- name (required)\n- email (required, email)\n- company (required, link to records/companies/)\n- role (string)\n\n### expense\n- date (required, date)\n- amount (required)\n- currency (default USD)\n";
3912
3913    #[test]
3914    fn parse_db_md_extracts_all_canonical_sections() {
3915        let config = parse_db_md(CANONICAL_DB_MD, Path::new("DB.md")).unwrap();
3916
3917        // Agent instructions: free-form prose, heading line stripped.
3918        let ai = config
3919            .agent_instructions
3920            .expect("agent instructions present");
3921        assert!(ai.starts_with("Prioritize creating"));
3922        assert!(!ai.contains("## Agent instructions"));
3923
3924        // Frozen pages: paths extracted from backticked bullets, comments dropped.
3925        assert_eq!(
3926            config.frozen_pages,
3927            vec![
3928                PathBuf::from("records/decisions/2026-q1-strategy.md"),
3929                PathBuf::from("records/synthesis/2026-annual-plan.md"),
3930            ]
3931        );
3932
3933        // Ignored types: comma list, backticks/comment stripped.
3934        assert_eq!(
3935            config.ignored_types,
3936            vec!["test".to_string(), "temp".to_string()]
3937        );
3938
3939        // Schemas: two types, each with its fields in source order.
3940        assert_eq!(config.schemas.len(), 2);
3941        let contact = config.schemas.get("contact").expect("contact schema");
3942        let names: Vec<&str> = contact.fields.iter().map(|f| f.name.as_str()).collect();
3943        assert_eq!(names, vec!["name", "email", "company", "role"]);
3944        assert!(contact.fields[0].required); // name
3945        assert_eq!(contact.fields[1].shape, Some(Shape::Email)); // email
3946        assert_eq!(
3947            contact.fields[2].link_prefix,
3948            Some(PathBuf::from("records/companies"))
3949        ); // company
3950
3951        let expense = config.schemas.get("expense").expect("expense schema");
3952        let cur = expense
3953            .fields
3954            .iter()
3955            .find(|f| f.name == "currency")
3956            .unwrap();
3957        assert_eq!(cur.default, Some(Value::String("USD".into())));
3958    }
3959
3960    #[test]
3961    fn parse_db_md_handles_malformed_and_unknown_modifiers() {
3962        // corpus-b shape: a `## Schemas` section with a malformed bullet, an
3963        // unknown modifier, and bullets that appear with NO `### <type>`
3964        // heading (so they belong to no schema and are dropped).
3965        let text = "---\ntype: db-md\n---\n\n## Schemas\n- orphan (required)\n\n### ticket\n- priority (required, mystery, enum: low, high)\n- broken (\n";
3966        let config = parse_db_md(text, Path::new("DB.md")).unwrap();
3967
3968        // The orphan bullet under `## Schemas` with no `### type` heading is not
3969        // captured as a schema.
3970        assert_eq!(config.schemas.len(), 1);
3971        let ticket = config.schemas.get("ticket").expect("ticket schema");
3972        assert_eq!(ticket.fields.len(), 2);
3973
3974        let priority = &ticket.fields[0];
3975        assert!(priority.required);
3976        assert_eq!(priority.unknown_modifiers, vec!["mystery".to_string()]);
3977        assert_eq!(
3978            priority.enum_values,
3979            Some(vec!["low".to_string(), "high".to_string()])
3980        );
3981
3982        // A bullet with an unclosed paren still yields a usable name.
3983        let broken = &ticket.fields[1];
3984        assert_eq!(broken.name, "broken");
3985    }
3986
3987    #[test]
3988    fn parse_db_md_missing_frontmatter_errors() {
3989        let text = "# No frontmatter\n\n## Agent instructions\nhi\n";
3990        let err = parse_db_md(text, Path::new("DB.md")).unwrap_err();
3991        assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
3992    }
3993
3994    #[test]
3995    fn parse_db_md_absent_sections_default_empty() {
3996        let text = "---\ntype: db-md\n---\n\n# Title only\n";
3997        let config = parse_db_md(text, Path::new("DB.md")).unwrap();
3998        assert_eq!(config, Config::default());
3999    }
4000
4001    // ── fm set / --fm list-valued link fields (meeting.attendees & friends) ──
4002
4003    /// `Frontmatter::set` is the value path every write surface (`fm set`,
4004    /// `write --fm`) funnels through. A list-of-wiki-links value (the SPEC's
4005    /// `meeting.attendees` shape) must serialize as a YAML **block sequence** of
4006    /// quoted links — readable back by [`links_in_field_value`] and accepted by
4007    /// `dbmd validate` — never the flow-form scalar string that trips
4008    /// `WIKI_LINK_FLOW_FORM_LIST`. Both the unquoted (`[[[a]], [[b]]]`) and
4009    /// quoted (`["[[a]]", "[[b]]"]`) spellings an agent types must normalize.
4010    #[test]
4011    fn set_list_of_wiki_links_becomes_block_sequence_both_spellings() {
4012        for value in [
4013            "[[[records/contacts/a]], [[records/contacts/b]]]",
4014            r#"["[[records/contacts/a]]", "[[records/contacts/b]]"]"#,
4015        ] {
4016            let mut fm = Frontmatter::default();
4017            fm.set("attendees", value).unwrap();
4018
4019            // Stored as a 2-element sequence of clean quoted links.
4020            let stored = fm.extra.get("attendees").expect("attendees set");
4021            let Value::Sequence(items) = stored else {
4022                panic!("attendees must be a Sequence, got {stored:?} for input {value}");
4023            };
4024            assert_eq!(items.len(), 2, "input {value}");
4025            assert_eq!(items[0], Value::String("[[records/contacts/a]]".into()));
4026            assert_eq!(items[1], Value::String("[[records/contacts/b]]".into()));
4027
4028            // The edge enumerator reads exactly the two links back (no stray
4029            // bracket targets, the flow-form-string symptom).
4030            let links: Vec<_> = links_in_field_value(stored)
4031                .into_iter()
4032                .map(|l| l.target)
4033                .collect();
4034            assert_eq!(
4035                links,
4036                vec!["records/contacts/a", "records/contacts/b"],
4037                "input {value}"
4038            );
4039
4040            // And the canonical writer renders it block-style, not as a scalar.
4041            let yaml = fm.to_yaml();
4042            assert!(
4043                yaml.contains("attendees:\n"),
4044                "expected block list in:\n{yaml}"
4045            );
4046            assert!(
4047                !yaml.contains("attendees: '[["),
4048                "must not be a flow-form scalar string in:\n{yaml}"
4049            );
4050        }
4051    }
4052
4053    /// A *single* inline wiki-link stays a scalar string (renders inline
4054    /// `field: [[x]]`), and a single link must never be widened to a one-item
4055    /// list — preserving the common `contact.company` / `expense.vendor` shape.
4056    #[test]
4057    fn set_single_inline_wiki_link_stays_scalar() {
4058        let mut fm = Frontmatter::default();
4059        fm.set("company", "[[records/companies/tideform]]").unwrap();
4060        assert_eq!(
4061            fm.extra.get("company"),
4062            Some(&Value::String("[[records/companies/tideform]]".into())),
4063        );
4064        // Still recognized as one link.
4065        let links: Vec<_> = links_in_field_value(fm.extra.get("company").unwrap())
4066            .into_iter()
4067            .map(|l| l.target)
4068            .collect();
4069        assert_eq!(links, vec!["records/companies/tideform"]);
4070    }
4071
4072    /// Plain text and a non-link flow list are left as verbatim scalar strings —
4073    /// the list normalization only triggers when every item is a clean wiki-link.
4074    #[test]
4075    fn set_non_link_values_stay_scalar_strings() {
4076        let mut fm = Frontmatter::default();
4077        fm.set("location", "Video call (remote)").unwrap();
4078        assert_eq!(
4079            fm.extra.get("location"),
4080            Some(&Value::String("Video call (remote)".into())),
4081        );
4082
4083        // A flow list whose items are NOT wiki-links must not be reinterpreted as
4084        // a link sequence; it stays the scalar string the agent passed.
4085        fm.set("note", "[draft, wip]").unwrap();
4086        assert_eq!(
4087            fm.extra.get("note"),
4088            Some(&Value::String("[draft, wip]".into()))
4089        );
4090    }
4091
4092    // ── Regression: non-string YAML keys round-trip (no Rust Debug corruption) ─
4093
4094    #[test]
4095    fn regression_non_string_yaml_keys_keep_their_text_on_round_trip() {
4096        // A numeric/bool/null/float frontmatter key is valid YAML and must NOT be
4097        // rewritten to its Rust `Debug` form (`Number(2026)`, `Bool(true)`,
4098        // `'Null'`). After the fix the key text survives (the key narrows to a
4099        // string-typed key, but the operator's data is no longer corrupted).
4100        let yaml = "type: note\n2026: planning notes\ntrue: yes-key\n3.14: f\n";
4101        let fm = Frontmatter::parse(yaml, Path::new("x.md")).unwrap();
4102        // Keys are stored as their scalar text, not the Debug string.
4103        assert!(fm.extra.contains_key("2026"), "numeric key text lost");
4104        assert!(fm.extra.contains_key("true"), "bool key text lost");
4105        assert!(fm.extra.contains_key("3.14"), "float key text lost");
4106        assert!(!fm.extra.keys().any(|k| k.starts_with("Number(")));
4107        assert!(!fm.extra.keys().any(|k| k.starts_with("Bool(")));
4108
4109        // And a re-emit never produces the Debug forms on disk.
4110        let out = fm.to_yaml();
4111        assert!(!out.contains("Number("), "Debug-form key emitted:\n{out}");
4112        assert!(!out.contains("Bool("), "Debug-form key emitted:\n{out}");
4113        // The key text is still present (quoted, since it now reads as a string).
4114        assert!(out.contains("2026"), "numeric key dropped:\n{out}");
4115        assert!(out.contains("planning notes"), "value dropped:\n{out}");
4116    }
4117
4118    // ── Regression: universal-key sequence/mapping values are preserved (#2) ───
4119
4120    #[test]
4121    fn regression_universal_key_non_scalar_value_is_preserved_not_deleted() {
4122        // A universal key carrying a sequence/mapping (`status: [active, draft]`)
4123        // is not a valid scalar for that field. Before the fix, the matched arm
4124        // consumed-and-dropped it (scalar_string -> None) and `to_yaml` then
4125        // omitted the field — `dbmd format` silently DELETED it. It must now pass
4126        // through `extra` and re-emit verbatim.
4127        let yaml = "type: note\nstatus:\n  - active\n  - draft\nsummary:\n  a: 1\n  b: 2\n";
4128        let fm = Frontmatter::parse(yaml, Path::new("x.md")).unwrap();
4129        // The typed accessors stay None (no valid scalar), but the data lives in
4130        // extra so nothing is lost.
4131        assert!(fm.status.is_none());
4132        assert!(fm.summary.is_none());
4133        assert!(fm.extra.contains_key("status"), "status value destroyed");
4134        assert!(fm.extra.contains_key("summary"), "summary value destroyed");
4135
4136        // A re-emit keeps both fields' data on disk.
4137        let out = fm.to_yaml();
4138        assert!(out.contains("status"), "status deleted on re-emit:\n{out}");
4139        assert!(out.contains("active"), "status items deleted:\n{out}");
4140        assert!(
4141            out.contains("summary"),
4142            "summary deleted on re-emit:\n{out}"
4143        );
4144
4145        // Round-trips as a fixed point — repeated curator-loop writes don't lose
4146        // the data.
4147        let reparsed = Frontmatter::parse(&out, Path::new("x.md")).unwrap();
4148        assert!(reparsed.extra.contains_key("status"));
4149        assert!(reparsed.extra.contains_key("summary"));
4150    }
4151
4152    // ── Regression: non-scalar tags items don't erase the tags field (#5) ──────
4153
4154    #[test]
4155    fn regression_non_scalar_tags_value_is_preserved_not_erased() {
4156        // `tags: [[vip]]` (an authoring slip — wiki-link brackets around a tag)
4157        // parses to a nested sequence; before the fix `parse_tags` filtered the
4158        // non-scalar item out and `to_yaml` then omitted the now-empty tags vec,
4159        // silently DELETING the tags line. It must now survive the re-emit (the
4160        // key data is preserved; the field is never dropped).
4161        let yaml = "type: note\ntags: [[vip]]\n";
4162        let fm = Frontmatter::parse(yaml, Path::new("x.md")).unwrap();
4163        // The typed tags vec is empty (no clean scalar list), but the raw value
4164        // is preserved in extra so nothing is destroyed.
4165        assert!(fm.tags.is_empty());
4166        assert!(fm.extra.contains_key("tags"), "tags value destroyed");
4167
4168        let out = fm.to_yaml();
4169        assert!(out.contains("tags"), "tags deleted on re-emit:\n{out}");
4170        // The `vip` text survives on disk in some form (never erased).
4171        assert!(out.contains("vip"), "tag content erased:\n{out}");
4172
4173        // A clean tag list still parses to the typed vec (not regressed).
4174        let clean =
4175            Frontmatter::parse("type: note\ntags: [vip, renewal]\n", Path::new("x.md")).unwrap();
4176        assert_eq!(clean.tags, vec!["vip".to_string(), "renewal".to_string()]);
4177        assert!(!clean.extra.contains_key("tags"));
4178    }
4179
4180    // ── Regression: plain nested string lists are NOT fabricated into links (#3) ─
4181
4182    #[test]
4183    fn regression_plain_nested_string_list_is_not_turned_into_wiki_links() {
4184        // `groups: [[alpha], [beta]]` is the data [["alpha"],["beta"]] — an
4185        // unknown nested string list that must pass through verbatim. Before the
4186        // fix, canonicalize_extra_value fabricated `- '[[alpha]]'` / `- '[[beta]]'`
4187        // (short-form links the tool then flagged), changing the field's type.
4188        let yaml = "type: note\ngroups: [[alpha], [beta]]\n";
4189        let fm = Frontmatter::parse(yaml, Path::new("x.md")).unwrap();
4190        let before = fm.extra.get("groups").cloned();
4191
4192        let out = fm.to_yaml();
4193        // No fabricated wiki-link brackets in the emitted YAML.
4194        assert!(!out.contains("[[alpha]]"), "fabricated a wiki-link:\n{out}");
4195        assert!(!out.contains("[[beta]]"), "fabricated a wiki-link:\n{out}");
4196
4197        // The value is unchanged across the canonical re-emit.
4198        let reparsed = Frontmatter::parse(&out, Path::new("x.md")).unwrap();
4199        assert_eq!(
4200            reparsed.extra.get("groups"),
4201            before.as_ref(),
4202            "nested string list mutated by canonicalize_extra_value"
4203        );
4204        // And it surfaces no links.
4205        assert!(reparsed.link_fields().is_empty());
4206    }
4207
4208    #[test]
4209    fn regression_genuine_nested_array_is_not_retyped_to_scalar_string() {
4210        // BUG: `dbmd format` silently retyped a genuine 2D array
4211        //     matrix:
4212        //     - - cell
4213        // (data `[["cell"]]`) into the scalar string `matrix: '[[cell]]'`. The
4214        // root cause is the irreducible YAML ambiguity: serde parses BOTH the
4215        // inline scalar wiki-link `field: [[x]]` AND the block nested-seq
4216        // `field:`\n`- - x` to the identical `Seq[Seq[String]]`. The old
4217        // `canonicalize_extra_value` collapsed every one-element `Seq[Seq[String]]`
4218        // to a string, destroying the array. The fix resolves the inline-link
4219        // case from the SOURCE text at parse time and leaves a genuine block
4220        // array verbatim.
4221        let yaml = "type: note\nsummary: nested\nmatrix:\n- - cell\n";
4222        let fm = Frontmatter::parse(yaml, Path::new("nested.md")).unwrap();
4223
4224        // The block source form stays a nested sequence, NOT a string — the
4225        // inline-link disambiguation only fires for source written `key: [[x]]`.
4226        let stored = fm.extra.get("matrix").expect("matrix preserved");
4227        assert!(
4228            matches!(stored, Value::Sequence(items)
4229                if items.len() == 1 && matches!(&items[0], Value::Sequence(_))),
4230            "genuine 2D array was retyped at parse time; got {stored:?}"
4231        );
4232
4233        let out = fm.to_yaml();
4234        // Emit must keep the array (a block nested sequence), never the bogus
4235        // scalar string `'[[cell]]'`.
4236        assert!(
4237            !out.contains("'[[cell]]'") && !out.contains("[[cell]]"),
4238            "genuine nested array retyped to a scalar wiki-link string; got:\n{out}"
4239        );
4240        assert!(
4241            out.contains("- - cell"),
4242            "nested array lost its 2D shape on emit; got:\n{out}"
4243        );
4244
4245        // Full round-trip: re-parsing the emitted YAML yields the identical value
4246        // — the file's bytes are preserved, which is what BUG 2 was about. (The
4247        // read-side `link_fields` still treats a one-element `Seq[Seq[String]]` as
4248        // the inline-link shape it is indistinguishable from on disk; that is the
4249        // same irreducible ambiguity and is out of scope here — the fix's job is
4250        // that `format` no longer silently RETYPES the array to a string.)
4251        let reparsed = Frontmatter::parse(&out, Path::new("nested.md")).unwrap();
4252        assert_eq!(
4253            reparsed.extra.get("matrix"),
4254            fm.extra.get("matrix"),
4255            "nested array did not round-trip through format"
4256        );
4257        // The stored value is still a sequence after round-trip (never a string).
4258        assert!(
4259            matches!(reparsed.extra.get("matrix"), Some(Value::Sequence(_))),
4260            "nested array became a non-sequence after round-trip"
4261        );
4262    }
4263
4264    #[test]
4265    fn inline_scalar_wiki_link_still_round_trips_after_nested_array_fix() {
4266        // The companion guarantee to the test above: the SPEC-canonical inline
4267        // scalar wiki-link `field: [[x]]` (SPEC.md:383) must still format to a
4268        // canonical inline `[[x]]` that round-trips and surfaces as one link —
4269        // the nested-array fix must not regress it.
4270        let yaml = "type: contact\ncompany: [[records/companies/northstar]]\n";
4271        let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
4272        // Disambiguated at parse time to the canonical scalar string.
4273        assert_eq!(
4274            fm.extra.get("company").and_then(|v| v.as_str()),
4275            Some("[[records/companies/northstar]]")
4276        );
4277
4278        let out = fm.to_yaml();
4279        assert!(
4280            out.contains("[[records/companies/northstar]]") && !out.contains("- - "),
4281            "inline wiki-link not canonical after the nested-array fix; got:\n{out}"
4282        );
4283
4284        let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
4285        let fields = reparsed.link_fields();
4286        let links: Vec<(&str, &str)> = fields
4287            .iter()
4288            .map(|(k, l)| (k.as_str(), l.target.as_str()))
4289            .collect();
4290        assert_eq!(links, vec![("company", "records/companies/northstar")]);
4291        // Idempotent across repeated curator-loop writes.
4292        assert_eq!(
4293            reparsed.to_yaml(),
4294            out,
4295            "inline link is not a format fixed point"
4296        );
4297    }
4298
4299    // ── Regression: fence-line trailing whitespace is tolerated (#4) ───────────
4300
4301    #[test]
4302    fn regression_split_frontmatter_tolerates_trailing_whitespace_on_fences() {
4303        // A fence written `--- ` (trailing space — invisible in editors) is
4304        // indexed/validated clean by index.rs/validate.rs (both use `trim_end()`)
4305        // but, before the fix, hard-failed every read/edit surface routed through
4306        // `split_frontmatter`. All three must now agree.
4307        let text = "--- \ntype: note\nsummary: x\n---\t\nbody\n";
4308        let parsed = split_frontmatter(text, Path::new("f.md")).unwrap();
4309        assert_eq!(parsed.frontmatter_yaml, "type: note\nsummary: x\n");
4310        assert_eq!(parsed.body, "body\n");
4311
4312        // End to end through read_file's parse.
4313        let fm = Frontmatter::parse(&parsed.frontmatter_yaml, Path::new("f.md")).unwrap();
4314        assert_eq!(fm.type_.as_deref(), Some("note"));
4315    }
4316
4317    // ── Regression: CommonMark trailing-'#' heading rule (#6) ──────────────────
4318
4319    #[test]
4320    fn regression_heading_text_keeps_abutting_hash_drops_closing_sequence() {
4321        // `## C#` → `C#` (the `#` abuts content, not a closing sequence).
4322        assert_eq!(heading_text("## C#", 2), "C#");
4323        assert_eq!(heading_text("## F#", 2), "F#");
4324        assert_eq!(heading_text("## issue-123#", 2), "issue-123#");
4325        // A genuine ATX closing sequence (space before the `#` run) is dropped.
4326        assert_eq!(heading_text("## Title ##", 2), "Title");
4327        assert_eq!(heading_text("## Title #", 2), "Title");
4328        // All-hashes content collapses to empty.
4329        assert_eq!(heading_text("## ##", 2), "");
4330        // No trailing hashes — unchanged.
4331        assert_eq!(heading_text("## Plain", 2), "Plain");
4332    }
4333
4334    #[test]
4335    fn regression_extract_sections_keeps_csharp_heading_and_schema_type_binds() {
4336        // `dbmd sections` must report `C#`, not `C`.
4337        let secs = extract_sections("## C#\nbody\n");
4338        assert_eq!(secs.len(), 1);
4339        assert_eq!(secs[0].heading, "C#");
4340
4341        // And a `### c#` schema must register under `c#`, not `c`.
4342        let db = "---\ntype: db-md\n---\n\n## Schemas\n\n### c#\n- name (required)\n";
4343        let config = parse_db_md(db, Path::new("DB.md")).unwrap();
4344        assert!(
4345            config.schemas.contains_key("c#"),
4346            "schema bound to wrong key"
4347        );
4348        assert!(!config.schemas.contains_key("c"));
4349    }
4350
4351    // ── Regression: section line numbers offset by the frontmatter block (#7) ──
4352
4353    #[test]
4354    fn regression_extract_sections_in_file_reports_source_line_numbers() {
4355        // A heading on file line 6 (after a 4-line frontmatter block + 1 body
4356        // line) must be reported as L6, not the body-relative L2.
4357        let text = "---\ntype: note\nsummary: x\n---\nbody line\n## Heading\nmore\n";
4358        let secs = extract_sections_in_file(text);
4359        assert_eq!(secs.len(), 1);
4360        assert_eq!(secs[0].heading, "Heading");
4361        assert_eq!(secs[0].line, 6, "section line not offset by frontmatter");
4362
4363        // The body-relative helper is unchanged (validate relies on that frame).
4364        let body_secs = extract_sections("body line\n## Heading\nmore\n");
4365        assert_eq!(body_secs[0].line, 2);
4366
4367        // No frontmatter: whole text is body, no offset.
4368        let plain = extract_sections_in_file("## Top\nx\n## Next\n");
4369        assert_eq!(plain[0].line, 1);
4370        assert_eq!(plain[1].line, 3);
4371    }
4372
4373    // ── Regression: colon-form schema field bullet parses modifiers (#8) ───────
4374
4375    #[test]
4376    fn regression_colon_form_field_bullet_parses_modifiers() {
4377        // `- title: string, required` is the natural mis-spelling of
4378        // `- title (string, required)`; before the fix the whole text became the
4379        // field name and every modifier was silently lost.
4380        let f = parse_field_spec("- title: string, required");
4381        assert_eq!(f.name, "title");
4382        assert!(f.required, "required modifier lost on colon-form");
4383        assert_eq!(f.shape, Some(Shape::String));
4384
4385        // Through the schema-bullet classifier (the real path), it is a Field.
4386        match parse_schema_bullet("- title: string, required") {
4387            SchemaBullet::Field(f) => {
4388                assert_eq!(f.name, "title");
4389                assert!(f.required);
4390                assert_eq!(f.shape, Some(Shape::String));
4391            }
4392            other => panic!("expected Field, got {other:?}"),
4393        }
4394
4395        // A paren form whose modifiers contain a colon still parses by parens.
4396        let g = parse_field_spec("- status (enum: open, closed)");
4397        assert_eq!(g.name, "status");
4398        assert_eq!(
4399            g.enum_values,
4400            Some(vec!["open".to_string(), "closed".to_string()])
4401        );
4402    }
4403
4404    // ── Regression: comma inside a `default` value is preserved (#9) ───────────
4405
4406    #[test]
4407    fn regression_default_value_preserves_internal_commas() {
4408        let f = parse_field_spec("- title (default Director, Operations)");
4409        assert_eq!(
4410            f.default,
4411            Some(Value::String("Director, Operations".into())),
4412            "comma-bearing default truncated"
4413        );
4414
4415        let g = parse_field_spec("- region (default North America, EMEA fallback)");
4416        assert_eq!(
4417            g.default,
4418            Some(Value::String("North America, EMEA fallback".into()))
4419        );
4420
4421        // A single-token default still works (no regression).
4422        let h = parse_field_spec("- currency (default USD)");
4423        assert_eq!(h.default, Some(Value::String("USD".into())));
4424    }
4425
4426    // ── Regression: a `default` after `enum` is parsed, not swallowed (#10) ────
4427
4428    #[test]
4429    fn regression_default_after_enum_is_parsed_not_an_enum_member() {
4430        let f = parse_field_spec("- status (enum: open, closed, default open)");
4431        assert_eq!(
4432            f.enum_values,
4433            Some(vec!["open".to_string(), "closed".to_string()]),
4434            "`default open` leaked into the enum list"
4435        );
4436        assert_eq!(
4437            f.default,
4438            Some(Value::String("open".into())),
4439            "default after enum was dropped"
4440        );
4441
4442        // The bare `enum` keyword form, with a trailing default.
4443        let g = parse_field_spec("- status (enum, open, closed, default open)");
4444        assert_eq!(
4445            g.enum_values,
4446            Some(vec!["open".to_string(), "closed".to_string()])
4447        );
4448        assert_eq!(g.default, Some(Value::String("open".into())));
4449    }
4450
4451    // ── Regression: frozen-page policy does not fail open (#11) ────────────────
4452
4453    #[test]
4454    fn regression_frozen_match_handles_leading_slash() {
4455        let cfg = Config {
4456            frozen_pages: vec![PathBuf::from("/records/decisions/q1.md")],
4457            ..Config::default()
4458        };
4459        assert!(
4460            cfg.is_frozen(Path::new("records/decisions/q1.md")),
4461            "leading-slash entry failed open"
4462        );
4463        assert!(cfg.is_frozen(Path::new("records/decisions/q1")));
4464    }
4465
4466    #[test]
4467    fn regression_frozen_match_supports_globs() {
4468        let cfg = Config {
4469            frozen_pages: vec![PathBuf::from("records/decisions/*")],
4470            ..Config::default()
4471        };
4472        assert!(
4473            cfg.is_frozen(Path::new("records/decisions/q1.md")),
4474            "glob entry failed to protect a concrete file"
4475        );
4476        assert!(cfg.is_frozen(Path::new("records/decisions/q2.md")));
4477        // The glob does not cross a `/` segment.
4478        assert!(!cfg.is_frozen(Path::new("records/decisions/sub/q1.md")));
4479        // `**` crosses segments.
4480        let deep = Config {
4481            frozen_pages: vec![PathBuf::from("records/**")],
4482            ..Config::default()
4483        };
4484        assert!(deep.is_frozen(Path::new("records/decisions/sub/q1.md")));
4485        assert!(deep.is_frozen(Path::new("records/x.md")));
4486        assert!(!deep.is_frozen(Path::new("sources/x.md")));
4487        // A `*.md`-style intra-segment glob.
4488        let suffix = Config {
4489            frozen_pages: vec![PathBuf::from("records/decisions/q*")],
4490            ..Config::default()
4491        };
4492        assert!(suffix.is_frozen(Path::new("records/decisions/q1.md")));
4493        assert!(!suffix.is_frozen(Path::new("records/decisions/draft.md")));
4494    }
4495
4496    #[test]
4497    fn regression_frozen_glob_many_double_stars_does_not_backtrack_exponentially() {
4498        use std::time::Instant;
4499
4500        // A DB.md frozen-page bullet with many consecutive `**` segments and a
4501        // literal tail (`zzz`), matched against a deep target that ends in a
4502        // DIFFERENT segment (`file.md`), is the catastrophic-backtracking case:
4503        // the old two-way `glob_segments` recursion explored an exponential
4504        // number of (star, path) splits before concluding "no match" — ~119s for
4505        // 15 stars — hanging the store's entire write path (every write/rename/
4506        // fm-set funnels through `frozen_match`). The two-pointer matcher + `**`
4507        // collapse make this polynomial.
4508        let pat = format!("{}/zzz", vec!["**"; 30].join("/"));
4509        let target_path = format!("records/{}/file.md", vec!["a"; 40].join("/"));
4510        let cfg = Config {
4511            frozen_pages: vec![PathBuf::from(&pat)],
4512            ..Config::default()
4513        };
4514
4515        let start = Instant::now();
4516        let frozen = cfg.is_frozen(Path::new(&target_path));
4517        let elapsed = start.elapsed();
4518
4519        // The tail `zzz` never matches the target's `file.md`, so it is NOT frozen…
4520        assert!(
4521            !frozen,
4522            "non-matching deep target wrongly reported frozen (semantics changed)"
4523        );
4524        // …and the decision must be near-instant, not exponential. The pre-fix
4525        // code took tens of seconds here; a generous ceiling still fails loudly
4526        // if the blow-up ever returns.
4527        assert!(
4528            elapsed.as_secs() < 1,
4529            "frozen glob took {elapsed:?} — catastrophic backtracking is back"
4530        );
4531
4532        // Semantics preserved: the same many-`**` pattern with a tail that DOES
4533        // match still freezes the file (a real match still refuses the write).
4534        let pat_hit = format!("{}/file.md", vec!["**"; 30].join("/"));
4535        let cfg_hit = Config {
4536            frozen_pages: vec![PathBuf::from(&pat_hit)],
4537            ..Config::default()
4538        };
4539        assert!(
4540            cfg_hit.is_frozen(Path::new(&target_path)),
4541            "many-`**` pattern failed to freeze a genuinely-matching deep target"
4542        );
4543    }
4544
4545    #[test]
4546    fn frozen_glob_double_star_collapse_preserves_match_set() {
4547        // Collapsing consecutive `**` must not change which paths match: `**/**`
4548        // matches exactly what `**` does. Interleaved `**` and literals still
4549        // match across segments, and a non-matching literal tail still fails.
4550        let collapsed = Config {
4551            frozen_pages: vec![PathBuf::from("records/**/**/**/q1.md")],
4552            ..Config::default()
4553        };
4554        assert!(collapsed.is_frozen(Path::new("records/decisions/q1.md")));
4555        assert!(collapsed.is_frozen(Path::new("records/a/b/c/q1.md")));
4556        assert!(collapsed.is_frozen(Path::new("records/q1.md")));
4557        assert!(!collapsed.is_frozen(Path::new("records/a/b/c/q2.md")));
4558        assert!(!collapsed.is_frozen(Path::new("sources/a/q1.md")));
4559
4560        // `**` between two literals spans zero or more intermediate segments.
4561        let between = Config {
4562            frozen_pages: vec![PathBuf::from("records/**/draft.md")],
4563            ..Config::default()
4564        };
4565        assert!(between.is_frozen(Path::new("records/draft.md")));
4566        assert!(between.is_frozen(Path::new("records/a/b/draft.md")));
4567        assert!(!between.is_frozen(Path::new("records/a/b/final.md")));
4568    }
4569
4570    #[test]
4571    fn regression_frozen_entry_single_hyphen_comment_is_stripped() {
4572        // `records/decisions/q3.md - finalized` (single ASCII hyphen comment, no
4573        // backticks): the comment must be stripped so the entry is just the path.
4574        let path = extract_path_bullet("- records/decisions/q3.md - finalized");
4575        assert_eq!(path, "records/decisions/q3.md");
4576
4577        // End to end: such a bullet freezes the file.
4578        let cfg = Config {
4579            frozen_pages: vec![PathBuf::from(extract_path_bullet(
4580                "- records/decisions/q3.md - finalized",
4581            ))],
4582            ..Config::default()
4583        };
4584        assert!(
4585            cfg.is_frozen(Path::new("records/decisions/q3.md")),
4586            "single-hyphen-comment entry failed open"
4587        );
4588    }
4589}