Skip to main content

memstead_base/validator/
strict.rs

1//! Per-entity strict checks over raw markdown bytes.
2//!
3//! Complements the tolerant `entity::parser::parse_markdown` — every
4//! invariant the tolerant parser papers over (missing title, missing
5//! frontmatter, unknown keys, unbalanced brackets) is checked here
6//! against the raw bytes before the archive is accepted.
7
8use std::sync::OnceLock;
9
10use memstead_schema::{FieldType, TypeDefinition};
11use regex::Regex;
12
13use super::ValidationError;
14use crate::entity::id::wiki_link_to_id;
15use crate::entity::parser::mask_code_blocks;
16use crate::entity::{Entity, MetadataValue};
17
18/// Run every strict check against one entity. `raw_bytes` is the
19/// archive's markdown bytes for this entity (pre-canonicalization, so
20/// line endings may still be CRLF and a BOM may still be leading).
21pub fn validate_strict(
22    raw_bytes: &str,
23    entity: &Entity,
24    schema: &TypeDefinition,
25    path: &str,
26) -> Result<(), ValidationError> {
27    let raw = strip_bom(raw_bytes);
28
29    let (meta_block, body) = split_frontmatter_strict(raw, path)?;
30    check_metadata(meta_block, entity, schema, path)?;
31    check_title_presence(body, path)?;
32    check_sections_present(entity, schema, path)?;
33    check_unknown_sections(body, schema, path)?;
34    check_relationships_syntax(body, path)?;
35    check_relationship_types(entity, path)?;
36    check_wiki_links(body, path)?;
37
38    Ok(())
39}
40
41fn strip_bom(s: &str) -> &str {
42    s.strip_prefix('\u{feff}').unwrap_or(s)
43}
44
45/// Verify the frontmatter opens with `---` on the first line and
46/// closes with `\n---`. Returns (metadata block text, body text).
47/// Body excludes the closing `\n---` line and the newline after it.
48fn split_frontmatter_strict<'a>(
49    raw: &'a str,
50    path: &str,
51) -> Result<(&'a str, &'a str), ValidationError> {
52    let after_open_len = if raw.starts_with("---\n") {
53        4
54    } else if raw.starts_with("---\r\n") {
55        5
56    } else {
57        return Err(ValidationError::MissingFrontmatter {
58            path: path.to_string(),
59        });
60    };
61
62    let after_open = &raw[after_open_len..];
63    let close_pos =
64        after_open
65            .find("\n---")
66            .ok_or_else(|| ValidationError::InvalidFrontmatter {
67                path: path.to_string(),
68                reason: "frontmatter block is not closed with `\\n---`".to_string(),
69            })?;
70    let meta_block = &after_open[..close_pos];
71
72    let body_start_rel = close_pos + "\n---".len();
73    let body_rest = &after_open[body_start_rel..];
74    let body = body_rest
75        .strip_prefix("\r\n")
76        .or_else(|| body_rest.strip_prefix('\n'))
77        .unwrap_or(body_rest);
78
79    Ok((meta_block, body))
80}
81
82fn check_metadata(
83    meta_block: &str,
84    entity: &Entity,
85    schema: &TypeDefinition,
86    path: &str,
87) -> Result<(), ValidationError> {
88    // 1. Unknown keys against the raw YAML (the tolerant parser accepts
89    //    them but strict ingress rejects anything not declared by the
90    //    type. `type:` itself is injected by the schema definition via
91    //    `meta_type()`, so it's already in `metadata_fields`.
92    let known_keys: Vec<&str> = schema
93        .metadata_fields
94        .iter()
95        .map(|f| f.key.as_str())
96        .collect();
97    for line in meta_block.lines() {
98        let trimmed = line.trim();
99        if trimmed.is_empty() || trimmed.starts_with('#') {
100            continue;
101        }
102        let Some(colon) = trimmed.find(':') else {
103            continue;
104        };
105        let key = trimmed[..colon].trim();
106        if !known_keys.contains(&key) {
107            return Err(ValidationError::UnknownFrontmatterKey {
108                path: path.to_string(),
109                key: key.to_string(),
110            });
111        }
112    }
113
114    // 2. Required fields present, 3. types match, 4. enum violations.
115    for field in &schema.metadata_fields {
116        let is_required = !field.optional;
117        let value = entity.metadata.get(field.key.as_str());
118        match (is_required, value) {
119            (true, None) => {
120                return Err(ValidationError::MissingRequiredField {
121                    path: path.to_string(),
122                    field: field.key.to_string(),
123                });
124            }
125            (_, Some(v)) => {
126                if !value_matches_type(v, field.field_type) {
127                    return Err(ValidationError::FieldTypeMismatch {
128                        path: path.to_string(),
129                        field: field.key.to_string(),
130                        expected: format!("{:?}", field.field_type),
131                    });
132                }
133                if let Some(ref allowed) = field.enum_values {
134                    let got = v.to_frontmatter_string();
135                    if !allowed.iter().any(|a| a == &got) {
136                        return Err(ValidationError::EnumViolation {
137                            path: path.to_string(),
138                            field: field.key.to_string(),
139                            got,
140                        });
141                    }
142                }
143            }
144            (false, None) => {}
145        }
146    }
147    Ok(())
148}
149
150fn value_matches_type(value: &MetadataValue, expected: FieldType) -> bool {
151    match (value, expected) {
152        (MetadataValue::Bool(_), FieldType::Boolean) => true,
153        (MetadataValue::Integer(_) | MetadataValue::Float(_), FieldType::Number) => true,
154        (MetadataValue::String(s), FieldType::Date) => {
155            // YYYY-MM-DD or the ISO-8601 datetime form `YYYY-MM-DDTHH:MM:SSZ`.
156            // Shared with the CRUD write path so import-ingress and
157            // create/update accept exactly the same date values.
158            crate::runtime_validator::is_date_shaped(s)
159        }
160        (MetadataValue::String(_), FieldType::String) => true,
161        // CSV array fields have `field_type: String` in the schema but
162        // arrive as strings regardless — accept.
163        (MetadataValue::String(_), _) => false,
164        _ => false,
165    }
166}
167
168/// Check that `# Title` appears on one of the first three non-empty
169/// lines of the body. The tolerant parser falls back to the filename
170/// slug when no `# ` heading is found — the point of this check is to
171/// make that fallback unreachable at ingress.
172fn check_title_presence(body: &str, path: &str) -> Result<(), ValidationError> {
173    let mut lines_seen = 0;
174    for line in body.lines() {
175        if line.trim().is_empty() {
176            continue;
177        }
178        lines_seen += 1;
179        if lines_seen > 3 {
180            break;
181        }
182        if let Some(rest) = line.strip_prefix("# ")
183            && !rest.trim().is_empty()
184        {
185            return Ok(());
186        }
187    }
188    Err(ValidationError::MissingTitle {
189        path: path.to_string(),
190    })
191}
192
193fn check_sections_present(
194    entity: &Entity,
195    schema: &TypeDefinition,
196    path: &str,
197) -> Result<(), ValidationError> {
198    for section in &schema.sections {
199        if !section.required || section.catch_all {
200            continue;
201        }
202        let present = entity
203            .sections
204            .get(section.key.as_str())
205            .is_some_and(|v| !v.trim().is_empty());
206        if !present {
207            return Err(ValidationError::MissingRequiredSection {
208                path: path.to_string(),
209                section: section.heading.clone(),
210            });
211        }
212    }
213    Ok(())
214}
215
216fn check_unknown_sections(
217    body: &str,
218    schema: &TypeDefinition,
219    path: &str,
220) -> Result<(), ValidationError> {
221    if schema.sections.iter().any(|s| s.catch_all) {
222        return Ok(());
223    }
224    let known_headings: Vec<&str> = schema
225        .sections
226        .iter()
227        .map(|s| s.heading.as_str())
228        .chain(std::iter::once("Relationships"))
229        .collect();
230
231    let masked = mask_code_blocks(body);
232    for line in masked.lines() {
233        if let Some(rest) = line.strip_prefix("## ") {
234            let heading = rest.trim();
235            if !known_headings.contains(&heading) {
236                return Err(ValidationError::UnknownSection {
237                    path: path.to_string(),
238                    section: heading.to_string(),
239                });
240            }
241        }
242    }
243    Ok(())
244}
245
246fn check_relationships_syntax(body: &str, path: &str) -> Result<(), ValidationError> {
247    let masked = mask_code_blocks(body);
248    let mut in_rel = false;
249    for line in masked.lines() {
250        if line.starts_with("## ") {
251            in_rel = line.strip_prefix("## ").map(str::trim) == Some("Relationships");
252            continue;
253        }
254        if !in_rel {
255            continue;
256        }
257        let trimmed = line.trim();
258        if trimmed.is_empty() || !trimmed.starts_with('-') {
259            continue;
260        }
261        if !relationship_line_regex().is_match(trimmed) {
262            return Err(ValidationError::InvalidRelationshipLine {
263                path: path.to_string(),
264                line: trimmed.to_string(),
265            });
266        }
267    }
268    Ok(())
269}
270
271fn relationship_line_regex() -> &'static Regex {
272    static RE: OnceLock<Regex> = OnceLock::new();
273    RE.get_or_init(|| Regex::new(r"^-\s+\*\*[A-Z_]+\*\*:\s*\[\[[^\]]+\]\](\s*—.*)?$").unwrap())
274}
275
276fn check_relationship_types(entity: &Entity, path: &str) -> Result<(), ValidationError> {
277    for rel in &entity.relationships {
278        if !rel_type_regex().is_match(&rel.rel_type) {
279            return Err(ValidationError::InvalidRelationshipType {
280                path: path.to_string(),
281                rel_type: rel.rel_type.clone(),
282            });
283        }
284    }
285    Ok(())
286}
287
288fn rel_type_regex() -> &'static Regex {
289    static RE: OnceLock<Regex> = OnceLock::new();
290    RE.get_or_init(|| Regex::new(r"^[A-Z_]+$").unwrap())
291}
292
293/// Bracket-balance + slug-regex + reserved-syntax check for every
294/// wiki-link in the body. Operates on the code-block-masked body so
295/// fenced code sections are ignored. Inline `` `…` `` spans are also
296/// stripped so grammar examples like `` `[[<target>]]` `` in prose don't
297/// trip the slug-regex check — mirrors what `entity::parser::extract_inline_links`
298/// already does on the extraction side.
299///
300/// Two inline passes, in order: first the CommonMark double-backtick
301/// delimiter (``` `` ` `` ``` — used to display a literal backtick, or
302/// to wrap content that itself contains a single backtick), then plain
303/// single-backtick spans. Running them in that order prevents the
304/// single-backtick regex from slicing into the middle of a double-
305/// backtick span and leaving stray `` ` `` / `[[` remnants that would
306/// then fool the bracket checker.
307fn check_wiki_links(body: &str, path: &str) -> Result<(), ValidationError> {
308    let fenced_masked = mask_code_blocks(body);
309    let after_double = inline_double_backtick_regex()
310        .replace_all(&fenced_masked, "")
311        .to_string();
312    let masked = inline_code_regex()
313        .replace_all(&after_double, "")
314        .to_string();
315
316    check_bracket_balance(&masked, path)?;
317
318    let link_re = wiki_link_regex();
319    for cap in link_re.captures_iter(&masked) {
320        let inner = &cap[1];
321        // Structural refusals fire first so their message stays
322        // specific. Slug-form grammar then routes through the same
323        // `wiki_link_to_id` that the create/update mutation pipeline
324        // calls — install-path and create-path refuse the same inputs
325        // by construction.
326        if inner.is_empty() {
327            return Err(ValidationError::InvalidWikiLink {
328                path: path.to_string(),
329                link: format!("[[{inner}]]"),
330                reason: "empty target".to_string(),
331            });
332        }
333        if inner.contains("::") {
334            return Err(ValidationError::InvalidWikiLink {
335                path: path.to_string(),
336                link: format!("[[{inner}]]"),
337                reason: "reserved `::` cross-mem syntax is not accepted".to_string(),
338            });
339        }
340        let target = match inner.find('|') {
341            Some(i) => &inner[..i],
342            None => inner,
343        };
344        if target.contains('#') {
345            return Err(ValidationError::InvalidWikiLink {
346                path: path.to_string(),
347                link: format!("[[{inner}]]"),
348                reason: "reserved `#` deep-link syntax is not accepted".to_string(),
349            });
350        }
351
352        // Delegate the slug / mem grammar checks to the shared
353        // strict resolver. The validator passes an empty current
354        // mem — the strict resolver's self-prefix-strip step is
355        // skipped (it's a Tier-1 convenience; the grammar gate fires
356        // before it), so the grammar outcome is mem-independent.
357        if let Err(e) = wiki_link_to_id(inner, "") {
358            return Err(ValidationError::InvalidWikiLink {
359                path: path.to_string(),
360                link: format!("[[{inner}]]"),
361                reason: e.to_string(),
362            });
363        }
364    }
365    Ok(())
366}
367
368fn check_bracket_balance(masked: &str, path: &str) -> Result<(), ValidationError> {
369    let bytes = masked.as_bytes();
370    let mut i = 0;
371    let mut open = 0usize;
372    while i + 1 < bytes.len() {
373        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
374            if open > 0 {
375                return Err(ValidationError::UnbalancedBrackets {
376                    path: path.to_string(),
377                });
378            }
379            open += 1;
380            i += 2;
381            continue;
382        }
383        if bytes[i] == b']' && bytes[i + 1] == b']' {
384            if open == 0 {
385                return Err(ValidationError::UnbalancedBrackets {
386                    path: path.to_string(),
387                });
388            }
389            open -= 1;
390            i += 2;
391            continue;
392        }
393        i += 1;
394    }
395    if open > 0 {
396        return Err(ValidationError::UnbalancedBrackets {
397            path: path.to_string(),
398        });
399    }
400    Ok(())
401}
402
403fn wiki_link_regex() -> &'static Regex {
404    static RE: OnceLock<Regex> = OnceLock::new();
405    RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
406}
407
408fn inline_code_regex() -> &'static Regex {
409    static RE: OnceLock<Regex> = OnceLock::new();
410    RE.get_or_init(|| Regex::new(r"`[^`]+`").unwrap())
411}
412
413fn inline_double_backtick_regex() -> &'static Regex {
414    static RE: OnceLock<Regex> = OnceLock::new();
415    RE.get_or_init(|| Regex::new(r"``[\s\S]*?``").unwrap())
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::entity::parser::parse_markdown;
422    use memstead_schema::type_by_name;
423
424    fn spec_type() -> std::sync::Arc<memstead_schema::TypeDefinition> {
425        type_by_name("spec").unwrap()
426    }
427
428    fn parse(content: &str) -> Entity {
429        parse_markdown(content, "test.md", &spec_type(), "v")
430            .unwrap()
431            .entity
432    }
433
434    fn validate(content: &str, entity: &Entity) -> Result<(), ValidationError> {
435        validate_strict(content, entity, &spec_type(), "test.md")
436    }
437
438    const MINIMAL_SPEC: &str = "\
439---
440type: spec
441created_date: 2026-01-15
442last_modified: 2026-01-15
443level: M0
444---
445# Test Entity
446
447## Identity
448
449A meaningful identity line.
450
451## Purpose
452
453Why it exists.
454
455## Specifies
456
457What it covers.
458
459## Constraints
460
461Its limits.
462
463## Rationale
464
465Design notes.
466";
467
468    #[test]
469    fn accepts_valid_spec() {
470        let entity = parse(MINIMAL_SPEC);
471        validate(MINIMAL_SPEC, &entity).unwrap();
472    }
473
474    #[test]
475    fn rejects_missing_frontmatter() {
476        let content = "# No Frontmatter\n\n## Identity\nBody.\n";
477        let entity = parse(&format!("---\ntype: spec\n---\n{content}"));
478        let err = validate(content, &entity).unwrap_err();
479        assert!(matches!(err, ValidationError::MissingFrontmatter { .. }));
480    }
481
482    #[test]
483    fn rejects_unclosed_frontmatter() {
484        let content = "---\ntype: spec\n# stuck in frontmatter\n";
485        let entity = parse(MINIMAL_SPEC); // entity parses fine; we still reject raw
486        let err = validate(content, &entity).unwrap_err();
487        assert!(matches!(err, ValidationError::InvalidFrontmatter { .. }));
488    }
489
490    #[test]
491    fn rejects_unknown_frontmatter_key() {
492        let content = MINIMAL_SPEC.replacen("level: M0", "level: M0\nunexpected_key: oops", 1);
493        let entity = parse(&content);
494        let err = validate(&content, &entity).unwrap_err();
495        match err {
496            ValidationError::UnknownFrontmatterKey { key, .. } => {
497                assert_eq!(key, "unexpected_key");
498            }
499            other => panic!("expected UnknownFrontmatterKey, got {other:?}"),
500        }
501    }
502
503    #[test]
504    fn rejects_missing_required_field() {
505        let content = MINIMAL_SPEC.replacen("level: M0\n", "", 1);
506        let entity = parse(&content);
507        let err = validate(&content, &entity).unwrap_err();
508        assert!(matches!(err, ValidationError::MissingRequiredField { .. }));
509    }
510
511    #[test]
512    fn rejects_missing_title() {
513        let content = MINIMAL_SPEC.replacen("# Test Entity\n", "\n", 1);
514        let entity = parse(&content);
515        let err = validate(&content, &entity).unwrap_err();
516        assert!(matches!(err, ValidationError::MissingTitle { .. }));
517    }
518
519    #[test]
520    fn rejects_missing_required_section() {
521        let content = MINIMAL_SPEC.replacen("## Purpose\n\nWhy it exists.\n\n", "", 1);
522        let entity = parse(&content);
523        let err = validate(&content, &entity).unwrap_err();
524        assert!(matches!(
525            err,
526            ValidationError::MissingRequiredSection { .. }
527        ));
528    }
529
530    // Note: every shipping schema today declares exactly one catch_all
531    // section (pinned by schemas::tests::every_schema_has_exactly_one_catch_all),
532    // so `check_unknown_sections` cannot fire for the 10 registered
533    // schemas. The check stays as defense-in-depth for hypothetical
534    // future no-catch-all schemas; testing it would require a test-only
535    // TypeDefinition fixture, deferred.
536
537    #[test]
538    fn rejects_malformed_relationship_line() {
539        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- USES: [[target]]\n");
540        let entity = parse(&content);
541        let err = validate(&content, &entity).unwrap_err();
542        assert!(matches!(
543            err,
544            ValidationError::InvalidRelationshipLine { .. }
545        ));
546    }
547
548    #[test]
549    fn accepts_valid_relationship_line() {
550        let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- **USES**: [[target-name]]\n");
551        let entity = parse(&content);
552        validate(&content, &entity).unwrap();
553    }
554
555    #[test]
556    fn rejects_invalid_wiki_link_uppercase() {
557        let content = format!("{MINIMAL_SPEC}\nSee [[MyThing]] for details.\n");
558        let entity = parse(&content);
559        let err = validate(&content, &entity).unwrap_err();
560        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
561    }
562
563    #[test]
564    fn rejects_invalid_wiki_link_underscore() {
565        let content = format!("{MINIMAL_SPEC}\nSee [[a_b]] for details.\n");
566        let entity = parse(&content);
567        let err = validate(&content, &entity).unwrap_err();
568        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
569    }
570
571    #[test]
572    fn rejects_invalid_wiki_link_space() {
573        let content = format!("{MINIMAL_SPEC}\nSee [[a b]] for details.\n");
574        let entity = parse(&content);
575        let err = validate(&content, &entity).unwrap_err();
576        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
577    }
578
579    #[test]
580    fn accepts_tier_two_cross_mem_link() {
581        let content = format!(
582            "{MINIMAL_SPEC}\nSee [[engine:health]] and [[engine:architecture/result]] for more.\n"
583        );
584        let entity = parse(&content);
585        validate(&content, &entity).unwrap();
586    }
587
588    /// Hierarchical mem paths are first-class. The install-side check
589    /// converges onto `wiki_link_to_id`, which already accepts
590    /// hierarchical Tier-2 prefixes — install no longer rejects what
591    /// create produces.
592    #[test]
593    fn accepts_hierarchical_tier_two_link() {
594        let content = format!("{MINIMAL_SPEC}\nSee [[external/engine:health]] for details.\n");
595        let entity = parse(&content);
596        validate(&content, &entity).unwrap();
597    }
598
599    #[test]
600    fn rejects_tier_two_with_empty_leaf() {
601        let content = format!("{MINIMAL_SPEC}\nSee [[:slug]] for details.\n");
602        let entity = parse(&content);
603        let err = validate(&content, &entity).unwrap_err();
604        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
605    }
606
607    #[test]
608    fn rejects_tier_two_with_empty_slug() {
609        let content = format!("{MINIMAL_SPEC}\nSee [[engine:]] for details.\n");
610        let entity = parse(&content);
611        let err = validate(&content, &entity).unwrap_err();
612        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
613    }
614
615    #[test]
616    fn rejects_tier_two_with_invalid_leaf_chars() {
617        let content = format!("{MINIMAL_SPEC}\nSee [[Engine:slug]] for details.\n");
618        let entity = parse(&content);
619        let err = validate(&content, &entity).unwrap_err();
620        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
621    }
622
623    #[test]
624    fn rejects_tier_two_with_invalid_slug_chars() {
625        let content = format!("{MINIMAL_SPEC}\nSee [[engine:Slug]] for details.\n");
626        let entity = parse(&content);
627        let err = validate(&content, &entity).unwrap_err();
628        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
629    }
630
631    #[test]
632    fn rejects_reserved_cross_mem_syntax() {
633        let content = format!("{MINIMAL_SPEC}\nSee [[other-mem::entity]] for details.\n");
634        let entity = parse(&content);
635        let err = validate(&content, &entity).unwrap_err();
636        match err {
637            ValidationError::InvalidWikiLink { reason, .. } => {
638                assert!(reason.contains("::"), "reason={reason}");
639            }
640            other => panic!("expected InvalidWikiLink, got {other:?}"),
641        }
642    }
643
644    #[test]
645    fn rejects_reserved_deep_link_syntax() {
646        let content = format!("{MINIMAL_SPEC}\nSee [[entity#section]]");
647        let entity = parse(&content);
648        let err = validate(&content, &entity).unwrap_err();
649        match err {
650            ValidationError::InvalidWikiLink { reason, .. } => {
651                assert!(reason.contains("#"), "reason={reason}");
652            }
653            other => panic!("expected InvalidWikiLink, got {other:?}"),
654        }
655    }
656
657    #[test]
658    fn rejects_empty_wiki_link() {
659        let content = format!("{MINIMAL_SPEC}\nSee [[]]");
660        let entity = parse(&content);
661        let err = validate(&content, &entity).unwrap_err();
662        assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
663    }
664
665    #[test]
666    fn rejects_unbalanced_brackets() {
667        let content = format!("{MINIMAL_SPEC}\nSee [[unterminated for details.\n");
668        let entity = parse(&content);
669        let err = validate(&content, &entity).unwrap_err();
670        assert!(matches!(err, ValidationError::UnbalancedBrackets { .. }));
671    }
672
673    #[test]
674    fn accepts_valid_stub_wiki_link() {
675        let content = format!("{MINIMAL_SPEC}\nSee [[planned-feature]] and [[a/b/c]] for more.\n");
676        let entity = parse(&content);
677        validate(&content, &entity).unwrap();
678    }
679
680    #[test]
681    fn accepts_wiki_link_inside_inline_code() {
682        let content =
683            format!("{MINIMAL_SPEC}\nOne line per edge, shape `- **<REL>**: [[<target>]]`.\n");
684        let entity = parse(&content);
685        validate(&content, &entity).unwrap();
686    }
687
688    #[test]
689    fn accepts_literal_backtick_via_double_delimiter() {
690        // Real-world line from a macOS spec documenting the inline-markup
691        // tokenizer: mixes `` ` `` (double-delim showing a literal `) with
692        // `[[` inside single-delim backticks. Before the double-backtick
693        // pre-pass was added, the single-backtick regex sliced through the
694        // `` ` `` span and left a stray `[[` that tripped the bracket
695        // checker.
696        let content = format!(
697            "{MINIMAL_SPEC}\nWalks left-to-right looking for the earliest of `**`, `` ` ``, `[[`. Done.\n"
698        );
699        let entity = parse(&content);
700        validate(&content, &entity).unwrap();
701    }
702
703    #[test]
704    fn accepts_brackets_inside_double_backtick_span() {
705        // `` `text` `` — double-backtick delimiter wrapping content that
706        // itself contains single backticks. The stripped span may hold
707        // unbalanced brackets without leaking to the bracket checker.
708        let content = format!("{MINIMAL_SPEC}\n| Inline code | `` `[[slug]]` `` | note. |\n");
709        let entity = parse(&content);
710        validate(&content, &entity).unwrap();
711    }
712
713    #[test]
714    fn accepts_wiki_link_with_alias() {
715        let content = format!("{MINIMAL_SPEC}\nSee [[target|Display Text]] for more.\n");
716        let entity = parse(&content);
717        validate(&content, &entity).unwrap();
718    }
719
720    #[test]
721    fn accepts_wiki_link_with_parent_relative_and_md() {
722        let content = format!("{MINIMAL_SPEC}\nSee [[../parent/entity.md]] for more.\n");
723        let entity = parse(&content);
724        validate(&content, &entity).unwrap();
725    }
726
727    #[test]
728    fn accepts_wiki_link_inside_code_block() {
729        let content = format!("{MINIMAL_SPEC}\n```\nlet x = [[this is not a link]];\n```\n");
730        let entity = parse(&content);
731        validate(&content, &entity).unwrap();
732    }
733
734    #[test]
735    fn accepts_windows_line_endings() {
736        let content = MINIMAL_SPEC.replace('\n', "\r\n");
737        let entity = parse(&content);
738        validate(&content, &entity).unwrap();
739    }
740
741    #[test]
742    fn accepts_leading_bom() {
743        // The BOM is stripped by the archive extraction layer before
744        // parse_markdown sees the bytes — mirror that here. The strict
745        // checker also tolerates BOM on the raw pass (defense-in-depth).
746        let raw = format!("\u{feff}{MINIMAL_SPEC}");
747        let stripped = raw.strip_prefix('\u{feff}').unwrap();
748        let entity = parse(stripped);
749        validate(&raw, &entity).unwrap();
750    }
751}