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, split_sections};
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
45pub(crate) fn 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> {
178 let masked_body = mask_code_blocks(body);
179 let mut lines_seen = 0;
180 for (line, masked) in body.lines().zip(masked_body.lines()) {
181 if line.trim().is_empty() {
182 continue;
183 }
184 lines_seen += 1;
185 if lines_seen > 3 {
186 break;
187 }
188 if let Some(rest) = masked.strip_prefix("# ")
189 && !rest.trim().is_empty()
190 {
191 return Ok(());
192 }
193 }
194 Err(ValidationError::MissingTitle {
195 path: path.to_string(),
196 })
197}
198
199fn check_sections_present(
200 entity: &Entity,
201 schema: &TypeDefinition,
202 path: &str,
203) -> Result<(), ValidationError> {
204 for section in &schema.sections {
205 if !section.required || section.catch_all {
206 continue;
207 }
208 let present = entity
209 .sections
210 .get(section.key.as_str())
211 .is_some_and(|v| !v.trim().is_empty());
212 if !present {
213 return Err(ValidationError::MissingRequiredSection {
214 path: path.to_string(),
215 section: section.heading.clone(),
216 });
217 }
218 }
219 Ok(())
220}
221
222fn check_unknown_sections(
223 body: &str,
224 schema: &TypeDefinition,
225 path: &str,
226) -> Result<(), ValidationError> {
227 if schema.sections.iter().any(|s| s.catch_all) {
228 return Ok(());
229 }
230 let known_headings: Vec<&str> = schema
231 .sections
232 .iter()
233 .map(|s| s.heading.as_str())
234 .chain(std::iter::once("Relationships"))
235 .collect();
236
237 let (_, _, raw_headings) = split_sections(body, &mask_code_blocks(body));
240 for heading in &raw_headings {
241 if !known_headings.contains(&heading.as_str()) {
242 return Err(ValidationError::UnknownSection {
243 path: path.to_string(),
244 section: heading.clone(),
245 });
246 }
247 }
248 Ok(())
249}
250
251fn relationships_format_def() -> &'static memstead_schema::SectionDef {
260 static DEF: OnceLock<memstead_schema::SectionDef> = OnceLock::new();
261 DEF.get_or_init(|| {
262 let content = "list(bullet)?";
263 memstead_schema::SectionDef {
264 key: "relationships".to_string(),
265 heading: "Relationships".to_string(),
266 required: false,
267 search_weight: 0.0,
268 catch_all: false,
269 write_rules: vec![],
270 description: None,
271 content: Some(content.to_string()),
272 item_pattern: Some(r"\*\*[A-Z_]+\*\*:\s*\[\[[^\]]+\]\](\s*—.*)?".to_string()),
273 table: None,
274 example: Some("- **USES**: [[target-name]]".to_string()),
275 format_severity: memstead_schema::ConstraintSeverity::Block,
276 compiled_content: Some(
277 memstead_schema::content_expr::ContentExpr::parse(content)
278 .expect("engine-side declaration is valid"),
279 ),
280 format_problems: Vec::new(),
281 }
282 })
283}
284
285fn check_relationships_syntax(body: &str, path: &str) -> Result<(), ValidationError> {
286 let (sections, _, _) = split_sections(body, &mask_code_blocks(body));
292 let section = match sections.get("relationships") {
293 Some(s) if !s.trim().is_empty() => s.clone(),
294 _ => return Ok(()),
295 };
296 if let Some(v) =
297 crate::section_format::check_section_format(relationships_format_def(), §ion)
298 .into_iter()
299 .next()
300 {
301 let line = match &v {
302 crate::section_format::SectionFormatViolation::ItemPatternMismatch { text, .. } => {
303 text.clone()
304 }
305 other => other.describe(),
306 };
307 return Err(ValidationError::InvalidRelationshipLine {
308 path: path.to_string(),
309 line,
310 });
311 }
312 Ok(())
313}
314
315fn check_relationship_types(entity: &Entity, path: &str) -> Result<(), ValidationError> {
316 for rel in &entity.relationships {
317 if !rel_type_regex().is_match(&rel.rel_type) {
318 return Err(ValidationError::InvalidRelationshipType {
319 path: path.to_string(),
320 rel_type: rel.rel_type.clone(),
321 });
322 }
323 }
324 Ok(())
325}
326
327fn rel_type_regex() -> &'static Regex {
328 static RE: OnceLock<Regex> = OnceLock::new();
329 RE.get_or_init(|| Regex::new(r"^[A-Z_]+$").unwrap())
330}
331
332fn check_wiki_links(body: &str, path: &str) -> Result<(), ValidationError> {
346 let masked = crate::markdown::mask_code_blocks_and_spans(body);
347
348 check_bracket_balance(&masked, path)?;
349
350 let link_re = wiki_link_regex();
351 for cap in link_re.captures_iter(&masked) {
352 let inner = &cap[1];
353 if inner.is_empty() {
359 return Err(ValidationError::InvalidWikiLink {
360 path: path.to_string(),
361 link: format!("[[{inner}]]"),
362 reason: "empty target".to_string(),
363 });
364 }
365 if inner.contains("::") {
366 return Err(ValidationError::InvalidWikiLink {
367 path: path.to_string(),
368 link: format!("[[{inner}]]"),
369 reason: "reserved `::` cross-mem syntax is not accepted".to_string(),
370 });
371 }
372 let target = match inner.find('|') {
373 Some(i) => &inner[..i],
374 None => inner,
375 };
376 if target.contains('#') {
377 return Err(ValidationError::InvalidWikiLink {
378 path: path.to_string(),
379 link: format!("[[{inner}]]"),
380 reason: "reserved `#` deep-link syntax is not accepted".to_string(),
381 });
382 }
383
384 if let Err(e) = wiki_link_to_id(inner, "") {
390 return Err(ValidationError::InvalidWikiLink {
391 path: path.to_string(),
392 link: format!("[[{inner}]]"),
393 reason: e.to_string(),
394 });
395 }
396 }
397 Ok(())
398}
399
400fn check_bracket_balance(masked: &str, path: &str) -> Result<(), ValidationError> {
401 let bytes = masked.as_bytes();
402 let mut i = 0;
403 let mut open = 0usize;
404 while i + 1 < bytes.len() {
405 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
406 if open > 0 {
407 return Err(ValidationError::UnbalancedBrackets {
408 path: path.to_string(),
409 });
410 }
411 open += 1;
412 i += 2;
413 continue;
414 }
415 if bytes[i] == b']' && bytes[i + 1] == b']' {
416 if open == 0 {
417 return Err(ValidationError::UnbalancedBrackets {
418 path: path.to_string(),
419 });
420 }
421 open -= 1;
422 i += 2;
423 continue;
424 }
425 i += 1;
426 }
427 if open > 0 {
428 return Err(ValidationError::UnbalancedBrackets {
429 path: path.to_string(),
430 });
431 }
432 Ok(())
433}
434
435fn wiki_link_regex() -> &'static Regex {
436 static RE: OnceLock<Regex> = OnceLock::new();
437 RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use crate::entity::parser::parse_markdown;
444 use memstead_schema::type_by_name;
445
446 fn spec_type() -> std::sync::Arc<memstead_schema::TypeDefinition> {
447 type_by_name("spec").unwrap()
448 }
449
450 fn parse(content: &str) -> Entity {
451 parse_markdown(content, "test.md", &spec_type(), "v")
452 .unwrap()
453 .entity
454 }
455
456 fn validate(content: &str, entity: &Entity) -> Result<(), ValidationError> {
457 validate_strict(content, entity, &spec_type(), "test.md")
458 }
459
460 const MINIMAL_SPEC: &str = "\
461---
462type: spec
463created_date: 2026-01-15
464last_modified: 2026-01-15
465level: M0
466---
467# Test Entity
468
469## Identity
470
471A meaningful identity line.
472
473## Purpose
474
475Why it exists.
476
477## Specifies
478
479What it covers.
480
481## Constraints
482
483Its limits.
484
485## Rationale
486
487Design notes.
488";
489
490 #[test]
491 fn accepts_valid_spec() {
492 let entity = parse(MINIMAL_SPEC);
493 validate(MINIMAL_SPEC, &entity).unwrap();
494 }
495
496 #[test]
497 fn rejects_missing_frontmatter() {
498 let content = "# No Frontmatter\n\n## Identity\nBody.\n";
499 let entity = parse(&format!("---\ntype: spec\n---\n{content}"));
500 let err = validate(content, &entity).unwrap_err();
501 assert!(matches!(err, ValidationError::MissingFrontmatter { .. }));
502 }
503
504 #[test]
505 fn rejects_unclosed_frontmatter() {
506 let content = "---\ntype: spec\n# stuck in frontmatter\n";
507 let entity = parse(MINIMAL_SPEC); let err = validate(content, &entity).unwrap_err();
509 assert!(matches!(err, ValidationError::InvalidFrontmatter { .. }));
510 }
511
512 #[test]
513 fn rejects_unknown_frontmatter_key() {
514 let content = MINIMAL_SPEC.replacen("level: M0", "level: M0\nunexpected_key: oops", 1);
515 let entity = parse(&content);
516 let err = validate(&content, &entity).unwrap_err();
517 match err {
518 ValidationError::UnknownFrontmatterKey { key, .. } => {
519 assert_eq!(key, "unexpected_key");
520 }
521 other => panic!("expected UnknownFrontmatterKey, got {other:?}"),
522 }
523 }
524
525 #[test]
526 fn rejects_missing_required_field() {
527 let content = MINIMAL_SPEC.replacen("level: M0\n", "", 1);
528 let entity = parse(&content);
529 let err = validate(&content, &entity).unwrap_err();
530 assert!(matches!(err, ValidationError::MissingRequiredField { .. }));
531 }
532
533 #[test]
534 fn rejects_missing_title() {
535 let content = MINIMAL_SPEC.replacen("# Test Entity\n", "\n", 1);
536 let entity = parse(&content);
537 let err = validate(&content, &entity).unwrap_err();
538 assert!(matches!(err, ValidationError::MissingTitle { .. }));
539 }
540
541 #[test]
542 fn rejects_missing_required_section() {
543 let content = MINIMAL_SPEC.replacen("## Purpose\n\nWhy it exists.\n\n", "", 1);
544 let entity = parse(&content);
545 let err = validate(&content, &entity).unwrap_err();
546 assert!(matches!(
547 err,
548 ValidationError::MissingRequiredSection { .. }
549 ));
550 }
551
552 #[test]
560 fn rejects_malformed_relationship_line() {
561 let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- USES: [[target]]\n");
562 let entity = parse(&content);
563 let err = validate(&content, &entity).unwrap_err();
564 assert!(matches!(
565 err,
566 ValidationError::InvalidRelationshipLine { .. }
567 ));
568 }
569
570 #[test]
571 fn accepts_valid_relationship_line() {
572 let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- **USES**: [[target-name]]\n");
573 let entity = parse(&content);
574 validate(&content, &entity).unwrap();
575 }
576
577 #[test]
578 fn rejects_invalid_wiki_link_uppercase() {
579 let content = format!("{MINIMAL_SPEC}\nSee [[MyThing]] for details.\n");
580 let entity = parse(&content);
581 let err = validate(&content, &entity).unwrap_err();
582 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
583 }
584
585 #[test]
586 fn rejects_invalid_wiki_link_underscore() {
587 let content = format!("{MINIMAL_SPEC}\nSee [[a_b]] for details.\n");
588 let entity = parse(&content);
589 let err = validate(&content, &entity).unwrap_err();
590 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
591 }
592
593 #[test]
594 fn rejects_invalid_wiki_link_space() {
595 let content = format!("{MINIMAL_SPEC}\nSee [[a b]] for details.\n");
596 let entity = parse(&content);
597 let err = validate(&content, &entity).unwrap_err();
598 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
599 }
600
601 #[test]
602 fn accepts_tier_two_cross_mem_link() {
603 let content = format!(
604 "{MINIMAL_SPEC}\nSee [[engine:health]] and [[engine:architecture/result]] for more.\n"
605 );
606 let entity = parse(&content);
607 validate(&content, &entity).unwrap();
608 }
609
610 #[test]
615 fn accepts_hierarchical_tier_two_link() {
616 let content = format!("{MINIMAL_SPEC}\nSee [[external/engine:health]] for details.\n");
617 let entity = parse(&content);
618 validate(&content, &entity).unwrap();
619 }
620
621 #[test]
622 fn rejects_tier_two_with_empty_leaf() {
623 let content = format!("{MINIMAL_SPEC}\nSee [[:slug]] for details.\n");
624 let entity = parse(&content);
625 let err = validate(&content, &entity).unwrap_err();
626 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
627 }
628
629 #[test]
630 fn rejects_tier_two_with_empty_slug() {
631 let content = format!("{MINIMAL_SPEC}\nSee [[engine:]] for details.\n");
632 let entity = parse(&content);
633 let err = validate(&content, &entity).unwrap_err();
634 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
635 }
636
637 #[test]
638 fn rejects_tier_two_with_invalid_leaf_chars() {
639 let content = format!("{MINIMAL_SPEC}\nSee [[Engine:slug]] for details.\n");
640 let entity = parse(&content);
641 let err = validate(&content, &entity).unwrap_err();
642 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
643 }
644
645 #[test]
646 fn rejects_tier_two_with_invalid_slug_chars() {
647 let content = format!("{MINIMAL_SPEC}\nSee [[engine:Slug]] for details.\n");
648 let entity = parse(&content);
649 let err = validate(&content, &entity).unwrap_err();
650 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
651 }
652
653 #[test]
654 fn rejects_reserved_cross_mem_syntax() {
655 let content = format!("{MINIMAL_SPEC}\nSee [[other-mem::entity]] for details.\n");
656 let entity = parse(&content);
657 let err = validate(&content, &entity).unwrap_err();
658 match err {
659 ValidationError::InvalidWikiLink { reason, .. } => {
660 assert!(reason.contains("::"), "reason={reason}");
661 }
662 other => panic!("expected InvalidWikiLink, got {other:?}"),
663 }
664 }
665
666 #[test]
667 fn rejects_reserved_deep_link_syntax() {
668 let content = format!("{MINIMAL_SPEC}\nSee [[entity#section]]");
669 let entity = parse(&content);
670 let err = validate(&content, &entity).unwrap_err();
671 match err {
672 ValidationError::InvalidWikiLink { reason, .. } => {
673 assert!(reason.contains("#"), "reason={reason}");
674 }
675 other => panic!("expected InvalidWikiLink, got {other:?}"),
676 }
677 }
678
679 #[test]
680 fn rejects_empty_wiki_link() {
681 let content = format!("{MINIMAL_SPEC}\nSee [[]]");
682 let entity = parse(&content);
683 let err = validate(&content, &entity).unwrap_err();
684 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
685 }
686
687 #[test]
688 fn rejects_unbalanced_brackets() {
689 let content = format!("{MINIMAL_SPEC}\nSee [[unterminated for details.\n");
690 let entity = parse(&content);
691 let err = validate(&content, &entity).unwrap_err();
692 assert!(matches!(err, ValidationError::UnbalancedBrackets { .. }));
693 }
694
695 #[test]
696 fn accepts_valid_stub_wiki_link() {
697 let content = format!("{MINIMAL_SPEC}\nSee [[planned-feature]] and [[a/b/c]] for more.\n");
698 let entity = parse(&content);
699 validate(&content, &entity).unwrap();
700 }
701
702 #[test]
703 fn accepts_wiki_link_inside_inline_code() {
704 let content =
705 format!("{MINIMAL_SPEC}\nOne line per edge, shape `- **<REL>**: [[<target>]]`.\n");
706 let entity = parse(&content);
707 validate(&content, &entity).unwrap();
708 }
709
710 #[test]
711 fn accepts_literal_backtick_via_double_delimiter() {
712 let content = format!(
719 "{MINIMAL_SPEC}\nWalks left-to-right looking for the earliest of `**`, `` ` ``, `[[`. Done.\n"
720 );
721 let entity = parse(&content);
722 validate(&content, &entity).unwrap();
723 }
724
725 #[test]
726 fn accepts_brackets_inside_double_backtick_span() {
727 let content = format!("{MINIMAL_SPEC}\n| Inline code | `` `[[slug]]` `` | note. |\n");
731 let entity = parse(&content);
732 validate(&content, &entity).unwrap();
733 }
734
735 #[test]
736 fn accepts_wiki_link_with_alias() {
737 let content = format!("{MINIMAL_SPEC}\nSee [[target|Display Text]] for more.\n");
738 let entity = parse(&content);
739 validate(&content, &entity).unwrap();
740 }
741
742 #[test]
743 fn accepts_wiki_link_with_parent_relative_and_md() {
744 let content = format!("{MINIMAL_SPEC}\nSee [[../parent/entity.md]] for more.\n");
745 let entity = parse(&content);
746 validate(&content, &entity).unwrap();
747 }
748
749 #[test]
750 fn accepts_wiki_link_inside_code_block() {
751 let content = format!("{MINIMAL_SPEC}\n```\nlet x = [[this is not a link]];\n```\n");
752 let entity = parse(&content);
753 validate(&content, &entity).unwrap();
754 }
755
756 #[test]
757 fn accepts_windows_line_endings() {
758 let content = MINIMAL_SPEC.replace('\n', "\r\n");
759 let entity = parse(&content);
760 validate(&content, &entity).unwrap();
761 }
762
763 #[test]
764 fn accepts_leading_bom() {
765 let raw = format!("\u{feff}{MINIMAL_SPEC}");
769 let stripped = raw.strip_prefix('\u{feff}').unwrap();
770 let entity = parse(stripped);
771 validate(&raw, &entity).unwrap();
772 }
773
774 fn spec_with_specifies(body: &str) -> String {
778 MINIMAL_SPEC.replace("What it covers.", body)
779 }
780
781 fn spec_type_without_catch_all() -> std::sync::Arc<memstead_schema::TypeDefinition> {
786 let mut t = (*spec_type()).clone();
787 for section in &mut t.sections {
788 section.catch_all = false;
789 }
790 std::sync::Arc::new(t)
791 }
792
793 fn validate_no_catch_all(content: &str) -> Result<(), ValidationError> {
794 let ty = spec_type_without_catch_all();
795 let entity = crate::entity::parser::parse_markdown(content, "test.md", &ty, "specs")
796 .unwrap()
797 .entity;
798 validate_strict(content, &entity, &ty, "test.md")
799 }
800
801 #[test]
805 fn unknown_section_check_ignores_code_block_headings() {
806 for body in [
807 "```\n## Not A Section\n```",
808 "~~~\n## Not A Section\n~~~",
809 "> ```\n> ## Not A Section\n> ```",
810 "````\n```\n## Not A Section\n```\n````",
811 " ## Not A Section",
812 ] {
813 let content = spec_with_specifies(body);
814 validate_no_catch_all(&content).unwrap_or_else(|e| {
815 panic!("code-block heading must not be a section: {body:?} -> {e}")
816 });
817 }
818 }
819
820 #[test]
823 fn unknown_section_check_still_refuses_a_prose_heading() {
824 let content = spec_with_specifies("text\n\n## Invented Section\n\nmore");
825 let err = validate_no_catch_all(&content).unwrap_err();
826 assert!(
827 matches!(err, ValidationError::UnknownSection { ref section, .. } if section == "Invented Section"),
828 "{err:?}"
829 );
830 }
831
832 #[test]
835 fn title_check_ignores_a_heading_inside_a_code_block() {
836 let content = MINIMAL_SPEC.replace("# Test Entity", "```\n# Fake\n```");
837 let entity = parse(&content);
838 let err = validate(&content, &entity).unwrap_err();
839 assert!(
840 matches!(err, ValidationError::MissingTitle { .. }),
841 "{err:?}"
842 );
843 }
844
845 #[test]
849 fn inline_code_spans_hide_links_from_the_validator() {
850 let content = spec_with_specifies("`[[Not A Slug]]` and ``[[Also Not]]`` are literals.");
851 let entity = parse(&content);
852 validate(&content, &entity).expect("links inside inline code are not links to any path");
853 }
854
855 #[test]
857 fn a_malformed_link_in_prose_is_still_refused() {
858 let content = spec_with_specifies("See [[Not A Slug]].");
859 let entity = parse(&content);
860 let err = validate(&content, &entity).unwrap_err();
861 assert!(
862 matches!(err, ValidationError::InvalidWikiLink { .. }),
863 "{err:?}"
864 );
865 }
866
867 #[test]
875 fn relationships_section_is_judged_on_the_original_body() {
876 let content = MINIMAL_SPEC.replace(
877 "## Rationale\n\nDesign notes.\n",
878 "## Rationale\n\nDesign notes.\n\n## Relationships\n\n```\n- **USES**: [[x]]\n```\n",
879 );
880 let entity = parse(&content);
881 let err = validate(&content, &entity).expect_err("a code block is not a relationship list");
882 assert!(
883 matches!(err, ValidationError::InvalidRelationshipLine { .. }),
884 "{err:?}"
885 );
886 }
887
888 #[test]
892 fn relationships_section_complements() {
893 let ok = MINIMAL_SPEC.replace(
894 "## Rationale\n\nDesign notes.\n",
895 "## Rationale\n\nDesign notes.\n\n## Relationships\n\n- **USES**: [[some-target]]\n",
896 );
897 let entity = parse(&ok);
898 validate(&ok, &entity).expect("a bullet relationship list is valid");
899
900 let fenced = spec_with_specifies("```\n## Relationships\n\nnot a list at all\n```");
901 let entity = parse(&fenced);
902 validate(&fenced, &entity).expect("a fenced `## Relationships` opens no section to check");
903 }
904
905 #[test]
907 fn empty_wiki_link_target_is_still_refused() {
908 let content = spec_with_specifies("An empty [[]] link.");
909 let entity = parse(&content);
910 let err = validate(&content, &entity).unwrap_err();
911 assert!(
912 matches!(err, ValidationError::InvalidWikiLink { ref reason, .. } if reason == "empty target"),
913 "{err:?}"
914 );
915 }
916}