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::{Frontmatter, 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 (meta_block, body) = split_frontmatter_strict(raw_bytes, path)?;
31 check_metadata(meta_block, entity, schema, path)?;
32 check_title_presence(body, path)?;
33 check_sections_present(entity, schema, path)?;
34 check_unknown_sections(body, schema, path)?;
35 check_relationships_syntax(body, path)?;
36 check_relationship_types(entity, path)?;
37 check_wiki_links(body, path)?;
38
39 Ok(())
40}
41
42pub(crate) fn split_frontmatter_strict<'a>(
46 raw: &'a str,
47 path: &str,
48) -> Result<(&'a str, &'a str), ValidationError> {
49 match crate::entity::parser::split_frontmatter_core(raw).1 {
54 Frontmatter::Present { meta, body } => Ok((meta, body)),
55 Frontmatter::NoOpeningDelimiter => Err(ValidationError::MissingFrontmatter {
56 path: path.to_string(),
57 }),
58 Frontmatter::Unclosed => Err(ValidationError::InvalidFrontmatter {
59 path: path.to_string(),
60 reason: "frontmatter block is not closed with `\\n---`".to_string(),
61 }),
62 }
63}
64
65fn check_metadata(
66 meta_block: &str,
67 entity: &Entity,
68 schema: &TypeDefinition,
69 path: &str,
70) -> Result<(), ValidationError> {
71 let known_keys: Vec<&str> = schema
76 .metadata_fields
77 .iter()
78 .map(|f| f.key.as_str())
79 .collect();
80 for line in meta_block.lines() {
81 let trimmed = line.trim();
82 if trimmed.is_empty() || trimmed.starts_with('#') {
83 continue;
84 }
85 let Some(colon) = trimmed.find(':') else {
86 continue;
87 };
88 let key = trimmed[..colon].trim();
89 if !known_keys.contains(&key) {
90 return Err(ValidationError::UnknownFrontmatterKey {
91 path: path.to_string(),
92 key: key.to_string(),
93 });
94 }
95 }
96
97 for field in &schema.metadata_fields {
99 let is_required = field.is_required();
100 let value = entity.metadata.get(field.key.as_str());
101 match (is_required, value) {
102 (true, None) => {
103 return Err(ValidationError::MissingRequiredField {
104 path: path.to_string(),
105 field: field.key.to_string(),
106 });
107 }
108 (_, Some(v)) => {
109 if !value_matches_type(v, field.field_type) {
110 return Err(ValidationError::FieldTypeMismatch {
111 path: path.to_string(),
112 field: field.key.to_string(),
113 expected: format!("{:?}", field.field_type),
114 });
115 }
116 if let Some(ref allowed) = field.enum_values {
117 let got = v.to_frontmatter_string();
118 if !allowed.iter().any(|a| a == &got) {
119 return Err(ValidationError::EnumViolation {
120 path: path.to_string(),
121 field: field.key.to_string(),
122 got,
123 });
124 }
125 }
126 }
127 (false, None) => {}
128 }
129 }
130 Ok(())
131}
132
133fn value_matches_type(value: &MetadataValue, expected: FieldType) -> bool {
134 match (value, expected) {
135 (MetadataValue::Bool(_), FieldType::Boolean) => true,
136 (MetadataValue::Integer(_) | MetadataValue::Float(_), FieldType::Number) => true,
137 (MetadataValue::String(s), FieldType::Date) => {
138 crate::runtime_validator::is_date_shaped(s)
142 }
143 (MetadataValue::String(_), FieldType::String) => true,
144 (MetadataValue::String(_), _) => false,
147 _ => false,
148 }
149}
150
151fn check_title_presence(body: &str, path: &str) -> Result<(), ValidationError> {
161 let masked_body = mask_code_blocks(body);
162 let mut lines_seen = 0;
163 for (line, masked) in body.lines().zip(masked_body.lines()) {
164 if line.trim().is_empty() {
165 continue;
166 }
167 lines_seen += 1;
168 if lines_seen > 3 {
169 break;
170 }
171 if let Some(rest) = masked.strip_prefix("# ")
172 && !rest.trim().is_empty()
173 {
174 return Ok(());
175 }
176 }
177 Err(ValidationError::MissingTitle {
178 path: path.to_string(),
179 })
180}
181
182fn check_sections_present(
183 entity: &Entity,
184 schema: &TypeDefinition,
185 path: &str,
186) -> Result<(), ValidationError> {
187 for section in &schema.sections {
188 if !section.required || section.catch_all {
189 continue;
190 }
191 let present = entity
192 .sections
193 .get(section.key.as_str())
194 .is_some_and(|v| !v.trim().is_empty());
195 if !present {
196 return Err(ValidationError::MissingRequiredSection {
197 path: path.to_string(),
198 section: section.heading.clone(),
199 });
200 }
201 }
202 Ok(())
203}
204
205fn check_unknown_sections(
206 body: &str,
207 schema: &TypeDefinition,
208 path: &str,
209) -> Result<(), ValidationError> {
210 if schema.sections.iter().any(|s| s.catch_all) {
211 return Ok(());
212 }
213 let known_headings: Vec<&str> = schema
214 .sections
215 .iter()
216 .map(|s| s.heading.as_str())
217 .chain(std::iter::once("Relationships"))
218 .collect();
219
220 let (_, _, raw_headings) = split_sections(body, &mask_code_blocks(body));
223 for heading in &raw_headings {
224 if !known_headings.contains(&heading.as_str()) {
225 return Err(ValidationError::UnknownSection {
226 path: path.to_string(),
227 section: heading.clone(),
228 });
229 }
230 }
231 Ok(())
232}
233
234fn relationships_format_def() -> &'static memstead_schema::SectionDef {
243 static DEF: OnceLock<memstead_schema::SectionDef> = OnceLock::new();
244 DEF.get_or_init(|| {
245 let content = "list(bullet)?";
246 memstead_schema::SectionDef {
247 key: "relationships".to_string(),
248 heading: "Relationships".to_string(),
249 required: false,
250 load_bearing: None,
251 search_weight: 0.0,
252 catch_all: false,
253 write_rules: vec![],
254 description: None,
255 content: Some(content.to_string()),
256 item_pattern: Some(r"\*\*[A-Z_]+\*\*:\s*\[\[[^\]]+\]\](\s*—.*)?".to_string()),
257 table: None,
258 example: Some("- **USES**: [[target-name]]".to_string()),
259 format_severity: memstead_schema::ConstraintSeverity::Block,
260 compiled_content: Some(
261 memstead_schema::content_expr::ContentExpr::parse(content)
262 .expect("engine-side declaration is valid"),
263 ),
264 format_problems: Vec::new(),
265 }
266 })
267}
268
269fn check_relationships_syntax(body: &str, path: &str) -> Result<(), ValidationError> {
270 let (sections, _, _) = split_sections(body, &mask_code_blocks(body));
276 let section = match sections.get("relationships") {
277 Some((_, s)) if !s.trim().is_empty() => s.clone(),
278 _ => return Ok(()),
279 };
280 if let Some(v) =
281 crate::section_format::check_section_format(relationships_format_def(), §ion)
282 .into_iter()
283 .next()
284 {
285 let line = match &v {
286 crate::section_format::SectionFormatViolation::ItemPatternMismatch { text, .. } => {
287 text.clone()
288 }
289 other => other.describe(),
290 };
291 return Err(ValidationError::InvalidRelationshipLine {
292 path: path.to_string(),
293 line,
294 });
295 }
296 Ok(())
297}
298
299fn check_relationship_types(entity: &Entity, path: &str) -> Result<(), ValidationError> {
300 for rel in &entity.relationships {
301 if !rel_type_regex().is_match(&rel.rel_type) {
302 return Err(ValidationError::InvalidRelationshipType {
303 path: path.to_string(),
304 rel_type: rel.rel_type.clone(),
305 });
306 }
307 }
308 Ok(())
309}
310
311fn rel_type_regex() -> &'static Regex {
312 static RE: OnceLock<Regex> = OnceLock::new();
313 RE.get_or_init(|| Regex::new(r"^[A-Z_]+$").unwrap())
314}
315
316fn check_wiki_links(body: &str, path: &str) -> Result<(), ValidationError> {
330 let masked = crate::markdown::mask_code_blocks_and_spans(body);
331
332 check_bracket_balance(&masked, path)?;
333
334 let link_re = wiki_link_regex();
335 for cap in link_re.captures_iter(&masked) {
336 let inner = &cap[1];
337 if inner.is_empty() {
343 return Err(ValidationError::InvalidWikiLink {
344 path: path.to_string(),
345 link: format!("[[{inner}]]"),
346 reason: "empty target".to_string(),
347 });
348 }
349 if inner.contains("::") {
350 return Err(ValidationError::InvalidWikiLink {
351 path: path.to_string(),
352 link: format!("[[{inner}]]"),
353 reason: "reserved `::` cross-mem syntax is not accepted".to_string(),
354 });
355 }
356 let target = match inner.find('|') {
357 Some(i) => &inner[..i],
358 None => inner,
359 };
360 if target.contains('#') {
361 return Err(ValidationError::InvalidWikiLink {
362 path: path.to_string(),
363 link: format!("[[{inner}]]"),
364 reason: "reserved `#` deep-link syntax is not accepted".to_string(),
365 });
366 }
367
368 if let Err(e) = wiki_link_to_id(inner, "") {
374 return Err(ValidationError::InvalidWikiLink {
375 path: path.to_string(),
376 link: format!("[[{inner}]]"),
377 reason: e.to_string(),
378 });
379 }
380 }
381 Ok(())
382}
383
384fn check_bracket_balance(masked: &str, path: &str) -> Result<(), ValidationError> {
385 let bytes = masked.as_bytes();
386 let mut i = 0;
387 let mut open = 0usize;
388 while i + 1 < bytes.len() {
389 if bytes[i] == b'[' && bytes[i + 1] == b'[' {
390 if open > 0 {
391 return Err(ValidationError::UnbalancedBrackets {
392 path: path.to_string(),
393 });
394 }
395 open += 1;
396 i += 2;
397 continue;
398 }
399 if bytes[i] == b']' && bytes[i + 1] == b']' {
400 if open == 0 {
401 return Err(ValidationError::UnbalancedBrackets {
402 path: path.to_string(),
403 });
404 }
405 open -= 1;
406 i += 2;
407 continue;
408 }
409 i += 1;
410 }
411 if open > 0 {
412 return Err(ValidationError::UnbalancedBrackets {
413 path: path.to_string(),
414 });
415 }
416 Ok(())
417}
418
419fn wiki_link_regex() -> &'static Regex {
420 static RE: OnceLock<Regex> = OnceLock::new();
421 RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use crate::entity::parser::parse_markdown;
428 use memstead_schema::type_by_name;
429
430 fn spec_type() -> std::sync::Arc<memstead_schema::TypeDefinition> {
431 type_by_name("spec").unwrap()
432 }
433
434 fn parse(content: &str) -> Entity {
435 parse_markdown(content, "test.md", &spec_type(), "v")
436 .unwrap()
437 .entity
438 }
439
440 fn validate(content: &str, entity: &Entity) -> Result<(), ValidationError> {
441 validate_strict(content, entity, &spec_type(), "test.md")
442 }
443
444 const MINIMAL_SPEC: &str = "\
445---
446type: spec
447created_date: 2026-01-15
448last_modified: 2026-01-15
449level: M0
450---
451# Test Entity
452
453## Identity
454
455A meaningful identity line.
456
457## Purpose
458
459Why it exists.
460
461## Specifies
462
463What it covers.
464
465## Constraints
466
467Its limits.
468
469## Rationale
470
471Design notes.
472";
473
474 #[test]
475 fn accepts_valid_spec() {
476 let entity = parse(MINIMAL_SPEC);
477 validate(MINIMAL_SPEC, &entity).unwrap();
478 }
479
480 #[test]
481 fn rejects_missing_frontmatter() {
482 let content = "# No Frontmatter\n\n## Identity\nBody.\n";
483 let entity = parse(&format!("---\ntype: spec\n---\n{content}"));
484 let err = validate(content, &entity).unwrap_err();
485 assert!(matches!(err, ValidationError::MissingFrontmatter { .. }));
486 }
487
488 #[test]
489 fn rejects_unclosed_frontmatter() {
490 let content = "---\ntype: spec\n# stuck in frontmatter\n";
491 let entity = parse(MINIMAL_SPEC); let err = validate(content, &entity).unwrap_err();
493 assert!(matches!(err, ValidationError::InvalidFrontmatter { .. }));
494 }
495
496 #[test]
497 fn rejects_unknown_frontmatter_key() {
498 let content = MINIMAL_SPEC.replacen("level: M0", "level: M0\nunexpected_key: oops", 1);
499 let entity = parse(&content);
500 let err = validate(&content, &entity).unwrap_err();
501 match err {
502 ValidationError::UnknownFrontmatterKey { key, .. } => {
503 assert_eq!(key, "unexpected_key");
504 }
505 other => panic!("expected UnknownFrontmatterKey, got {other:?}"),
506 }
507 }
508
509 #[test]
510 fn rejects_missing_required_field() {
511 let content = MINIMAL_SPEC.replacen("level: M0\n", "", 1);
512 let entity = parse(&content);
513 let err = validate(&content, &entity).unwrap_err();
514 assert!(matches!(err, ValidationError::MissingRequiredField { .. }));
515 }
516
517 #[test]
518 fn rejects_missing_title() {
519 let content = MINIMAL_SPEC.replacen("# Test Entity\n", "\n", 1);
520 let entity = parse(&content);
521 let err = validate(&content, &entity).unwrap_err();
522 assert!(matches!(err, ValidationError::MissingTitle { .. }));
523 }
524
525 #[test]
526 fn rejects_missing_required_section() {
527 let content = MINIMAL_SPEC.replacen("## Purpose\n\nWhy it exists.\n\n", "", 1);
528 let entity = parse(&content);
529 let err = validate(&content, &entity).unwrap_err();
530 assert!(matches!(
531 err,
532 ValidationError::MissingRequiredSection { .. }
533 ));
534 }
535
536 #[test]
544 fn rejects_malformed_relationship_line() {
545 let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- USES: [[target]]\n");
546 let entity = parse(&content);
547 let err = validate(&content, &entity).unwrap_err();
548 assert!(matches!(
549 err,
550 ValidationError::InvalidRelationshipLine { .. }
551 ));
552 }
553
554 #[test]
555 fn accepts_valid_relationship_line() {
556 let content = format!("{MINIMAL_SPEC}\n## Relationships\n\n- **USES**: [[target-name]]\n");
557 let entity = parse(&content);
558 validate(&content, &entity).unwrap();
559 }
560
561 #[test]
562 fn rejects_invalid_wiki_link_uppercase() {
563 let content = format!("{MINIMAL_SPEC}\nSee [[MyThing]] for details.\n");
564 let entity = parse(&content);
565 let err = validate(&content, &entity).unwrap_err();
566 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
567 }
568
569 #[test]
570 fn rejects_invalid_wiki_link_underscore() {
571 let content = format!("{MINIMAL_SPEC}\nSee [[a_b]] for details.\n");
572 let entity = parse(&content);
573 let err = validate(&content, &entity).unwrap_err();
574 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
575 }
576
577 #[test]
578 fn rejects_invalid_wiki_link_space() {
579 let content = format!("{MINIMAL_SPEC}\nSee [[a b]] 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 accepts_tier_two_cross_mem_link() {
587 let content = format!(
588 "{MINIMAL_SPEC}\nSee [[engine:health]] and [[engine:architecture/result]] for more.\n"
589 );
590 let entity = parse(&content);
591 validate(&content, &entity).unwrap();
592 }
593
594 #[test]
599 fn accepts_hierarchical_tier_two_link() {
600 let content = format!("{MINIMAL_SPEC}\nSee [[external/engine:health]] for details.\n");
601 let entity = parse(&content);
602 validate(&content, &entity).unwrap();
603 }
604
605 #[test]
606 fn rejects_tier_two_with_empty_leaf() {
607 let content = format!("{MINIMAL_SPEC}\nSee [[:slug]] for details.\n");
608 let entity = parse(&content);
609 let err = validate(&content, &entity).unwrap_err();
610 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
611 }
612
613 #[test]
614 fn rejects_tier_two_with_empty_slug() {
615 let content = format!("{MINIMAL_SPEC}\nSee [[engine:]] for details.\n");
616 let entity = parse(&content);
617 let err = validate(&content, &entity).unwrap_err();
618 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
619 }
620
621 #[test]
622 fn rejects_tier_two_with_invalid_leaf_chars() {
623 let content = format!("{MINIMAL_SPEC}\nSee [[Engine: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_invalid_slug_chars() {
631 let content = format!("{MINIMAL_SPEC}\nSee [[engine:Slug]] 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_reserved_cross_mem_syntax() {
639 let content = format!("{MINIMAL_SPEC}\nSee [[other-mem::entity]] for details.\n");
640 let entity = parse(&content);
641 let err = validate(&content, &entity).unwrap_err();
642 match err {
643 ValidationError::InvalidWikiLink { reason, .. } => {
644 assert!(reason.contains("::"), "reason={reason}");
645 }
646 other => panic!("expected InvalidWikiLink, got {other:?}"),
647 }
648 }
649
650 #[test]
651 fn rejects_reserved_deep_link_syntax() {
652 let content = format!("{MINIMAL_SPEC}\nSee [[entity#section]]");
653 let entity = parse(&content);
654 let err = validate(&content, &entity).unwrap_err();
655 match err {
656 ValidationError::InvalidWikiLink { reason, .. } => {
657 assert!(reason.contains("#"), "reason={reason}");
658 }
659 other => panic!("expected InvalidWikiLink, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn rejects_empty_wiki_link() {
665 let content = format!("{MINIMAL_SPEC}\nSee [[]]");
666 let entity = parse(&content);
667 let err = validate(&content, &entity).unwrap_err();
668 assert!(matches!(err, ValidationError::InvalidWikiLink { .. }));
669 }
670
671 #[test]
672 fn rejects_unbalanced_brackets() {
673 let content = format!("{MINIMAL_SPEC}\nSee [[unterminated for details.\n");
674 let entity = parse(&content);
675 let err = validate(&content, &entity).unwrap_err();
676 assert!(matches!(err, ValidationError::UnbalancedBrackets { .. }));
677 }
678
679 #[test]
680 fn accepts_valid_stub_wiki_link() {
681 let content = format!("{MINIMAL_SPEC}\nSee [[planned-feature]] and [[a/b/c]] for more.\n");
682 let entity = parse(&content);
683 validate(&content, &entity).unwrap();
684 }
685
686 #[test]
687 fn accepts_wiki_link_inside_inline_code() {
688 let content =
689 format!("{MINIMAL_SPEC}\nOne line per edge, shape `- **<REL>**: [[<target>]]`.\n");
690 let entity = parse(&content);
691 validate(&content, &entity).unwrap();
692 }
693
694 #[test]
695 fn accepts_literal_backtick_via_double_delimiter() {
696 let content = format!(
703 "{MINIMAL_SPEC}\nWalks left-to-right looking for the earliest of `**`, `` ` ``, `[[`. Done.\n"
704 );
705 let entity = parse(&content);
706 validate(&content, &entity).unwrap();
707 }
708
709 #[test]
710 fn accepts_brackets_inside_double_backtick_span() {
711 let content = format!("{MINIMAL_SPEC}\n| Inline code | `` `[[slug]]` `` | note. |\n");
715 let entity = parse(&content);
716 validate(&content, &entity).unwrap();
717 }
718
719 #[test]
720 fn accepts_wiki_link_with_alias() {
721 let content = format!("{MINIMAL_SPEC}\nSee [[target|Display Text]] for more.\n");
722 let entity = parse(&content);
723 validate(&content, &entity).unwrap();
724 }
725
726 #[test]
727 fn accepts_wiki_link_with_parent_relative_and_md() {
728 let content = format!("{MINIMAL_SPEC}\nSee [[../parent/entity.md]] for more.\n");
729 let entity = parse(&content);
730 validate(&content, &entity).unwrap();
731 }
732
733 #[test]
734 fn accepts_wiki_link_inside_code_block() {
735 let content = format!("{MINIMAL_SPEC}\n```\nlet x = [[this is not a link]];\n```\n");
736 let entity = parse(&content);
737 validate(&content, &entity).unwrap();
738 }
739
740 #[test]
741 fn accepts_windows_line_endings() {
742 let content = MINIMAL_SPEC.replace('\n', "\r\n");
743 let entity = parse(&content);
744 validate(&content, &entity).unwrap();
745 }
746
747 #[test]
748 fn accepts_leading_bom() {
749 let raw = format!("\u{feff}{MINIMAL_SPEC}");
754 let stripped = raw.strip_prefix('\u{feff}').unwrap();
755 let entity = parse(stripped);
756 validate(&raw, &entity).unwrap();
757 }
758
759 fn spec_with_specifies(body: &str) -> String {
763 MINIMAL_SPEC.replace("What it covers.", body)
764 }
765
766 fn spec_type_without_catch_all() -> std::sync::Arc<memstead_schema::TypeDefinition> {
771 let mut t = (*spec_type()).clone();
772 for section in &mut t.sections {
773 section.catch_all = false;
774 }
775 std::sync::Arc::new(t)
776 }
777
778 fn validate_no_catch_all(content: &str) -> Result<(), ValidationError> {
779 let ty = spec_type_without_catch_all();
780 let entity = crate::entity::parser::parse_markdown(content, "test.md", &ty, "specs")
781 .unwrap()
782 .entity;
783 validate_strict(content, &entity, &ty, "test.md")
784 }
785
786 #[test]
790 fn unknown_section_check_ignores_code_block_headings() {
791 for body in [
792 "```\n## Not A Section\n```",
793 "~~~\n## Not A Section\n~~~",
794 "> ```\n> ## Not A Section\n> ```",
795 "````\n```\n## Not A Section\n```\n````",
796 " ## Not A Section",
797 ] {
798 let content = spec_with_specifies(body);
799 validate_no_catch_all(&content).unwrap_or_else(|e| {
800 panic!("code-block heading must not be a section: {body:?} -> {e}")
801 });
802 }
803 }
804
805 #[test]
808 fn unknown_section_check_still_refuses_a_prose_heading() {
809 let content = spec_with_specifies("text\n\n## Invented Section\n\nmore");
810 let err = validate_no_catch_all(&content).unwrap_err();
811 assert!(
812 matches!(err, ValidationError::UnknownSection { ref section, .. } if section == "Invented Section"),
813 "{err:?}"
814 );
815 }
816
817 #[test]
820 fn title_check_ignores_a_heading_inside_a_code_block() {
821 let content = MINIMAL_SPEC.replace("# Test Entity", "```\n# Fake\n```");
822 let entity = parse(&content);
823 let err = validate(&content, &entity).unwrap_err();
824 assert!(
825 matches!(err, ValidationError::MissingTitle { .. }),
826 "{err:?}"
827 );
828 }
829
830 #[test]
834 fn inline_code_spans_hide_links_from_the_validator() {
835 let content = spec_with_specifies("`[[Not A Slug]]` and ``[[Also Not]]`` are literals.");
836 let entity = parse(&content);
837 validate(&content, &entity).expect("links inside inline code are not links to any path");
838 }
839
840 #[test]
842 fn a_malformed_link_in_prose_is_still_refused() {
843 let content = spec_with_specifies("See [[Not A Slug]].");
844 let entity = parse(&content);
845 let err = validate(&content, &entity).unwrap_err();
846 assert!(
847 matches!(err, ValidationError::InvalidWikiLink { .. }),
848 "{err:?}"
849 );
850 }
851
852 #[test]
860 fn relationships_section_is_judged_on_the_original_body() {
861 let content = MINIMAL_SPEC.replace(
862 "## Rationale\n\nDesign notes.\n",
863 "## Rationale\n\nDesign notes.\n\n## Relationships\n\n```\n- **USES**: [[x]]\n```\n",
864 );
865 let entity = parse(&content);
866 let err = validate(&content, &entity).expect_err("a code block is not a relationship list");
867 assert!(
868 matches!(err, ValidationError::InvalidRelationshipLine { .. }),
869 "{err:?}"
870 );
871 }
872
873 #[test]
877 fn relationships_section_complements() {
878 let ok = MINIMAL_SPEC.replace(
879 "## Rationale\n\nDesign notes.\n",
880 "## Rationale\n\nDesign notes.\n\n## Relationships\n\n- **USES**: [[some-target]]\n",
881 );
882 let entity = parse(&ok);
883 validate(&ok, &entity).expect("a bullet relationship list is valid");
884
885 let fenced = spec_with_specifies("```\n## Relationships\n\nnot a list at all\n```");
886 let entity = parse(&fenced);
887 validate(&fenced, &entity).expect("a fenced `## Relationships` opens no section to check");
888 }
889
890 #[test]
892 fn empty_wiki_link_target_is_still_refused() {
893 let content = spec_with_specifies("An empty [[]] link.");
894 let entity = parse(&content);
895 let err = validate(&content, &entity).unwrap_err();
896 assert!(
897 matches!(err, ValidationError::InvalidWikiLink { ref reason, .. } if reason == "empty target"),
898 "{err:?}"
899 );
900 }
901}