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