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