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