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.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 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 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
293fn 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 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 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); 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 #[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 #[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 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 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 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}