Skip to main content

memstead_base/entity/
parser.rs

1//! Markdown → Entity parser. Handles YAML frontmatter, sections, wiki-links.
2//!
3//! Key design decisions:
4//! - Hand-rolled YAML frontmatter parser (NOT serde_yaml) to match JS type coercion
5//! - Code blocks are masked before section/link detection to prevent false matches
6//! - The parser is schema-aware: it uses the schema to determine catch-all sections
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::sync::OnceLock;
11
12use indexmap::IndexMap;
13use regex::Regex;
14use sha2::{Digest, Sha256};
15
16use memstead_schema::TypeDefinition;
17
18use super::id::{WikiLinkError, file_path_to_id, wiki_link_to_id, wiki_link_to_id_lenient};
19use super::{Entity, EntityId, HeadingSpan, MetadataValue, ParseResult, Relationship};
20
21/// Parse a markdown string into an Entity.
22pub fn parse_markdown(
23    content: &str,
24    relative_path: &str,
25    schema: &TypeDefinition,
26    mem: &str,
27) -> Result<ParseResult, ParseError> {
28    let id = file_path_to_id(relative_path, mem);
29
30    // Compute content hash from raw markdown
31    let content_hash = compute_hash(content);
32
33    // Mask fenced code blocks so patterns inside them are not detected
34    let masked = mask_code_blocks(content);
35
36    // Extract YAML frontmatter
37    let (metadata, body, masked_body) = split_frontmatter(content, &masked)?;
38
39    // Extract title (first # heading)
40    let title = extract_title(&body).unwrap_or_else(|| id.name().to_string());
41
42    // Split body into ## sections (match against masked, slice from original).
43    // Duplicate `## Heading` lines whose slug matches a schema-declared key
44    // become `DuplicateSectionHeading` warnings below; first-wins is the
45    // resolution policy.
46    let (sections_map, duplicate_headings) = split_sections(&body, &masked_body);
47
48    // Parse typed relationships from the Relationships section.
49    // The entity-id collector lets the parser surface
50    // `AMBIGUOUS_DESCRIPTION_DELIMITER` warnings against a concrete
51    // source so boot / reload / attach sites can report them in
52    // `LoadCollector::warnings`.
53    let rel_heading_key = "relationships";
54    let entity_id_for_rel_warnings = file_path_to_id(relative_path, mem);
55    let (relationships, rel_parse_warnings) = parse_relationships_with_warnings(
56        sections_map
57            .get(rel_heading_key)
58            .map(|s| s.as_str())
59            .unwrap_or(""),
60        mem,
61        Some(&entity_id_for_rel_warnings),
62    );
63
64    // Build catch-all section content
65    let catch_all_content = build_catch_all(&sections_map, schema);
66
67    // Extract schema-defined section values.
68    // IndexMap + this loop order is what guarantees sections iterate in the
69    // schema's declared order downstream. Do not change to a HashMap.
70    let mut result_sections = IndexMap::new();
71    for s in &schema.sections {
72        if s.catch_all {
73            result_sections.insert(s.key.clone(), catch_all_content.trim().to_string());
74        } else {
75            let val = sections_map
76                .get(s.key.as_str())
77                .map(|v| v.trim().to_string())
78                .unwrap_or_default();
79            result_sections.insert(s.key.clone(), val);
80        }
81    }
82
83    // Parse metadata values with type coercion
84    let mut parsed_metadata = parse_metadata(&metadata);
85
86    // Determine type from metadata or default, and ensure it's in metadata.
87    // The entity's `type:` frontmatter key takes precedence over the mem's
88    // default type — parse-time resolution means each file is authoritative
89    // about its own type.
90    let type_name = parsed_metadata
91        .get("type")
92        .and_then(|v| v.as_str())
93        .unwrap_or(schema.name.as_str())
94        .to_string();
95    parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
96
97    // Extract inline wiki-links from text fields (excluding relationships section)
98    let inline_link_text: String = schema
99        .text_fields
100        .iter()
101        .filter_map(|f| result_sections.get(f.as_str()))
102        .cloned()
103        .collect::<Vec<_>>()
104        .join("\n");
105    // Read-time scan: tolerate pre-strict on-disk drift so loaders
106    // and dangling-link reporters keep working against legacy
107    // entities. The mutation pipeline re-extracts strictly via
108    // `extract_inline_links` and refuses on grammar violations.
109    let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
110
111    // Filter out targets already covered by explicit relationships
112    let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
113    let inline_links: Vec<EntityId> = inline_links
114        .into_iter()
115        .filter(|link| !explicit_targets.contains(link))
116        .collect();
117
118    // Extract H3–H6 spans per section for search-time heading-path attribution.
119    // Side-struct only: regenerated every parse, never persisted.
120    let heading_spans = extract_heading_spans(&result_sections);
121
122    // Build warnings for duplicate-heading occurrences whose slug matches a
123    // schema-declared key. Catch-all keys (`s.catch_all`) absorb arbitrary
124    // headings by design, so duplicates there are not surfaced.
125    let declared_keys: HashSet<&str> = schema
126        .sections
127        .iter()
128        .filter(|s| !s.catch_all)
129        .map(|s| s.key.as_str())
130        .collect();
131    let entity_id_for_warnings = file_path_to_id(relative_path, mem);
132    let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
133        .into_iter()
134        .filter(|d| declared_keys.contains(d.key.as_str()))
135        .map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
136            entity_id: entity_id_for_warnings.clone(),
137            section_key: d.key,
138            heading: d.heading,
139            occurrences: d.occurrences,
140        })
141        .collect();
142    parse_warnings.extend(rel_parse_warnings);
143
144    let entity = Entity {
145        id,
146        title,
147        entity_type: type_name,
148        mem: mem.to_string(),
149        file_path: relative_path.to_string(),
150        metadata: parsed_metadata,
151        sections: result_sections,
152        relationships,
153        content_hash,
154        stub: false,
155        stub_kind: None,
156        heading_spans,
157    };
158
159    Ok(ParseResult {
160        entity,
161        inline_links,
162        parse_warnings,
163    })
164}
165
166/// Parse an entity from a file on disk.
167pub fn parse_file(
168    path: &Path,
169    mem_dir: &Path,
170    schema: &TypeDefinition,
171    mem: &str,
172) -> Result<ParseResult, ParseError> {
173    let content = std::fs::read_to_string(path)?;
174    let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
175    parse_markdown(&content, &relative_path, schema, mem)
176}
177
178// ---------------------------------------------------------------------------
179// Frontmatter
180// ---------------------------------------------------------------------------
181
182/// Extract the `type:` value from frontmatter without running the full parser.
183///
184/// Used by the loader to resolve each file's type independently — the mem
185/// config's default type is only a fallback for files that don't declare one.
186/// Returns None if there's no frontmatter, no `type:` line, or it's empty.
187pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
188    let after_open = if content.starts_with("---\r\n") {
189        5
190    } else if content.starts_with("---\n") {
191        4
192    } else {
193        return None;
194    };
195
196    let close_pos = content[after_open..].find("\n---")?;
197    let frontmatter = &content[after_open..after_open + close_pos];
198
199    for line in frontmatter.lines() {
200        let trimmed = line.trim();
201        if trimmed.is_empty() || trimmed.starts_with('#') {
202            continue;
203        }
204        let Some(colon_idx) = trimmed.find(':') else {
205            continue;
206        };
207        let key = trimmed[..colon_idx].trim();
208        if key != "type" {
209            continue;
210        }
211        let mut value = trimmed[colon_idx + 1..].trim();
212        if let Some(hash_idx) = value.find('#') {
213            value = value[..hash_idx].trim();
214        }
215        let value = value.trim_matches(|c| c == '"' || c == '\'');
216        if value.is_empty() {
217            return None;
218        }
219        return Some(value.to_string());
220    }
221    None
222}
223
224/// Peek the entity title (first `# ` heading in the body) and type
225/// (`type:` frontmatter field) from raw markdown without running the
226/// full schema-aware parser. Used by surfaces that read a markdown blob
227/// outside the in-memory store — e.g. `memstead_diff` walking git trees
228/// between two arbitrary refs, where the store snapshot (current HEAD)
229/// is not a valid source for a non-HEAD ref. Returns `None` for `title`
230/// when the body carries no `# ` heading and `None` for `entity_type`
231/// when the frontmatter lacks a non-empty `type:`.
232pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
233    let entity_type = peek_type_from_frontmatter(content);
234    let title = extract_title(body_after_frontmatter(content));
235    (title, entity_type)
236}
237
238/// Return the body slice after a leading `---`-fenced frontmatter block,
239/// or the whole input when no frontmatter is present. Mirrors the
240/// offset arithmetic in [`split_frontmatter`] but borrows rather than
241/// allocating — the title peek only needs to scan, not own.
242fn body_after_frontmatter(content: &str) -> &str {
243    let after_open = if content.starts_with("---\r\n") {
244        5
245    } else if content.starts_with("---\n") {
246        4
247    } else {
248        return content;
249    };
250    let Some(close_pos) = content[after_open..].find("\n---") else {
251        return content;
252    };
253    let body_start = after_open + close_pos + 4; // past "\n---"
254    let rest = &content[body_start..];
255    rest.strip_prefix("\r\n")
256        .or_else(|| rest.strip_prefix('\n'))
257        .unwrap_or(rest)
258}
259
260/// Split content into frontmatter metadata string and body.
261/// Returns (metadata_string, body, masked_body).
262fn split_frontmatter<'a>(
263    content: &'a str,
264    masked: &'a str,
265) -> Result<(String, String, String), ParseError> {
266    // Look for YAML frontmatter: ---\n...\n---
267    if content.starts_with("---\n") || content.starts_with("---\r\n") {
268        let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
269        // Find closing ---
270        if let Some(close_pos) = content[after_open..].find("\n---") {
271            let meta_end = after_open + close_pos;
272            let metadata = content[after_open..meta_end].to_string();
273            // Body starts after the closing --- and its newline
274            let body_start = meta_end + 4; // "\n---"
275            let body_start = if content[body_start..].starts_with('\n') {
276                body_start + 1
277            } else if content[body_start..].starts_with("\r\n") {
278                body_start + 2
279            } else {
280                body_start
281            };
282            let body = content[body_start..].to_string();
283            let masked_body = masked[body_start..].to_string();
284            return Ok((metadata, body, masked_body));
285        }
286    }
287
288    // No frontmatter found — entire content is body
289    Ok((String::new(), content.to_string(), masked.to_string()))
290}
291
292/// Parse metadata key-value pairs with JS-compatible type coercion.
293///
294/// Handles: strings, integers, floats, booleans.
295/// Strips inline comments (`value # comment`) and quotes (`"value"`).
296fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
297    let mut meta = IndexMap::new();
298    if text.is_empty() {
299        return meta;
300    }
301
302    for line in text.lines() {
303        let trimmed = line.trim();
304        // Skip empty lines, comments, heading markers, delimiters
305        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
306            continue;
307        }
308
309        let Some(colon_idx) = trimmed.find(':') else {
310            continue;
311        };
312
313        let key = trimmed[..colon_idx].trim().to_string();
314        let raw_value = trimmed[colon_idx + 1..].trim();
315
316        // Strip inline comments (# not inside the value)
317        let value = strip_inline_comment(raw_value).trim().to_string();
318
319        if value.is_empty() {
320            meta.insert(key, MetadataValue::String(String::new()));
321            continue;
322        }
323
324        // Type coercion (matching JS parser behavior exactly)
325        if value == "true" {
326            meta.insert(key, MetadataValue::Bool(true));
327        } else if value == "false" {
328            meta.insert(key, MetadataValue::Bool(false));
329        } else if is_float_literal(&value) {
330            if let Ok(f) = value.parse::<f64>() {
331                meta.insert(key, MetadataValue::Float(f));
332            } else {
333                meta.insert(key, MetadataValue::String(strip_quotes(&value)));
334            }
335        } else if is_integer_literal(&value) {
336            if let Ok(n) = value.parse::<i64>() {
337                meta.insert(key, MetadataValue::Integer(n));
338            } else {
339                meta.insert(key, MetadataValue::String(strip_quotes(&value)));
340            }
341        } else {
342            meta.insert(key, MetadataValue::String(strip_quotes(&value)));
343        }
344    }
345
346    meta
347}
348
349/// Check if a string matches the JS float regex: /^-?\d+\.\d+$/
350fn is_float_literal(s: &str) -> bool {
351    let s = s.strip_prefix('-').unwrap_or(s);
352    if let Some((before, after)) = s.split_once('.') {
353        !before.is_empty()
354            && before.chars().all(|c| c.is_ascii_digit())
355            && !after.is_empty()
356            && after.chars().all(|c| c.is_ascii_digit())
357    } else {
358        false
359    }
360}
361
362/// Check if a string matches the JS integer regex: /^-?\d+$/
363fn is_integer_literal(s: &str) -> bool {
364    let s = s.strip_prefix('-').unwrap_or(s);
365    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
366}
367
368/// Would `parse_metadata` coerce this raw value away from
369/// `MetadataValue::String`? Exposed so the generator can decide whether
370/// to YAML-quote a string value that would otherwise round-trip as
371/// Integer / Float / Bool. Kept co-located with the coercion rules so
372/// the two cannot drift.
373pub(crate) fn would_coerce_from_string(s: &str) -> bool {
374    s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
375}
376
377/// Strip inline comments: `value # comment` → `value`.
378fn strip_inline_comment(s: &str) -> &str {
379    // Find ` #` pattern (space followed by #)
380    // But be careful not to strip inside quoted strings
381    if let Some(idx) = s.find(" #") {
382        s[..idx].trim_end()
383    } else {
384        s
385    }
386}
387
388/// Strip surrounding quotes: `"value"` or `'value'` → `value`.
389/// A lone quote character is not a quoted value — `len >= 2` keeps the
390/// slice in bounds (a 1-char `"` satisfies both starts_with and ends_with).
391fn strip_quotes(s: &str) -> String {
392    if s.len() >= 2
393        && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
394    {
395        s[1..s.len() - 1].to_string()
396    } else {
397        s.to_string()
398    }
399}
400
401// ---------------------------------------------------------------------------
402// Code block masking
403// ---------------------------------------------------------------------------
404
405/// Mask fenced code blocks by replacing content with spaces (preserves line count and offsets).
406/// Handles unclosed code blocks safely — they mask to end of text.
407pub fn mask_code_blocks(text: &str) -> String {
408    let lines: Vec<&str> = text.split('\n').collect();
409    let mut result = Vec::with_capacity(lines.len());
410    let mut fence: Option<String> = None;
411
412    for line in &lines {
413        if let Some(ref _f) = fence {
414            // Inside a code block — check for closing fence
415            let trimmed = line.trim_end();
416            if trimmed.starts_with("```") {
417                result.push(" ".repeat(line.len()));
418                fence = None;
419            } else {
420                result.push(" ".repeat(line.len()));
421            }
422        } else {
423            // Outside — check for opening fence
424            if line.starts_with("```") {
425                fence = Some("```".to_string());
426                result.push(" ".repeat(line.len()));
427            } else {
428                result.push((*line).to_string());
429            }
430        }
431    }
432
433    result.join("\n")
434}
435
436// ---------------------------------------------------------------------------
437// Section splitting
438// ---------------------------------------------------------------------------
439
440/// Tracks one schema-declared section key seen more than once on parse.
441/// `key` is the slugified storage key (e.g. `realization`); `heading` is
442/// the original literal text from the first occurrence (e.g. `Realization`).
443/// `occurrences` counts every header line for that key — first plus
444/// duplicates.
445pub(super) struct DuplicateSection {
446    pub key: String,
447    pub heading: String,
448    pub occurrences: usize,
449}
450
451/// Split body into named sections. Returns `Map<lowercase_key, content>`
452/// plus a list of duplicate-heading occurrences. Duplicate headings keep
453/// the first occurrence's body; subsequent occurrences are dropped from
454/// the storage value entirely (no embedded `## Heading` separator). The
455/// caller decides whether each duplicate becomes a `WarningHint`
456/// (schema-declared keys only — catch-all repetition stays silent).
457pub(super) fn split_sections(
458    body: &str,
459    masked_body: &str,
460) -> (HashMap<String, String>, Vec<DuplicateSection>) {
461    let mut sections = HashMap::new();
462    let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
463    static SECTION_RE: OnceLock<Regex> = OnceLock::new();
464    let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
465
466    let matches: Vec<_> = section_re.find_iter(masked_body).collect();
467
468    for (i, m) in matches.iter().enumerate() {
469        // Extract heading name from original body (not masked)
470        let heading_line = &body[m.start()..m.end()];
471        let name = heading_line
472            .strip_prefix("## ")
473            .unwrap_or(heading_line)
474            .trim();
475
476        let content_start = m.end();
477        let content_end = if i + 1 < matches.len() {
478            matches[i + 1].start()
479        } else {
480            body.len()
481        };
482        let content = body[content_start..content_end].trim().to_string();
483        // Schema section keys are underscore-separated (e.g. `current_state`).
484        // A heading like `## Current State` must slugify to the same form so
485        // schema-declared sections land in `result_sections` under the right
486        // key instead of falling through to catch-all — which would break
487        // canonical byte-stability for any multi-word section.
488        let key = name.to_lowercase().replace(' ', "_");
489
490        match sections.entry(key.clone()) {
491            std::collections::hash_map::Entry::Vacant(slot) => {
492                slot.insert(content);
493                duplicates.insert(
494                    key.clone(),
495                    DuplicateSection {
496                        key: key.clone(),
497                        heading: name.to_string(),
498                        occurrences: 1,
499                    },
500                );
501            }
502            std::collections::hash_map::Entry::Occupied(_) => {
503                // First-wins: drop this duplicate's body entirely. Bump the
504                // occurrence count for the warning emitted by the caller.
505                if let Some(d) = duplicates.get_mut(&key) {
506                    d.occurrences += 1;
507                }
508            }
509        }
510    }
511
512    let dup_list: Vec<DuplicateSection> = duplicates
513        .into_values()
514        .filter(|d| d.occurrences > 1)
515        .collect();
516
517    (sections, dup_list)
518}
519
520/// Extract the title from the first `# ` heading.
521fn extract_title(body: &str) -> Option<String> {
522    for line in body.lines() {
523        if let Some(title) = line.strip_prefix("# ") {
524            return Some(title.trim().to_string());
525        }
526    }
527    None
528}
529
530// ---------------------------------------------------------------------------
531// Heading spans (H3–H6)
532// ---------------------------------------------------------------------------
533
534/// Extract H3–H6 heading spans from each section's content. Byte offsets are
535/// into the (trimmed) section string stored in `result_sections`. Code blocks
536/// are masked before scanning so `### foo` inside a fenced block is ignored.
537///
538/// End offsets use a level-aware closing rule: a span closes at the next
539/// heading with the same or lower level (H3 closes on next H3 or H2 — but
540/// H2 doesn't appear here since sections are already split), otherwise at
541/// the end of the section. Level skips (H2 → H4 without H3) are tolerated:
542/// the H4 span is recorded flat, and query-time path resolution uses offset
543/// containment to reconstruct ancestry.
544fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
545    // Compiled once per process; shape-constrained so it can't fail at runtime.
546    static RE: OnceLock<Regex> = OnceLock::new();
547    let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
548    let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
549
550    for (key, content) in sections {
551        if content.is_empty() {
552            continue;
553        }
554        let masked = mask_code_blocks(content);
555
556        // Collect (start_offset, level, title) in document order.
557        let raw: Vec<(usize, u8, String)> = re
558            .captures_iter(&masked)
559            .map(|cap| {
560                let whole = cap.get(0).unwrap();
561                let level = cap[1].len() as u8; // 3..=6
562                // Read the title from the original (unmasked) content so the
563                // captured text survives code-block masking's space-padding.
564                let line_end = content[whole.start()..]
565                    .find('\n')
566                    .map(|i| whole.start() + i)
567                    .unwrap_or(content.len());
568                let hashes_end = whole.start() + level as usize;
569                let title = content[hashes_end..line_end].trim().to_string();
570                (whole.start(), level, title)
571            })
572            .collect();
573
574        if raw.is_empty() {
575            continue;
576        }
577
578        let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
579        for (i, &(start, level, ref title)) in raw.iter().enumerate() {
580            // Scan forward for the next heading with level <= this one.
581            let end = raw[i + 1..]
582                .iter()
583                .find(|(_, l, _)| *l <= level)
584                .map(|(s, _, _)| *s)
585                .unwrap_or(content.len());
586            spans.push(HeadingSpan {
587                level,
588                title: title.clone(),
589                start_offset: start,
590                end_offset: end,
591            });
592        }
593        out.insert(key.clone(), spans);
594    }
595
596    out
597}
598
599// ---------------------------------------------------------------------------
600// Catch-all section
601// ---------------------------------------------------------------------------
602
603/// Build catch-all section content from its own section + non-schema sections.
604fn build_catch_all(sections: &HashMap<String, String>, schema: &TypeDefinition) -> String {
605    let catch_all = match schema.catch_all_section() {
606        Some(s) => s,
607        None => return String::new(),
608    };
609
610    let known_sections: HashSet<&str> = schema
611        .sections
612        .iter()
613        .map(|s| s.key.as_str())
614        .chain(std::iter::once("relationships"))
615        .collect();
616
617    let mut parts = Vec::new();
618
619    // First, add the explicit catch-all section content
620    if let Some(content) = sections.get(catch_all.key.as_str())
621        && !content.is_empty()
622    {
623        parts.push(content.clone());
624    }
625
626    // Then add all non-schema sections (with headings reconstructed).
627    // `sections: &HashMap` — iteration order is randomized per process.
628    // Non-determinism is invisible today because strict ingress rejects
629    // unknown sections unless the schema declares a catch-all, and then
630    // only the catch-all section itself lands here (see validator-v2 R6).
631    // If a future schema change lets multiple non-schema sections coexist
632    // under one catch-all, switch `sections` to `IndexMap` so canonical
633    // bytes stay stable.
634    for (key, content) in sections {
635        if !known_sections.contains(key.as_str()) && !content.is_empty() {
636            let heading = format!(
637                "## {}{}",
638                key.chars().next().unwrap_or_default().to_uppercase(),
639                &key[key.chars().next().map_or(0, |c| c.len_utf8())..]
640            );
641            parts.push(format!("{heading}\n{content}"));
642        }
643    }
644
645    parts.join("\n\n")
646}
647
648// ---------------------------------------------------------------------------
649// Relationships
650// ---------------------------------------------------------------------------
651
652/// Parse typed relationships from the Relationships section.
653///
654/// Recognises two row shapes:
655/// - simple: `- **TYPE**: [[target]]` → `description: None`
656/// - em-dash: `- **TYPE**: [[target]] — text` → `description: Some(text)`
657///
658/// Returns the relations plus parse-time warnings flagging
659/// AMBIGUOUS-delimiter rows (`-- text`, `- text`, en-dash, minus). On
660/// AMBIGUOUS rows the description is dropped — the renderer will
661/// normalise the row to the simple form on next write.
662pub(crate) fn parse_relationships_with_warnings(
663    text: &str,
664    mem: &str,
665    entity_id: Option<&EntityId>,
666) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
667    // Anchor on the canonical row prefix `- **TYPE**: [[<target>]]` and
668    // capture everything that follows on the same line so the trailing
669    // segment can be classified (simple, em-dash, or AMBIGUOUS).
670    static RE: OnceLock<Regex> = OnceLock::new();
671    let re = RE.get_or_init(|| {
672        Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]]+)\]\](?P<tail>[^\n]*)").unwrap()
673    });
674    let mut relationships = Vec::new();
675    let mut warnings = Vec::new();
676    for cap in re.captures_iter(text) {
677        let rel_type = cap[1].to_uppercase();
678        // Read-time parsing of the ## Relationships table tolerates
679        // pre-strict on-disk drift so legacy rows whose target fails
680        // the wiki-link grammar continue to round-trip. The mutation
681        // pipeline (`memstead_relate`, declare_relations) gates strictly
682        // via `validate_relation_target_grammar`.
683        let target = wiki_link_to_id_lenient(&cap[2], mem);
684        let tail = cap.name("tail").map(|m| m.as_str()).unwrap_or("");
685        let description = match classify_description_tail(tail) {
686            DescriptionTail::None => None,
687            DescriptionTail::EmDash(text) => Some(text),
688            DescriptionTail::Ambiguous(literal) => {
689                if let Some(id) = entity_id {
690                    warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
691                        from: id.clone(),
692                        rel_type: rel_type.clone(),
693                        target: target.clone(),
694                        trailing: literal,
695                    });
696                }
697                None
698            }
699        };
700        relationships.push(Relationship {
701            rel_type,
702            target,
703            description,
704        });
705    }
706    (relationships, warnings)
707}
708
709/// Classification of the per-line tail that follows `]]` on a
710/// `## Relationships` row.
711enum DescriptionTail {
712    /// Tail is empty or whitespace-only.
713    None,
714    /// Tail begins with the canonical em-dash delimiter; carries the
715    /// captured description text (trimmed of trailing whitespace).
716    EmDash(String),
717    /// Tail starts with a non-canonical dash-like delimiter (`-`,
718    /// `--`, U+2013 en-dash, U+2212 minus). Carries the literal
719    /// trailing content so the warning surfaces what was dropped.
720    Ambiguous(String),
721}
722
723/// Inspect the post-`]]` tail of a `## Relationships` row and decide
724/// what shape it takes. The em-dash delimiter is the exact three-byte
725/// UTF-8 sequence of U+2014 framed by single ASCII spaces; everything
726/// else falls into [`DescriptionTail::None`] or
727/// [`DescriptionTail::Ambiguous`].
728fn classify_description_tail(tail: &str) -> DescriptionTail {
729    let trimmed_end = tail.trim_end();
730    if trimmed_end.is_empty() {
731        return DescriptionTail::None;
732    }
733    // Canonical: literal space + U+2014 + literal space + content.
734    if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
735        if rest.is_empty() {
736            return DescriptionTail::None;
737        }
738        return DescriptionTail::EmDash(rest.to_string());
739    }
740    // U+2014 directly after `]]` (no leading space) is also ambiguous
741    // — the canonical form requires the framing space. Likewise an
742    // em-dash with no trailing content (` — `) collapses to None.
743    if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
744        // ` —` (no trailing space, but content followed) lands here.
745        return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
746    }
747    // Dash-likes: ASCII `--`, ASCII `-`, en-dash U+2013, minus U+2212.
748    let starters = [" --", " -", " \u{2013}", " \u{2212}"];
749    if starters
750        .iter()
751        .any(|prefix| trimmed_end.starts_with(prefix))
752    {
753        return DescriptionTail::Ambiguous(trimmed_end.to_string());
754    }
755    // Anything else after `]]` (e.g. inline comment, stray text) —
756    // classify as ambiguous so the operator sees that content was
757    // dropped rather than silently swallowed.
758    DescriptionTail::Ambiguous(trimmed_end.to_string())
759}
760
761// ---------------------------------------------------------------------------
762// Wiki-links
763// ---------------------------------------------------------------------------
764
765/// A wiki-link found in markdown content.
766#[derive(Debug, Clone)]
767pub struct WikiLink {
768    pub target: String,
769    pub label: Option<String>,
770}
771
772/// The `[[target]]` / `[[target|label]]` wiki-link pattern, compiled once.
773fn wiki_link_re() -> &'static Regex {
774    static RE: OnceLock<Regex> = OnceLock::new();
775    RE.get_or_init(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap())
776}
777
778/// Inline code spans (masked out before link extraction), compiled once.
779fn inline_code_re() -> &'static Regex {
780    static RE: OnceLock<Regex> = OnceLock::new();
781    RE.get_or_init(|| Regex::new(r"`[^`]+`").unwrap())
782}
783
784/// Extract all wiki-links from markdown content.
785pub fn extract_wiki_links(content: &str) -> Vec<WikiLink> {
786    let re = wiki_link_re();
787    re.captures_iter(content)
788        .map(|cap| {
789            let raw = &cap[1];
790            let (target, label) = match raw.find('|') {
791                Some(i) => (raw[..i].to_string(), Some(raw[i + 1..].to_string())),
792                None => (raw.to_string(), None),
793            };
794            WikiLink { target, label }
795        })
796        .collect()
797}
798
799/// Extract unique mem-prefixed entity IDs from inline wiki-links,
800/// strictly validating each target against the slug-form grammar.
801/// Strips fenced code blocks and inline code before scanning.
802///
803/// Returns the deduped valid ids on success, or every refusal in the
804/// scan window on failure (errors are collected, not fail-fast — the
805/// agent sees every malformed link in a single round-trip).
806///
807/// Mutation-pipeline callers (`synthesise_alias_relations`, etc.) use
808/// this strict variant and map [`WikiLinkError`] to the typed engine
809/// envelope with section context. Read-side scanners that must
810/// tolerate pre-strict on-disk drift use [`extract_inline_links_lenient`].
811pub(crate) fn extract_inline_links(
812    text: &str,
813    mem: &str,
814) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
815    let stripped = mask_code_blocks(text);
816    let stripped = inline_code_re().replace_all(&stripped, "");
817
818    let link_re = wiki_link_re();
819    let mut seen = HashSet::new();
820    let mut links = Vec::new();
821    let mut errors = Vec::new();
822
823    for cap in link_re.captures_iter(&stripped) {
824        match wiki_link_to_id(&cap[1], mem) {
825            Ok(id) => {
826                if errors.is_empty() && seen.insert(id.0.clone()) {
827                    links.push(id);
828                }
829            }
830            Err(e) => errors.push(e),
831        }
832    }
833
834    if errors.is_empty() {
835        Ok(links)
836    } else {
837        Err(errors)
838    }
839}
840
841/// Permissive sibling of [`extract_inline_links`] for read-side
842/// scanners. Decodes every `[[...]]` token via [`wiki_link_to_id_lenient`]
843/// so on-disk drift (legacy entities, archive-imports from pre-strict
844/// engines, partial-mutation rollbacks) keeps flowing through dangling-
845/// link reporters and graph inspectors. Mutation paths MUST NOT use this
846/// helper — see [`extract_inline_links`] for the strict variant.
847pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
848    let stripped = mask_code_blocks(text);
849    let stripped = inline_code_re().replace_all(&stripped, "");
850
851    let link_re = wiki_link_re();
852    let mut seen = HashSet::new();
853    let mut links = Vec::new();
854
855    for cap in link_re.captures_iter(&stripped) {
856        let id = wiki_link_to_id_lenient(&cap[1], mem);
857        if seen.insert(id.0.clone()) {
858            links.push(id);
859        }
860    }
861
862    links
863}
864
865// ---------------------------------------------------------------------------
866// Content hash
867// ---------------------------------------------------------------------------
868
869/// Compute SHA-256 hash of content, truncated to 16 hex characters.
870pub fn compute_hash(content: &str) -> String {
871    let mut hasher = Sha256::new();
872    hasher.update(content.as_bytes());
873    let result = hasher.finalize();
874    format!("{:x}", result)[..16].to_string()
875}
876
877// ---------------------------------------------------------------------------
878// Errors
879// ---------------------------------------------------------------------------
880
881#[derive(Debug, thiserror::Error)]
882pub enum ParseError {
883    #[error("missing frontmatter")]
884    MissingFrontmatter,
885    #[error("invalid frontmatter: {0}")]
886    InvalidFrontmatter(String),
887    #[error("missing title")]
888    MissingTitle,
889    #[error("io error: {0}")]
890    Io(#[from] std::io::Error),
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use memstead_schema::{builtin_names, type_by_name};
897    use std::sync::Arc;
898
899    fn spec_schema() -> Arc<TypeDefinition> {
900        type_by_name(builtin_names::SPEC).unwrap()
901    }
902
903    fn memo_schema() -> Arc<TypeDefinition> {
904        type_by_name(builtin_names::MEMO).unwrap()
905    }
906
907    #[test]
908    fn parse_metadata_types() {
909        let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
910        assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
911        assert_eq!(meta["num"], MetadataValue::Integer(42));
912        assert_eq!(meta["float"], MetadataValue::Float(0.85));
913        assert_eq!(meta["bool"], MetadataValue::Bool(true));
914        assert_eq!(meta["falsy"], MetadataValue::Bool(false));
915    }
916
917    #[test]
918    fn parse_metadata_strips_comments() {
919        let meta = parse_metadata("key: value # this is a comment");
920        assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
921    }
922
923    #[test]
924    fn parse_metadata_strips_quotes() {
925        let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
926        assert_eq!(
927            meta["key"],
928            MetadataValue::String("quoted value".to_string())
929        );
930        assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
931    }
932
933    #[test]
934    fn parse_metadata_survives_malformed_values() {
935        // A lone quote character satisfies both starts_with and ends_with —
936        // the old unguarded slice `s[1..s.len()-1]` panicked on it.
937        let meta = parse_metadata(
938            "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
939        );
940        assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
941        assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
942        assert_eq!(meta["key3"], MetadataValue::String(String::new()));
943        assert_eq!(meta["key4"], MetadataValue::String(String::new()));
944        assert_eq!(
945            meta["key5"],
946            MetadataValue::String("\"unterminated".to_string())
947        );
948        assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
949
950        // More frontmatter shapes that must parse to a value, never panic:
951        // colon-only lines, multi-byte values, keyless colons, huge digits.
952        let meta =
953            parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
954        assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
955        assert_eq!(
956            meta["key8"],
957            MetadataValue::String("99999999999999999999999999".to_string())
958        );
959        assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
960    }
961
962    #[test]
963    fn parse_metadata_skips_comments_and_empty() {
964        let meta = parse_metadata("# comment\n\nkey: val\n---");
965        assert_eq!(meta.len(), 1);
966        assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
967    }
968
969    #[test]
970    fn peek_type_finds_value() {
971        let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
972        assert_eq!(
973            peek_type_from_frontmatter(content),
974            Some("memo".to_string())
975        );
976    }
977
978    #[test]
979    fn peek_type_returns_none_when_missing() {
980        let content = "---\ntitle: Test\n---\n# Body\n";
981        assert_eq!(peek_type_from_frontmatter(content), None);
982    }
983
984    #[test]
985    fn peek_type_returns_none_without_frontmatter() {
986        let content = "# Just a heading\n\nBody with type: concept inside text.\n";
987        assert_eq!(peek_type_from_frontmatter(content), None);
988    }
989
990    #[test]
991    fn peek_type_handles_windows_line_endings() {
992        let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
993        assert_eq!(
994            peek_type_from_frontmatter(content),
995            Some("principle".to_string())
996        );
997    }
998
999    #[test]
1000    fn peek_type_strips_quotes_and_comments() {
1001        let quoted = "---\ntype: \"concept\"\n---\n";
1002        assert_eq!(
1003            peek_type_from_frontmatter(quoted),
1004            Some("concept".to_string())
1005        );
1006        let commented = "---\ntype: memo # kind of\n---\n";
1007        assert_eq!(
1008            peek_type_from_frontmatter(commented),
1009            Some("memo".to_string())
1010        );
1011    }
1012
1013    #[test]
1014    fn peek_type_empty_value_returns_none() {
1015        let content = "---\ntype:\n---\n";
1016        assert_eq!(peek_type_from_frontmatter(content), None);
1017    }
1018
1019    #[test]
1020    fn peek_type_ignores_legacy_schema_key() {
1021        // After the hard break, a bare `schema:` in frontmatter is not
1022        // recognized as the type key — it's just arbitrary metadata.
1023        let content = concat!("---\n", "schema", ": memo\n---\n");
1024        assert_eq!(peek_type_from_frontmatter(content), None);
1025    }
1026
1027    #[test]
1028    fn mask_code_blocks_basic() {
1029        let input = "before\n```\ncode [[link]]\n```\nafter";
1030        let masked = mask_code_blocks(input);
1031        assert!(!masked.contains("[[link]]"));
1032        assert!(masked.contains("before"));
1033        assert!(masked.contains("after"));
1034    }
1035
1036    #[test]
1037    fn mask_code_blocks_preserves_line_count() {
1038        let input = "line1\n```\ncode\nmore code\n```\nline6";
1039        let masked = mask_code_blocks(input);
1040        assert_eq!(input.lines().count(), masked.lines().count());
1041    }
1042
1043    #[test]
1044    fn mask_code_blocks_unclosed() {
1045        let input = "before\n```\ncode\nmore code";
1046        let masked = mask_code_blocks(input);
1047        assert!(masked.contains("before"));
1048        assert!(!masked.contains("code"));
1049    }
1050
1051    #[test]
1052    fn extract_wiki_links_basic() {
1053        let links = extract_wiki_links("See [[target]] and [[other|label]]");
1054        assert_eq!(links.len(), 2);
1055        assert_eq!(links[0].target, "target");
1056        assert_eq!(links[1].target, "other");
1057        assert_eq!(links[1].label.as_deref(), Some("label"));
1058    }
1059
1060    #[test]
1061    fn parse_relationships_basic() {
1062        let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1063        let rels = parse_relationships_with_warnings(text, "specs", None).0;
1064        assert_eq!(rels.len(), 2);
1065        assert_eq!(rels[0].rel_type, "USES");
1066        assert_eq!(rels[0].target.0, "specs--target-entity");
1067        assert_eq!(rels[1].rel_type, "PART_OF");
1068        assert_eq!(rels[1].target.0, "specs--parent");
1069        // Simple form parses without a description.
1070        assert!(rels[0].description.is_none());
1071        assert!(rels[1].description.is_none());
1072    }
1073
1074    #[test]
1075    fn parse_relationships_canonical_em_dash_captures_description() {
1076        let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1077        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1078        assert_eq!(rels.len(), 1);
1079        assert_eq!(
1080            rels[0].description.as_deref(),
1081            Some("replaced by checkout-flow")
1082        );
1083        assert!(warnings.is_empty(), "canonical em-dash does not warn");
1084    }
1085
1086    #[test]
1087    fn parse_relationships_em_dash_inside_description_body() {
1088        let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1089        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1090        assert_eq!(rels.len(), 1);
1091        assert_eq!(
1092            rels[0].description.as_deref(),
1093            Some("note with — inside body"),
1094            "the parser captures up to end-of-line; em-dashes inside the body survive"
1095        );
1096        assert!(warnings.is_empty());
1097    }
1098
1099    #[test]
1100    fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1101        let text = "- **USES**: [[a]] -- legacy delimiter";
1102        let entity_id = EntityId::new("specs", "src");
1103        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1104        assert_eq!(rels.len(), 1);
1105        assert!(rels[0].description.is_none(), "trailing content is dropped");
1106        assert_eq!(warnings.len(), 1);
1107        assert!(matches!(
1108            warnings[0],
1109            crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1110        ));
1111    }
1112
1113    #[test]
1114    fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1115        let text = "- **USES**: [[a]] - single hyphen";
1116        let entity_id = EntityId::new("specs", "src");
1117        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1118        assert_eq!(rels.len(), 1);
1119        assert!(rels[0].description.is_none());
1120        assert_eq!(warnings.len(), 1);
1121        assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1122    }
1123
1124    #[test]
1125    fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1126        let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1127        let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1128        assert_eq!(rels.len(), 1);
1129        assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1130        assert_eq!(rels[0].description.as_deref(), Some("ok"));
1131        assert!(warnings.is_empty());
1132    }
1133
1134    #[test]
1135    fn parse_full_entity() {
1136        let md = "\
1137---
1138type: spec
1139created_date: 2026-01-15
1140last_modified: 2026-04-12
1141level: M0
1142tags: backend, api
1143---
1144# Test Entity
1145
1146## Identity
1147
1148This is a test entity.
1149
1150## Purpose
1151
1152Testing the parser.
1153
1154## Relationships
1155
1156- **USES**: [[other-entity]]
1157
1158## Specifies
1159
1160Some specification content with [[inline-link]].
1161";
1162        let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1163        let entity = &result.entity;
1164        assert_eq!(entity.id.0, "specs--test-entity");
1165        assert_eq!(entity.title, "Test Entity");
1166        assert_eq!(entity.mem, "specs");
1167        assert_eq!(
1168            entity.metadata["type"],
1169            MetadataValue::String("spec".to_string())
1170        );
1171        assert_eq!(
1172            entity.metadata["level"],
1173            MetadataValue::String("M0".to_string())
1174        );
1175        assert_eq!(
1176            entity.metadata["tags"],
1177            MetadataValue::String("backend, api".to_string())
1178        );
1179        assert_eq!(entity.sections["identity"], "This is a test entity.");
1180        assert_eq!(entity.sections["purpose"], "Testing the parser.");
1181        assert_eq!(entity.relationships.len(), 1);
1182        assert_eq!(entity.relationships[0].rel_type, "USES");
1183        assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1184        assert_eq!(result.inline_links.len(), 1);
1185        assert_eq!(result.inline_links[0].0, "specs--inline-link");
1186    }
1187
1188    #[test]
1189    fn parse_full_entity_memo_schema() {
1190        let md = "\
1191---
1192type: memo
1193created_date: 2026-01-15
1194last_modified: 2026-04-12
1195status: active
1196tags: decision, architecture
1197---
1198# Use Sled For Storage
1199
1200## Claim
1201
1202Sled is the right embedded store for this workload.
1203
1204## Context
1205
1206We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1207
1208## Substance
1209
1210Sled wins on pure-Rust dependency footprint.
1211";
1212        let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1213        let entity = &result.entity;
1214        assert_eq!(entity.id.0, "memos--use-sled");
1215        assert_eq!(entity.title, "Use Sled For Storage");
1216        assert_eq!(entity.mem, "memos");
1217        assert_eq!(
1218            entity.metadata["type"],
1219            MetadataValue::String("memo".to_string())
1220        );
1221        assert_eq!(
1222            entity.metadata["status"],
1223            MetadataValue::String("active".to_string())
1224        );
1225        assert_eq!(
1226            entity.sections["claim"],
1227            "Sled is the right embedded store for this workload."
1228        );
1229        assert_eq!(
1230            entity.sections["context"],
1231            "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1232        );
1233        assert_eq!(
1234            entity.sections["substance"],
1235            "Sled wins on pure-Rust dependency footprint."
1236        );
1237        assert!(!entity.sections.contains_key("identity"));
1238        assert!(!entity.sections.contains_key("purpose"));
1239    }
1240
1241    #[test]
1242    fn parse_entity_without_frontmatter() {
1243        let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1244        let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1245        assert_eq!(result.entity.title, "No Frontmatter");
1246        // Only the auto-injected type field should be present
1247        assert_eq!(result.entity.metadata.len(), 1);
1248        assert_eq!(
1249            result.entity.metadata.get("type"),
1250            Some(&MetadataValue::String("spec".to_string()))
1251        );
1252    }
1253
1254    #[test]
1255    fn parse_entity_code_blocks_not_detected() {
1256        let md = "\
1257---
1258type: spec
1259---
1260# Code Test
1261
1262## Identity
1263
1264Test entity.
1265
1266## Specifies
1267
1268```
1269## Not A Section
1270- **USES**: [[not-a-link]]
1271```
1272
1273Real content after code block.
1274";
1275        let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1276        // The ## inside code block should NOT be parsed as a section
1277        assert!(!result.entity.sections.contains_key("not a section"));
1278        // The wiki-link inside code block should NOT be extracted
1279        assert!(result.inline_links.is_empty());
1280    }
1281
1282    #[test]
1283    fn compute_hash_deterministic() {
1284        let hash1 = compute_hash("test content");
1285        let hash2 = compute_hash("test content");
1286        assert_eq!(hash1, hash2);
1287        assert_eq!(hash1.len(), 16);
1288    }
1289
1290    #[test]
1291    fn compute_hash_differs() {
1292        let hash1 = compute_hash("content a");
1293        let hash2 = compute_hash("content b");
1294        assert_ne!(hash1, hash2);
1295    }
1296
1297    #[test]
1298    fn is_float_literal_matches() {
1299        assert!(is_float_literal("0.85"));
1300        assert!(is_float_literal("-1.5"));
1301        assert!(is_float_literal("100.0"));
1302        assert!(!is_float_literal(".5"));
1303        assert!(!is_float_literal("1."));
1304        assert!(!is_float_literal("42"));
1305        assert!(!is_float_literal("hello"));
1306    }
1307
1308    #[test]
1309    fn is_integer_literal_matches() {
1310        assert!(is_integer_literal("42"));
1311        assert!(is_integer_literal("-1"));
1312        assert!(is_integer_literal("0"));
1313        assert!(!is_integer_literal("0.5"));
1314        assert!(!is_integer_literal("hello"));
1315        assert!(!is_integer_literal(""));
1316    }
1317
1318    // Regression lock for metadata-key order. The parser reads frontmatter
1319    // line-by-line into an IndexMap, so metadata iteration yields the file's
1320    // declared key order. Render sites iterate entity.metadata directly (see
1321    // `render::render_entity_markdown`), so any regression to HashMap
1322    // reintroduces hash-seed-dependent frontmatter ordering in MCP output.
1323    #[test]
1324    fn parse_preserves_frontmatter_key_order() {
1325        let md = "\
1326---
1327type: principle
1328universality: domain-wide
1329authority: proposed
1330tags: a, b, c
1331created_date: 2026-01-15
1332last_modified: 2026-04-12
1333---
1334# Key Order
1335";
1336        let result = parse_markdown(
1337            md,
1338            "key-order.md",
1339            &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1340            "knowledge",
1341        )
1342        .unwrap();
1343        let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1344        assert_eq!(
1345            keys,
1346            vec![
1347                "type",
1348                "universality",
1349                "authority",
1350                "tags",
1351                "created_date",
1352                "last_modified",
1353            ],
1354            "metadata iteration must preserve frontmatter declaration order"
1355        );
1356    }
1357
1358    // Regression lock for section-order round-trip stability. Today this
1359    // passes by construction: the parser inserts keys in schema-declared
1360    // order, the generator writes them in schema-declared order, and
1361    // `IndexMap` preserves that order across re-parses. HashMap iteration
1362    // order was the hole — an IndexMap-based entity.sections closes it.
1363    // Keep the test; if a future refactor reintroduces a HashMap anywhere on
1364    // the parse/write path, this catches it.
1365    #[test]
1366    fn parse_write_roundtrip_preserves_section_order() {
1367        let md = "\
1368---
1369type: spec
1370created_date: 2026-01-15
1371last_modified: 2026-04-12
1372level: M0
1373---
1374# Order Roundtrip
1375
1376## Identity
1377
1378Identity content.
1379
1380## Purpose
1381
1382Purpose content.
1383
1384## Specifies
1385
1386Specifies content.
1387";
1388        let schema = spec_schema();
1389        let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1390        let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1391        let second = parse_markdown(&regenerated, "order-roundtrip.md", &schema, "specs").unwrap();
1392
1393        let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1394        let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1395        assert_eq!(
1396            first_keys, second_keys,
1397            "section iteration order must survive parse -> generate -> parse"
1398        );
1399    }
1400
1401    // ------------------------------------------------------------------
1402    // Heading-spans extraction (H3–H6)
1403    //
1404    // These lock the parser contract: one extra pass per section that
1405    // records H3+ headings as a side-struct. Flat storage; level skips
1406    // are tolerated; code blocks are ignored. See
1407    // `extract_heading_spans`.
1408    // ------------------------------------------------------------------
1409
1410    #[test]
1411    fn parser_extracts_single_h3() {
1412        let md = "\
1413---
1414type: spec
1415---
1416# Entity
1417
1418## Identity
1419
1420Body.
1421
1422## Specifies
1423
1424### Response Shapes
1425
1426Content under response shapes.
1427";
1428        let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1429        let spans = result
1430            .entity
1431            .heading_spans
1432            .get("specifies")
1433            .expect("specifies section should have spans");
1434        assert_eq!(spans.len(), 1);
1435        assert_eq!(spans[0].level, 3);
1436        assert_eq!(spans[0].title, "Response Shapes");
1437        // The section is trimmed, so the H3 sits at offset 0.
1438        assert_eq!(spans[0].start_offset, 0);
1439        let section = result.entity.sections.get("specifies").unwrap();
1440        assert_eq!(spans[0].end_offset, section.len());
1441        // Non-specifies sections either get no entry or the content has no H3+ headings.
1442        assert!(
1443            result
1444                .entity
1445                .heading_spans
1446                .get("identity")
1447                .is_none_or(Vec::is_empty)
1448        );
1449    }
1450
1451    #[test]
1452    fn parser_extracts_nested_h3_h4() {
1453        let md = "\
1454---
1455type: spec
1456---
1457# Entity
1458
1459## Identity
1460
1461Body.
1462
1463## Specifies
1464
1465### Outer
1466
1467Outer body.
1468
1469#### Inner
1470
1471Inner body.
1472";
1473        let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1474        let spans = result.entity.heading_spans.get("specifies").unwrap();
1475        assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1476        assert_eq!(spans[0].level, 3);
1477        assert_eq!(spans[0].title, "Outer");
1478        assert_eq!(spans[1].level, 4);
1479        assert_eq!(spans[1].title, "Inner");
1480        assert!(
1481            spans[0].start_offset < spans[1].start_offset,
1482            "spans must be in document order"
1483        );
1484        // H3 contains H4: H3.end_offset must cover H4.start_offset.
1485        assert!(
1486            spans[0].end_offset > spans[1].start_offset,
1487            "outer H3 must contain inner H4 by offset"
1488        );
1489    }
1490
1491    #[test]
1492    fn parser_ignores_headings_in_code_blocks() {
1493        let md = "\
1494---
1495type: spec
1496---
1497# Entity
1498
1499## Identity
1500
1501Body.
1502
1503## Specifies
1504
1505Prefix.
1506
1507```
1508### Not a heading
1509Still code.
1510```
1511
1512Suffix.
1513";
1514        let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1515        let spans = result
1516            .entity
1517            .heading_spans
1518            .get("specifies")
1519            .cloned()
1520            .unwrap_or_default();
1521        assert!(
1522            spans.is_empty(),
1523            "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1524        );
1525    }
1526
1527    #[test]
1528    fn parser_handles_level_skip() {
1529        let md = "\
1530---
1531type: spec
1532---
1533# Entity
1534
1535## Identity
1536
1537Body.
1538
1539## Specifies
1540
1541#### Skipped To H4
1542
1543Content under a sudden H4 — no virtual H3 is inserted.
1544";
1545        let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1546        let spans = result.entity.heading_spans.get("specifies").unwrap();
1547        assert_eq!(spans.len(), 1);
1548        assert_eq!(spans[0].level, 4);
1549        assert_eq!(spans[0].title, "Skipped To H4");
1550    }
1551
1552    #[test]
1553    fn parser_handles_duplicate_siblings() {
1554        let md = "\
1555---
1556type: spec
1557---
1558# Entity
1559
1560## Identity
1561
1562Body.
1563
1564## Specifies
1565
1566### Same Title
1567
1568First occurrence body.
1569
1570### Same Title
1571
1572Second occurrence body.
1573";
1574        let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1575        let spans = result.entity.heading_spans.get("specifies").unwrap();
1576        assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1577        assert_eq!(spans[0].title, spans[1].title);
1578        assert_ne!(
1579            spans[0].start_offset, spans[1].start_offset,
1580            "spans with identical titles must be distinguishable by offset"
1581        );
1582        // Siblings at the same level: neither contains the other.
1583        assert!(
1584            spans[0].end_offset <= spans[1].start_offset,
1585            "first sibling must close before the second starts"
1586        );
1587    }
1588
1589    // Duplicate `## Heading` lines for a schema-declared key collapse to the
1590    // first occurrence's body and emit a `DuplicateSectionHeading` warning.
1591    // Catch-all keys absorb arbitrary headings by design and do not warn.
1592
1593    #[test]
1594    fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1595        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1596        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1597        assert_eq!(
1598            result.entity.sections.get("identity").map(String::as_str),
1599            Some("first body"),
1600            "first body must win"
1601        );
1602        assert!(
1603            !result
1604                .entity
1605                .sections
1606                .get("identity")
1607                .unwrap()
1608                .contains("## Identity"),
1609            "storage value must not embed a duplicate heading"
1610        );
1611        assert_eq!(result.parse_warnings.len(), 1);
1612        match &result.parse_warnings[0] {
1613            crate::ops::WarningHint::DuplicateSectionHeading {
1614                section_key,
1615                heading,
1616                occurrences,
1617                ..
1618            } => {
1619                assert_eq!(section_key, "identity");
1620                assert_eq!(heading, "Identity");
1621                assert_eq!(*occurrences, 2);
1622            }
1623            other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1624        }
1625    }
1626
1627    #[test]
1628    fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1629        // First-wins is mechanical: a blank first occurrence wins over a
1630        // populated second one. The warning surfaces so the operator
1631        // notices content was discarded.
1632        let md =
1633            "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1634        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1635        assert_eq!(
1636            result.entity.sections.get("identity").map(String::as_str),
1637            Some(""),
1638            "first (blank) occurrence wins; second body is dropped"
1639        );
1640        assert_eq!(result.parse_warnings.len(), 1);
1641    }
1642
1643    #[test]
1644    fn duplicate_declared_heading_three_occurrences() {
1645        let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
1646        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1647        assert_eq!(
1648            result
1649                .entity
1650                .sections
1651                .get("constraints")
1652                .map(String::as_str),
1653            Some("A"),
1654        );
1655        assert_eq!(result.parse_warnings.len(), 1);
1656        match &result.parse_warnings[0] {
1657            crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
1658                assert_eq!(*occurrences, 3);
1659            }
1660            _ => unreachable!(),
1661        }
1662    }
1663
1664    #[test]
1665    fn no_warning_when_each_declared_section_appears_once() {
1666        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
1667        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1668        assert!(result.parse_warnings.is_empty());
1669    }
1670
1671    #[test]
1672    fn no_warning_when_catch_all_section_repeats() {
1673        // `specifies` is the spec schema's catch-all section. Repetition
1674        // there is silent — duplicates only warn for non-catch-all keys.
1675        let md =
1676            "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
1677        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1678        assert!(
1679            result.parse_warnings.is_empty(),
1680            "catch-all repetition must not warn"
1681        );
1682    }
1683
1684    // Three `## Realization` headings on a spec entity. The default-schema
1685    // `spec` does not declare `realization`, so it flows to the catch-all
1686    // `specifies` bucket and emits no warning, but the storage must still
1687    // not concatenate duplicate heading bytes — that was the bug being
1688    // fixed. Workspaces that declare `realization` (e.g. `software@0.1.0`)
1689    // additionally surface a `DuplicateSectionHeading` warning.
1690    #[test]
1691    fn duplicate_realization_does_not_concatenate_headers_in_storage() {
1692        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Realization\n\n- a.mjs\n- b.mjs\n\n## Realization\n\n## Realization\n\n- c.mjs\n\n## Constraints\n\nC\n";
1693        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1694        let catch_all = result.entity.sections.get("specifies").unwrap();
1695        let header_count = catch_all.matches("## Realization").count();
1696        assert!(
1697            header_count <= 1,
1698            "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
1699        );
1700    }
1701
1702    // After a parse → render round-trip, an entity that was loaded from a
1703    // markdown file with three `## Identity` headings emits exactly one
1704    // `## Identity` heading on re-render. This is the self-heal contract:
1705    // the next read-modify-write of a duplicate-heading entity collapses
1706    // the markdown to one heading per declared section.
1707    #[test]
1708    fn parse_render_round_trip_collapses_duplicate_headings() {
1709        let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nA\n\n## Identity\n\n## Identity\n\nC\n\n## Purpose\n\nP\n";
1710        let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1711        let rendered = crate::render::render_entity_markdown(&result.entity, None);
1712        let identity_count = rendered.matches("## Identity").count();
1713        assert_eq!(
1714            identity_count, 1,
1715            "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
1716        );
1717        // First-wins: the rendered Identity body is `A`, not `C`.
1718        assert!(rendered.contains("\n## Identity\n\nA\n"));
1719        assert!(!rendered.contains("C\n"), "second body must not survive");
1720    }
1721}