1use 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
18pub 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
45fn 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 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 for field in &schema.metadata_fields {
116 let is_required = field.is_required();
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 crate::runtime_validator::is_date_shaped(s)
159 }
160 (MetadataValue::String(_), FieldType::String) => true,
161 (MetadataValue::String(_), _) => false,
164 _ => false,
165 }
166}
167
168fn 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 relationships_format_def() -> &'static memstead_schema::SectionDef {
255 static DEF: OnceLock<memstead_schema::SectionDef> = OnceLock::new();
256 DEF.get_or_init(|| {
257 let content = "list(bullet)?";
258 memstead_schema::SectionDef {
259 key: "relationships".to_string(),
260 heading: "Relationships".to_string(),
261 required: false,
262 search_weight: 0.0,
263 catch_all: false,
264 write_rules: vec![],
265 description: None,
266 content: Some(content.to_string()),
267 item_pattern: Some(r"\*\*[A-Z_]+\*\*:\s*\[\[[^\]]+\]\](\s*—.*)?".to_string()),
268 table: None,
269 example: Some("- **USES**: [[target-name]]".to_string()),
270 format_severity: memstead_schema::ConstraintSeverity::Block,
271 compiled_content: Some(
272 memstead_schema::content_expr::ContentExpr::parse(content)
273 .expect("engine-side declaration is valid"),
274 ),
275 format_problems: Vec::new(),
276 }
277 })
278}
279
280fn check_relationships_syntax(body: &str, path: &str) -> Result<(), ValidationError> {
281 let masked = mask_code_blocks(body);
285 let mut section = String::new();
286 let mut in_rel = false;
287 for line in masked.lines() {
288 if line.starts_with("## ") {
289 in_rel = line.strip_prefix("## ").map(str::trim) == Some("Relationships");
290 continue;
291 }
292 if in_rel {
293 section.push_str(line);
294 section.push('\n');
295 }
296 }
297 if section.trim().is_empty() {
298 return Ok(());
299 }
300 if let Some(v) =
301 crate::section_format::check_section_format(relationships_format_def(), §ion)
302 .into_iter()
303 .next()
304 {
305 let line = match &v {
306 crate::section_format::SectionFormatViolation::ItemPatternMismatch { text, .. } => {
307 text.clone()
308 }
309 other => other.describe(),
310 };
311 return Err(ValidationError::InvalidRelationshipLine {
312 path: path.to_string(),
313 line,
314 });
315 }
316 Ok(())
317}
318
319fn check_relationship_types(entity: &Entity, path: &str) -> Result<(), ValidationError> {
320 for rel in &entity.relationships {
321 if !rel_type_regex().is_match(&rel.rel_type) {
322 return Err(ValidationError::InvalidRelationshipType {
323 path: path.to_string(),
324 rel_type: rel.rel_type.clone(),
325 });
326 }
327 }
328 Ok(())
329}
330
331fn rel_type_regex() -> &'static Regex {
332 static RE: OnceLock<Regex> = OnceLock::new();
333 RE.get_or_init(|| Regex::new(r"^[A-Z_]+$").unwrap())
334}
335
336fn check_wiki_links(body: &str, path: &str) -> Result<(), ValidationError> {
351 let fenced_masked = mask_code_blocks(body);
352 let after_double = inline_double_backtick_regex()
353 .replace_all(&fenced_masked, "")
354 .to_string();
355 let masked = inline_code_regex()
356 .replace_all(&after_double, "")
357 .to_string();
358
359 check_bracket_balance(&masked, path)?;
360
361 let link_re = wiki_link_regex();
362 for cap in link_re.captures_iter(&masked) {
363 let inner = &cap[1];
364 if inner.is_empty() {
370 return Err(ValidationError::InvalidWikiLink {
371 path: path.to_string(),
372 link: format!("[[{inner}]]"),
373 reason: "empty target".to_string(),
374 });
375 }
376 if inner.contains("::") {
377 return Err(ValidationError::InvalidWikiLink {
378 path: path.to_string(),
379 link: format!("[[{inner}]]"),
380 reason: "reserved `::` cross-mem syntax is not accepted".to_string(),
381 });
382 }
383 let target = match inner.find('|') {
384 Some(i) => &inner[..i],
385 None => inner,
386 };
387 if target.contains('#') {
388 return Err(ValidationError::InvalidWikiLink {
389 path: path.to_string(),
390 link: format!("[[{inner}]]"),
391 reason: "reserved `#` deep-link syntax is not accepted".to_string(),
392 });
393 }
394
395 if let Err(e) = wiki_link_to_id(inner, "") {
401 return Err(ValidationError::InvalidWikiLink {
402 path: path.to_string(),
403 link: format!("[[{inner}]]"),
404 reason: e.to_string(),
405 });
406 }
407 }
408 Ok(())
409}
410
411fn check_bracket_balance(masked: &str, path: &str) -> Result<(), ValidationError> {
412 let bytes = masked.as_bytes();
413 let mut i = 0;
414 let mut open = 0usize;
415 while i + 1 < bytes.len() {
416 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
417 if open > 0 {
418 return Err(ValidationError::UnbalancedBrackets {
419 path: path.to_string(),
420 });
421 }
422 open += 1;
423 i += 2;
424 continue;
425 }
426 if bytes[i] == b']' && bytes[i + 1] == b']' {
427 if open == 0 {
428 return Err(ValidationError::UnbalancedBrackets {
429 path: path.to_string(),
430 });
431 }
432 open -= 1;
433 i += 2;
434 continue;
435 }
436 i += 1;
437 }
438 if open > 0 {
439 return Err(ValidationError::UnbalancedBrackets {
440 path: path.to_string(),
441 });
442 }
443 Ok(())
444}
445
446fn wiki_link_regex() -> &'static Regex {
447 static RE: OnceLock<Regex> = OnceLock::new();
448 RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
449}
450
451fn inline_code_regex() -> &'static Regex {
452 static RE: OnceLock<Regex> = OnceLock::new();
453 RE.get_or_init(|| Regex::new(r"`[^`]+`").unwrap())
454}
455
456fn inline_double_backtick_regex() -> &'static Regex {
457 static RE: OnceLock<Regex> = OnceLock::new();
458 RE.get_or_init(|| Regex::new(r"``[\s\S]*?``").unwrap())
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::entity::parser::parse_markdown;
465 use memstead_schema::type_by_name;
466
467 fn spec_type() -> std::sync::Arc<memstead_schema::TypeDefinition> {
468 type_by_name("spec").unwrap()
469 }
470
471 fn parse(content: &str) -> Entity {
472 parse_markdown(content, "test.md", &spec_type(), "v")
473 .unwrap()
474 .entity
475 }
476
477 fn validate(content: &str, entity: &Entity) -> Result<(), ValidationError> {
478 validate_strict(content, entity, &spec_type(), "test.md")
479 }
480
481 const MINIMAL_SPEC: &str = "\
482---
483type: spec
484created_date: 2026-01-15
485last_modified: 2026-01-15
486level: M0
487---
488# Test Entity
489
490## Identity
491
492A meaningful identity line.
493
494## Purpose
495
496Why it exists.
497
498## Specifies
499
500What it covers.
501
502## Constraints
503
504Its limits.
505
506## Rationale
507
508Design notes.
509";
510
511 #[test]
512 fn accepts_valid_spec() {
513 let entity = parse(MINIMAL_SPEC);
514 validate(MINIMAL_SPEC, &entity).unwrap();
515 }
516
517 #[test]
518 fn rejects_missing_frontmatter() {
519 let content = "# No Frontmatter\n\n## Identity\nBody.\n";
520 let entity = parse(&format!("---\ntype: spec\n---\n{content}"));
521 let err = validate(content, &entity).unwrap_err();
522 assert!(matches!(err, ValidationError::MissingFrontmatter { .. }));
523 }
524
525 #[test]
526 fn rejects_unclosed_frontmatter() {
527 let content = "---\ntype: spec\n# stuck in frontmatter\n";
528 let entity = parse(MINIMAL_SPEC); let err = validate(content, &entity).unwrap_err();
530 assert!(matches!(err, ValidationError::InvalidFrontmatter { .. }));
531 }
532
533 #[test]
534 fn rejects_unknown_frontmatter_key() {
535 let content = MINIMAL_SPEC.replacen("level: M0", "level: M0\nunexpected_key: oops", 1);
536 let entity = parse(&content);
537 let err = validate(&content, &entity).unwrap_err();
538 match err {
539 ValidationError::UnknownFrontmatterKey { key, .. } => {
540 assert_eq!(key, "unexpected_key");
541 }
542 other => panic!("expected UnknownFrontmatterKey, got {other:?}"),
543 }
544 }
545
546 #[test]
547 fn rejects_missing_required_field() {
548 let content = MINIMAL_SPEC.replacen("level: M0\n", "", 1);
549 let entity = parse(&content);
550 let err = validate(&content, &entity).unwrap_err();
551 assert!(matches!(err, ValidationError::MissingRequiredField { .. }));
552 }
553
554 #[test]
555 fn rejects_missing_title() {
556 let content = MINIMAL_SPEC.replacen("# Test Entity\n", "\n", 1);
557 let entity = parse(&content);
558 let err = validate(&content, &entity).unwrap_err();
559 assert!(matches!(err, ValidationError::MissingTitle { .. }));
560 }
561
562 #[test]
563 fn rejects_missing_required_section() {
564 let content = MINIMAL_SPEC.replacen("## Purpose\n\nWhy it exists.\n\n", "", 1);
565 let entity = parse(&content);
566 let err = validate(&content, &entity).unwrap_err();
567 assert!(matches!(
568 err,
569 ValidationError::MissingRequiredSection { .. }
570 ));
571 }
572
573 #[test]
581 fn rejects_malformed_relationship_line() {
582 let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- USES: [[target]]\n");
583 let entity = parse(&content);
584 let err = validate(&content, &entity).unwrap_err();
585 assert!(matches!(
586 err,
587 ValidationError::InvalidRelationshipLine { .. }
588 ));
589 }
590
591 #[test]
592 fn accepts_valid_relationship_line() {
593 let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- **USES**: [[target-name]]\n");
594 let entity = parse(&content);
595 validate(&content, &entity).unwrap();
596 }
597
598 #[test]
599 fn rejects_invalid_wiki_link_uppercase() {
600 let content = format!("{MINIMAL_SPEC}\nSee [[MyThing]] for details.\n");
601 let entity = parse(&content);
602 let err = validate(&content, &entity).unwrap_err();
603 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
604 }
605
606 #[test]
607 fn rejects_invalid_wiki_link_underscore() {
608 let content = format!("{MINIMAL_SPEC}\nSee [[a_b]] for details.\n");
609 let entity = parse(&content);
610 let err = validate(&content, &entity).unwrap_err();
611 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
612 }
613
614 #[test]
615 fn rejects_invalid_wiki_link_space() {
616 let content = format!("{MINIMAL_SPEC}\nSee [[a b]] for details.\n");
617 let entity = parse(&content);
618 let err = validate(&content, &entity).unwrap_err();
619 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
620 }
621
622 #[test]
623 fn accepts_tier_two_cross_mem_link() {
624 let content = format!(
625 "{MINIMAL_SPEC}\nSee [[engine:health]] and [[engine:architecture/result]] for more.\n"
626 );
627 let entity = parse(&content);
628 validate(&content, &entity).unwrap();
629 }
630
631 #[test]
636 fn accepts_hierarchical_tier_two_link() {
637 let content = format!("{MINIMAL_SPEC}\nSee [[external/engine:health]] for details.\n");
638 let entity = parse(&content);
639 validate(&content, &entity).unwrap();
640 }
641
642 #[test]
643 fn rejects_tier_two_with_empty_leaf() {
644 let content = format!("{MINIMAL_SPEC}\nSee [[:slug]] for details.\n");
645 let entity = parse(&content);
646 let err = validate(&content, &entity).unwrap_err();
647 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
648 }
649
650 #[test]
651 fn rejects_tier_two_with_empty_slug() {
652 let content = format!("{MINIMAL_SPEC}\nSee [[engine:]] for details.\n");
653 let entity = parse(&content);
654 let err = validate(&content, &entity).unwrap_err();
655 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
656 }
657
658 #[test]
659 fn rejects_tier_two_with_invalid_leaf_chars() {
660 let content = format!("{MINIMAL_SPEC}\nSee [[Engine:slug]] for details.\n");
661 let entity = parse(&content);
662 let err = validate(&content, &entity).unwrap_err();
663 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
664 }
665
666 #[test]
667 fn rejects_tier_two_with_invalid_slug_chars() {
668 let content = format!("{MINIMAL_SPEC}\nSee [[engine:Slug]] for details.\n");
669 let entity = parse(&content);
670 let err = validate(&content, &entity).unwrap_err();
671 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
672 }
673
674 #[test]
675 fn rejects_reserved_cross_mem_syntax() {
676 let content = format!("{MINIMAL_SPEC}\nSee [[other-mem::entity]] for details.\n");
677 let entity = parse(&content);
678 let err = validate(&content, &entity).unwrap_err();
679 match err {
680 ValidationError::InvalidWikiLink { reason, .. } => {
681 assert!(reason.contains("::"), "reason={reason}");
682 }
683 other => panic!("expected InvalidWikiLink, got {other:?}"),
684 }
685 }
686
687 #[test]
688 fn rejects_reserved_deep_link_syntax() {
689 let content = format!("{MINIMAL_SPEC}\nSee [[entity#section]]");
690 let entity = parse(&content);
691 let err = validate(&content, &entity).unwrap_err();
692 match err {
693 ValidationError::InvalidWikiLink { reason, .. } => {
694 assert!(reason.contains("#"), "reason={reason}");
695 }
696 other => panic!("expected InvalidWikiLink, got {other:?}"),
697 }
698 }
699
700 #[test]
701 fn rejects_empty_wiki_link() {
702 let content = format!("{MINIMAL_SPEC}\nSee [[]]");
703 let entity = parse(&content);
704 let err = validate(&content, &entity).unwrap_err();
705 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
706 }
707
708 #[test]
709 fn rejects_unbalanced_brackets() {
710 let content = format!("{MINIMAL_SPEC}\nSee [[unterminated for details.\n");
711 let entity = parse(&content);
712 let err = validate(&content, &entity).unwrap_err();
713 assert!(matches!(err, ValidationError::UnbalancedBrackets { .. }));
714 }
715
716 #[test]
717 fn accepts_valid_stub_wiki_link() {
718 let content = format!("{MINIMAL_SPEC}\nSee [[planned-feature]] and [[a/b/c]] for more.\n");
719 let entity = parse(&content);
720 validate(&content, &entity).unwrap();
721 }
722
723 #[test]
724 fn accepts_wiki_link_inside_inline_code() {
725 let content =
726 format!("{MINIMAL_SPEC}\nOne line per edge, shape `- **<REL>**: [[<target>]]`.\n");
727 let entity = parse(&content);
728 validate(&content, &entity).unwrap();
729 }
730
731 #[test]
732 fn accepts_literal_backtick_via_double_delimiter() {
733 let content = format!(
740 "{MINIMAL_SPEC}\nWalks left-to-right looking for the earliest of `**`, `` ` ``, `[[`. Done.\n"
741 );
742 let entity = parse(&content);
743 validate(&content, &entity).unwrap();
744 }
745
746 #[test]
747 fn accepts_brackets_inside_double_backtick_span() {
748 let content = format!("{MINIMAL_SPEC}\n| Inline code | `` `[[slug]]` `` | note. |\n");
752 let entity = parse(&content);
753 validate(&content, &entity).unwrap();
754 }
755
756 #[test]
757 fn accepts_wiki_link_with_alias() {
758 let content = format!("{MINIMAL_SPEC}\nSee [[target|Display Text]] for more.\n");
759 let entity = parse(&content);
760 validate(&content, &entity).unwrap();
761 }
762
763 #[test]
764 fn accepts_wiki_link_with_parent_relative_and_md() {
765 let content = format!("{MINIMAL_SPEC}\nSee [[../parent/entity.md]] for more.\n");
766 let entity = parse(&content);
767 validate(&content, &entity).unwrap();
768 }
769
770 #[test]
771 fn accepts_wiki_link_inside_code_block() {
772 let content = format!("{MINIMAL_SPEC}\n```\nlet x = [[this is not a link]];\n```\n");
773 let entity = parse(&content);
774 validate(&content, &entity).unwrap();
775 }
776
777 #[test]
778 fn accepts_windows_line_endings() {
779 let content = MINIMAL_SPEC.replace('\n', "\r\n");
780 let entity = parse(&content);
781 validate(&content, &entity).unwrap();
782 }
783
784 #[test]
785 fn accepts_leading_bom() {
786 let raw = format!("\u{feff}{MINIMAL_SPEC}");
790 let stripped = raw.strip_prefix('\u{feff}').unwrap();
791 let entity = parse(stripped);
792 validate(&raw, &entity).unwrap();
793 }
794}