1use std::sync::OnceLock;
19
20use indexmap::IndexMap;
21use memstead_schema::{
22 CrossMemRelationshipEntry, FieldType, RelationshipDef, RelationshipMode, Schema, TypeDefinition,
23};
24use regex::Regex;
25
26use crate::entity::MetadataValue;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RelationshipHint {
36 pub name: String,
37 pub when_to_use: Option<String>,
38}
39
40impl std::fmt::Display for RelationshipHint {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.write_str(&self.name)
46 }
47}
48
49#[derive(Debug, Clone, thiserror::Error)]
53pub enum ValidationError {
54 #[error("unknown section '{key}' for type '{entity_type}'")]
57 UnknownSection {
58 key: String,
59 entity_type: String,
60 declared: Vec<String>,
61 suggestion: Option<String>,
62 },
63 #[error("unknown metadata field '{key}' for type '{entity_type}'")]
65 UnknownMetadata {
66 key: String,
67 entity_type: String,
68 declared: Vec<String>,
69 suggestion: Option<String>,
70 },
71 #[error("invalid value '{value}' for field '{field}' on type '{entity_type}'")]
74 InvalidEnumValue {
75 field: String,
76 value: String,
77 allowed: Vec<String>,
78 field_description: Option<String>,
79 suggestion: Option<String>,
80 type_write_rules: Vec<String>,
81 entity_type: String,
82 },
83 #[error("cannot change read-only field '{field}' via update")]
86 ReadOnlyField { field: String },
87 #[error("section '{section}' is not updatable for type '{entity_type}'")]
92 SectionNotUpdatable {
93 section: String,
94 entity_type: String,
95 },
96 #[error("invalid relationship type '{input}'")]
101 InvalidRelationshipType {
102 input: String,
103 allowed: Vec<RelationshipHint>,
104 suggestion: Option<String>,
105 },
106 #[error(
111 "relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape"
112 )]
113 InvalidRelationshipShape {
114 rel_type: String,
115 from_type: String,
116 to_type: String,
117 allowed_source_types: Vec<String>,
118 allowed_target_types: Vec<String>,
119 suggestion: Option<RelationshipHint>,
120 },
121 #[error(
129 "section '{section}' content contains an embedded reserved (`# ` / `## `) heading line '{embedded_heading}' — \
130 the compose-then-reparse pipeline would split the value at that heading; use `### ` or \
131 deeper for sub-headings"
132 )]
133 SectionContentInvalid {
134 section: String,
135 embedded_heading: String,
136 },
137 #[error(
149 "section '{section}' carries heading '{heading}', which type '{entity_type}' does not \
150 declare and which has no body under it. The catch-all keeps absorbed content but skips \
151 empty content, so this heading would be dropped by the write. Give it a body, or \
152 remove the heading."
153 )]
154 EmptyUndeclaredHeading {
155 section: String,
156 heading: String,
157 entity_type: String,
158 },
159 #[error(
177 "section '{section}' ends inside an unterminated `{fence}` code fence — every section \
178 heading written after it would be swallowed into this body and read back as empty. \
179 Close the fence with a line of `{fence}`."
180 )]
181 UnterminatedFence { section: String, fence: String },
182 #[error(
195 "section '{section}' content contains a disallowed control character U+{codepoint:04X} \
196 at byte offset {byte_offset} — only tab and newline are permitted in section bodies"
197 )]
198 SectionContentControlByte {
199 section: String,
200 control_char: char,
203 codepoint: u32,
206 byte_offset: usize,
209 },
210 #[error(
221 "invalid value '{value}' for field '{field}' on type '{entity_type}' — expected {expected_type}"
222 )]
223 InvalidFieldValue {
224 field: String,
225 value: String,
226 expected_type: String,
227 expected_format: Option<String>,
228 field_description: Option<String>,
229 entity_type: String,
230 },
231}
232
233impl ValidationError {
234 pub fn code(&self) -> &'static str {
239 match self {
240 ValidationError::UnknownSection { .. } => "UNKNOWN_SECTION",
241 ValidationError::UnknownMetadata { .. } => "UNKNOWN_METADATA_FIELD",
242 ValidationError::InvalidEnumValue { .. } => "INVALID_ENUM_VALUE",
243 ValidationError::ReadOnlyField { .. } => "READ_ONLY_FIELD",
244 ValidationError::SectionNotUpdatable { .. } => "SECTION_NOT_UPDATABLE",
245 ValidationError::InvalidRelationshipType { .. } => "INVALID_REL_TYPE",
246 ValidationError::InvalidRelationshipShape { .. } => "INVALID_REL_SHAPE",
247 ValidationError::EmptyUndeclaredHeading { .. } => "EMPTY_UNDECLARED_HEADING",
248 ValidationError::UnterminatedFence { .. } => "UNTERMINATED_FENCE",
249 ValidationError::SectionContentInvalid { .. } => "SECTION_CONTENT_INVALID",
250 ValidationError::SectionContentControlByte { .. } => "SECTION_CONTENT_INVALID",
251 ValidationError::InvalidFieldValue { .. } => "INVALID_FIELD_VALUE",
252 }
253 }
254
255 pub fn details(&self) -> serde_json::Value {
262 match self {
263 ValidationError::UnknownSection {
264 key,
265 entity_type,
266 declared,
267 suggestion,
268 } => serde_json::json!({
269 "key": key,
270 "entity_type": entity_type,
271 "declared": declared,
272 "suggestion": suggestion,
273 }),
274 ValidationError::UnknownMetadata {
275 key,
276 entity_type,
277 declared,
278 suggestion,
279 } => serde_json::json!({
280 "key": key,
281 "entity_type": entity_type,
282 "declared": declared,
283 "suggestion": suggestion,
284 }),
285 ValidationError::InvalidEnumValue {
286 field,
287 value,
288 allowed,
289 field_description,
290 suggestion,
291 type_write_rules,
292 entity_type,
293 } => serde_json::json!({
294 "field": field,
295 "value": value,
296 "allowed": allowed,
297 "field_description": field_description,
298 "suggestion": suggestion,
299 "type_write_rules": type_write_rules,
300 "entity_type": entity_type,
301 }),
302 ValidationError::ReadOnlyField { field } => serde_json::json!({
303 "field": field,
304 }),
305 ValidationError::SectionNotUpdatable {
306 section,
307 entity_type,
308 } => serde_json::json!({
309 "section": section,
310 "entity_type": entity_type,
311 }),
312 ValidationError::InvalidRelationshipType {
313 input,
314 allowed,
315 suggestion,
316 } => {
317 let allowed_json: Vec<serde_json::Value> = allowed
318 .iter()
319 .map(|h| {
320 serde_json::json!({
321 "name": h.name,
322 "when_to_use": h.when_to_use,
323 })
324 })
325 .collect();
326 serde_json::json!({
327 "input": input,
328 "allowed": allowed_json,
329 "suggestion": suggestion,
330 })
331 }
332 ValidationError::InvalidRelationshipShape {
333 rel_type,
334 from_type,
335 to_type,
336 allowed_source_types,
337 allowed_target_types,
338 suggestion,
339 } => {
340 let suggestion_json = suggestion.as_ref().map(|h| {
341 serde_json::json!({
342 "name": h.name,
343 "when_to_use": h.when_to_use,
344 })
345 });
346 let mut details = serde_json::Map::new();
347 details.insert(
348 "rel_type".into(),
349 serde_json::Value::String(rel_type.clone()),
350 );
351 details.insert(
352 "from_type".into(),
353 serde_json::Value::String(from_type.clone()),
354 );
355 details.insert("to_type".into(), serde_json::Value::String(to_type.clone()));
356 if !allowed_source_types.is_empty() {
363 details.insert(
364 "allowed_source_types".into(),
365 serde_json::json!(allowed_source_types),
366 );
367 }
368 if !allowed_target_types.is_empty() {
369 details.insert(
370 "allowed_target_types".into(),
371 serde_json::json!(allowed_target_types),
372 );
373 }
374 details.insert("suggestion".into(), serde_json::json!(suggestion_json));
375 serde_json::Value::Object(details)
376 }
377 ValidationError::UnterminatedFence { section, fence } => serde_json::json!({
378 "section": section,
379 "fence": fence,
380 "expected": format!(
381 "close the fence with a line of `{fence}`, or remove the opener"
382 ),
383 }),
384 ValidationError::EmptyUndeclaredHeading {
385 section,
386 heading,
387 entity_type,
388 } => serde_json::json!({
389 "section": section,
390 "heading": heading,
391 "entity_type": entity_type,
392 "expected": "give the heading a body, or remove it: the catch-all keeps \
393 absorbed content but skips empty content",
394 }),
395 ValidationError::SectionContentInvalid {
396 section,
397 embedded_heading,
398 } => serde_json::json!({
399 "section": section,
400 "embedded_heading": embedded_heading,
401 }),
402 ValidationError::SectionContentControlByte {
403 section,
404 control_char,
405 codepoint,
406 byte_offset,
407 } => serde_json::json!({
408 "section": section,
409 "control_char": control_char.to_string(),
410 "codepoint": codepoint,
411 "byte_offset": byte_offset,
412 }),
413 ValidationError::InvalidFieldValue {
414 field,
415 value,
416 expected_type,
417 expected_format,
418 field_description,
419 entity_type,
420 } => serde_json::json!({
421 "field": field,
422 "value": value,
423 "expected_type": expected_type,
424 "expected_format": expected_format,
425 "field_description": field_description,
426 "entity_type": entity_type,
427 }),
428 }
429 }
430
431 pub fn prose_render(&self) -> String {
441 match self {
442 ValidationError::UnknownSection {
443 key,
444 entity_type,
445 declared,
446 suggestion,
447 } => {
448 let declared_inline = if declared.is_empty() {
449 "(none)".to_string()
450 } else {
451 declared.join(", ")
452 };
453 let suggestion_clause = suggestion
454 .as_deref()
455 .map(|s| format!(" Did you mean '{s}'?"))
456 .unwrap_or_default();
457 format!(
458 "unknown section '{key}' for type '{entity_type}' — declared sections: {declared_inline}.{suggestion_clause}"
459 )
460 }
461 ValidationError::UnknownMetadata {
462 key,
463 entity_type,
464 declared,
465 suggestion,
466 } => {
467 let declared_inline = if declared.is_empty() {
468 "(none)".to_string()
469 } else {
470 declared.join(", ")
471 };
472 let suggestion_clause = suggestion
473 .as_deref()
474 .map(|s| format!(" Did you mean '{s}'?"))
475 .unwrap_or_default();
476 format!(
477 "unknown metadata field '{key}' for type '{entity_type}' — declared fields: {declared_inline}.{suggestion_clause}"
478 )
479 }
480 ValidationError::InvalidEnumValue {
481 field,
482 value,
483 allowed,
484 field_description,
485 suggestion,
486 type_write_rules,
487 entity_type,
488 } => {
489 let allowed_inline = if allowed.is_empty() {
490 "(none)".to_string()
491 } else {
492 allowed.join(", ")
493 };
494 let desc_clause = field_description
495 .as_deref()
496 .map(|d| format!(" Field purpose: {d}."))
497 .unwrap_or_default();
498 let suggestion_clause = suggestion
499 .as_deref()
500 .map(|s| format!(" Did you mean '{s}'?"))
501 .unwrap_or_default();
502 let rules_clause = if type_write_rules.is_empty() {
503 String::new()
504 } else {
505 format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
506 };
507 format!(
508 "invalid value '{value}' for field '{field}' on type '{entity_type}' — allowed: {allowed_inline}.{desc_clause}{suggestion_clause}{rules_clause}"
509 )
510 }
511 ValidationError::ReadOnlyField { field } => {
512 format!("cannot change read-only field '{field}' via update")
513 }
514 ValidationError::SectionNotUpdatable {
515 section,
516 entity_type,
517 } => format!("section '{section}' is not updatable for type '{entity_type}'"),
518 ValidationError::InvalidRelationshipType {
519 input,
520 allowed,
521 suggestion,
522 } => {
523 let allowed_inline = if allowed.is_empty() {
524 "(none)".to_string()
525 } else {
526 allowed
527 .iter()
528 .map(|h| h.name.clone())
529 .collect::<Vec<_>>()
530 .join(", ")
531 };
532 let suggestion_clause = suggestion
533 .as_deref()
534 .map(|s| format!(" Did you mean '{s}'?"))
535 .unwrap_or_default();
536 format!(
537 "invalid relationship type '{input}' — must be one of the schema's declared types: {allowed_inline}.{suggestion_clause}"
538 )
539 }
540 ValidationError::InvalidRelationshipShape {
541 rel_type,
542 from_type,
543 to_type,
544 allowed_source_types,
545 allowed_target_types,
546 suggestion,
547 } => {
548 let sources_inline = if allowed_source_types.is_empty() {
549 "any".to_string()
550 } else {
551 allowed_source_types.join(", ")
552 };
553 let targets_inline = if allowed_target_types.is_empty() {
554 "any".to_string()
555 } else {
556 allowed_target_types.join(", ")
557 };
558 let suggestion_clause = suggestion
559 .as_ref()
560 .map(|h| format!(" Suggested rel-type: '{}'.", h.name))
561 .unwrap_or_default();
562 format!(
563 "relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape — allowed sources: {sources_inline}; allowed targets: {targets_inline}.{suggestion_clause}"
564 )
565 }
566 ValidationError::EmptyUndeclaredHeading {
567 section,
568 heading,
569 entity_type,
570 } => format!(
571 "section '{section}' carries heading '{heading}', which type '{entity_type}' does \
572 not declare and which has no body under it. An undeclared heading WITH content \
573 is kept byte-verbatim in the catch-all; an empty one is skipped, so this write \
574 would drop it. Give the heading a body, or remove it."
575 ),
576 ValidationError::UnterminatedFence { section, fence } => format!(
577 "section '{section}' ends inside an unterminated `{fence}` code fence. In \
578 CommonMark an open fence runs to the end of the text, so every section heading \
579 written after this one would land inside the fence, be masked, and be read back \
580 as part of THIS section's body: the sections after it would render empty and \
581 the entity would still read as healthy. Close the fence with a line of \
582 `{fence}`, or remove the opener."
583 ),
584 ValidationError::SectionContentInvalid {
585 section,
586 embedded_heading,
587 } => format!(
588 "section '{section}' content contains an embedded reserved (`# ` / `## `) heading line '{embedded_heading}' — use `### ` or deeper for sub-headings"
589 ),
590 ValidationError::SectionContentControlByte {
591 section,
592 codepoint,
593 byte_offset,
594 ..
595 } => format!(
596 "section '{section}' content contains a disallowed control character U+{codepoint:04X} at byte offset {byte_offset} — \
597 only tab (U+0009) and newline (U+000A) are permitted in section bodies. Remove the control character: it would break \
598 the diffable-markdown invariant (a NUL makes git treat the file as binary and text tooling truncates at it)."
599 ),
600 ValidationError::InvalidFieldValue {
601 field,
602 value,
603 expected_type,
604 expected_format,
605 field_description,
606 entity_type,
607 } => {
608 let format_clause = expected_format
609 .as_deref()
610 .map(|f| format!(" Expected format: {f}."))
611 .unwrap_or_default();
612 let desc_clause = field_description
613 .as_deref()
614 .map(|d| format!(" Field purpose: {d}."))
615 .unwrap_or_default();
616 format!(
617 "invalid value '{value}' for field '{field}' on type '{entity_type}' — \
618 not a valid {expected_type}.{format_clause}{desc_clause}"
619 )
620 }
621 }
622 }
623}
624
625pub const READ_ONLY_METADATA_KEYS: &[&str] = &["mem", "id", "type"];
635
636pub fn validate_reserved_metadata_key(key: &str) -> Result<(), ValidationError> {
655 if READ_ONLY_METADATA_KEYS.contains(&key) || key.starts_with('_') {
656 return Err(ValidationError::ReadOnlyField {
657 field: key.to_string(),
658 });
659 }
660 Ok(())
661}
662
663pub fn validate_writable_metadata_key(
674 key: &str,
675 schema: &TypeDefinition,
676) -> Result<(), ValidationError> {
677 validate_reserved_metadata_key(key)?;
678 if let Some(field) = schema.metadata_field(key)
679 && (field.init_timestamp || field.auto_timestamp)
680 {
681 return Err(ValidationError::ReadOnlyField {
682 field: key.to_string(),
683 });
684 }
685 Ok(())
686}
687
688pub fn validate_unsettable_metadata_key(
700 key: &str,
701 schema: &TypeDefinition,
702) -> Result<(), ValidationError> {
703 if let Some(field) = schema.metadata_field(key)
704 && (field.init_timestamp || field.auto_timestamp)
705 {
706 return Err(ValidationError::ReadOnlyField {
707 field: key.to_string(),
708 });
709 }
710 Ok(())
711}
712
713pub fn validate_updatable_section(
719 section: &str,
720 schema: &TypeDefinition,
721) -> Result<(), ValidationError> {
722 if section == "relationships" {
723 return Err(ValidationError::SectionNotUpdatable {
724 section: section.to_string(),
725 entity_type: schema.name.clone(),
726 });
727 }
728 if !schema.updatable_fields.is_empty() && !schema.updatable_fields.iter().any(|f| f == section)
729 {
730 return Err(ValidationError::SectionNotUpdatable {
731 section: section.to_string(),
732 entity_type: schema.name.clone(),
733 });
734 }
735 Ok(())
736}
737
738#[derive(Debug, Clone)]
745pub struct MissingRequiredSection {
746 pub entity_type: String,
747 pub key: String,
748 pub heading: String,
749 pub write_rules: Vec<String>,
750}
751
752#[derive(Debug, Clone, Copy)]
754pub struct CatchAllContext<'a> {
755 pub key: &'a str,
757 pub entity_type: &'a str,
760 pub declared_headings: &'a [&'a str],
763}
764
765pub fn catch_all_context<'a>(
768 type_def: &'a memstead_schema::TypeDefinition,
769 buf: &'a mut Vec<&'a str>,
770) -> Option<CatchAllContext<'a>> {
771 let key = type_def.catch_all_section()?.key.as_str();
772 buf.extend(type_def.sections.iter().map(|s| s.heading.as_str()));
773 Some(CatchAllContext {
774 key,
775 entity_type: type_def.name.as_str(),
776 declared_headings: buf,
777 })
778}
779
780fn heading_body_is_empty(body: &str, heading_line: &str) -> bool {
783 let mut lines = body.lines().skip_while(|l| *l != heading_line);
784 lines.next();
785 for line in lines {
786 if line.starts_with("## ") {
787 return true;
788 }
789 if !line.trim().is_empty() {
790 return false;
791 }
792 }
793 true
794}
795
796pub fn validate_section_content<'a>(
834 sections: impl Iterator<Item = (&'a str, &'a str)>,
835 catch_all: Option<CatchAllContext<'_>>,
836) -> Result<(), ValidationError> {
837 for (key, value) in sections {
838 if let Some((byte_offset, ch)) = value
850 .char_indices()
851 .find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
852 {
853 return Err(ValidationError::SectionContentControlByte {
854 section: key.to_string(),
855 control_char: ch,
856 codepoint: ch as u32,
857 byte_offset,
858 });
859 }
860 if let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) {
865 return Err(ValidationError::UnterminatedFence {
866 section: key.to_string(),
867 fence,
868 });
869 }
870 let stored = value.trim();
875 let masked = crate::markdown::mask_code_blocks(stored);
876 for (line, masked_line) in stored.lines().zip(masked.lines()) {
877 let is_h2 = masked_line.starts_with("## ") && masked_line.len() > 3;
885 let is_h1 = masked_line.starts_with("# ") && masked_line.len() > 2;
886 if is_h2
894 && catch_all.is_some_and(|c| {
895 c.key == key && !c.declared_headings.contains(&&masked_line[3..])
896 })
897 {
898 if heading_body_is_empty(stored, line) {
899 return Err(ValidationError::EmptyUndeclaredHeading {
900 section: key.to_string(),
901 heading: masked_line[3..].to_string(),
902 entity_type: catch_all
903 .map(|c| c.entity_type.to_string())
904 .unwrap_or_default(),
905 });
906 }
907 continue;
908 }
909 if is_h2 || is_h1 {
910 return Err(ValidationError::SectionContentInvalid {
911 section: key.to_string(),
912 embedded_heading: line.to_string(),
913 });
914 }
915 }
916 }
917 Ok(())
918}
919
920pub fn validate_section_keys<'a>(
930 provided: impl Iterator<Item = &'a str>,
931 schema: &TypeDefinition,
932) -> Result<(), ValidationError> {
933 let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
934 declared.sort();
935 let declared_set: std::collections::HashSet<&str> =
936 schema.sections.iter().map(|s| s.key.as_str()).collect();
937 let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
938
939 for key in provided {
940 if key == "relationships" {
941 continue;
942 }
943 if declared_set.contains(key) {
944 continue;
945 }
946 let suggestion = schema
947 .suggest_section(key)
948 .or_else(|| catch_all_key.clone());
949 return Err(ValidationError::UnknownSection {
950 key: key.to_string(),
951 entity_type: schema.name.clone(),
952 declared: declared.clone(),
953 suggestion,
954 });
955 }
956 Ok(())
957}
958
959pub fn parse_metadata_value(
969 key: &str,
970 value: &str,
971 schema: &TypeDefinition,
972) -> Result<MetadataValue, ValidationError> {
973 let Some(field_def) = schema.metadata_field(key) else {
974 let mut declared: Vec<String> = schema
975 .metadata_fields
976 .iter()
977 .map(|f| f.key.clone())
978 .collect();
979 declared.sort();
980 return Err(ValidationError::UnknownMetadata {
981 key: key.to_string(),
982 entity_type: schema.name.clone(),
983 declared,
984 suggestion: schema.suggest_metadata_field(key),
985 });
986 };
987
988 if let Some(ref allowed) = field_def.enum_values
989 && !allowed.iter().any(|v| v == value)
990 {
991 let suggestion = nearest_str_match(value, allowed);
992 return Err(ValidationError::InvalidEnumValue {
993 field: key.to_string(),
994 value: value.to_string(),
995 allowed: allowed.clone(),
996 field_description: Some(field_def.description.clone()),
997 suggestion,
998 type_write_rules: schema.write_rules.clone(),
999 entity_type: schema.name.clone(),
1000 });
1001 }
1002
1003 Ok(match field_def.field_type {
1004 FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
1005 FieldType::Number => {
1006 if let Ok(n) = value.parse::<i64>() {
1007 MetadataValue::Integer(n)
1008 } else if let Ok(f) = value.parse::<f64>() {
1009 MetadataValue::Float(f)
1010 } else {
1011 return Err(ValidationError::InvalidFieldValue {
1016 field: key.to_string(),
1017 value: value.to_string(),
1018 expected_type: "Number".to_string(),
1019 expected_format: Some("an integer or decimal number".to_string()),
1020 field_description: Some(field_def.description.clone()),
1021 entity_type: schema.name.clone(),
1022 });
1023 }
1024 }
1025 FieldType::Date => {
1026 if !is_date_shaped(value) {
1034 return Err(ValidationError::InvalidFieldValue {
1035 field: key.to_string(),
1036 value: value.to_string(),
1037 expected_type: "Date".to_string(),
1038 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
1039 field_description: Some(field_def.description.clone()),
1040 entity_type: schema.name.clone(),
1041 });
1042 }
1043 MetadataValue::String(value.to_string())
1044 }
1045 _ => MetadataValue::String(value.to_string()),
1046 })
1047}
1048
1049pub fn is_date_shaped(s: &str) -> bool {
1058 static RE: OnceLock<Regex> = OnceLock::new();
1059 RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
1060 .is_match(s)
1061}
1062
1063#[derive(Debug, Clone)]
1071pub struct MissingRequiredField {
1072 pub entity_type: String,
1073 pub key: String,
1074 pub description: String,
1075 pub enum_values: Vec<String>,
1076}
1077
1078pub fn missing_required_fields(
1089 schema: &TypeDefinition,
1090 supplied: &IndexMap<String, String>,
1091) -> Vec<MissingRequiredField> {
1092 schema
1093 .metadata_fields
1094 .iter()
1095 .filter(|f| {
1096 !READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
1100 && f.is_required()
1101 && f.default_value.is_none()
1102 && !f.init_timestamp
1103 && !f.auto_timestamp
1104 && !supplied.contains_key(f.key.as_str())
1105 })
1106 .map(|f| MissingRequiredField {
1107 entity_type: schema.name.clone(),
1108 key: f.key.clone(),
1109 description: f.description.clone(),
1110 enum_values: f.enum_values.clone().unwrap_or_default(),
1111 })
1112 .collect()
1113}
1114
1115pub fn missing_required_sections(
1119 schema: &TypeDefinition,
1120 sections: &IndexMap<String, String>,
1121) -> Vec<MissingRequiredSection> {
1122 schema
1123 .required_sections()
1124 .filter_map(|sec| {
1125 let is_empty = sections
1126 .get(sec.key.as_str())
1127 .is_none_or(|c| c.trim().is_empty());
1128 is_empty.then(|| MissingRequiredSection {
1129 entity_type: schema.name.clone(),
1130 key: sec.key.clone(),
1131 heading: sec.heading.clone(),
1132 write_rules: sec.write_rules.clone(),
1133 })
1134 })
1135 .collect()
1136}
1137
1138#[derive(Debug, Clone)]
1143pub enum RelationshipCheck {
1144 Ok,
1146 OpenWarning(String),
1149}
1150
1151pub fn validate_rel_type(
1161 rel_type: &str,
1162 schema: &Schema,
1163) -> Result<RelationshipCheck, ValidationError> {
1164 if schema.relationship_known(rel_type) {
1165 return Ok(RelationshipCheck::Ok);
1166 }
1167 match schema.mode() {
1168 RelationshipMode::Strict => {
1169 let allowed = declared_relationship_hints(schema);
1170 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1171 let suggestion = nearest_str_match(rel_type, &candidate_names);
1172 Err(ValidationError::InvalidRelationshipType {
1173 input: rel_type.to_string(),
1174 allowed,
1175 suggestion,
1176 })
1177 }
1178 RelationshipMode::Open => {
1179 let declared: Vec<String> = declared_relationship_hints(schema)
1180 .into_iter()
1181 .map(|h| h.name)
1182 .collect();
1183 let suggestion = schema
1184 .suggest_relationship(rel_type)
1185 .map(|s| format!(" Did you mean '{s}'?"))
1186 .unwrap_or_default();
1187 let (schema_name, schema_version) = schema.id();
1188 Ok(RelationshipCheck::OpenWarning(format!(
1189 "relationship '{rel_type}' is not declared in schema \
1190 '{schema_name}@{schema_version}' (mode: open). \
1191 Accepted with default weight. Declared: [{}].{suggestion}",
1192 declared.join(", "),
1193 )))
1194 }
1195 }
1196}
1197
1198pub fn validate_rel_shape(
1212 rel_type: &str,
1213 from_type: &str,
1214 to_type: Option<&str>,
1215 schema: &Schema,
1216) -> Result<(), ValidationError> {
1217 let Some(def) = schema.relationship_def(rel_type) else {
1218 return Ok(());
1219 };
1220 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1221 let target_ok = def.target_types.is_empty()
1222 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1223 if source_ok && target_ok {
1224 return Ok(());
1225 }
1226 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1227 let suggestion = suggest_shape_admitting(from_type, to_type, schema);
1228 Err(ValidationError::InvalidRelationshipShape {
1229 rel_type: rel_type.to_string(),
1230 from_type: from_type.to_string(),
1231 to_type: to_for_err,
1232 allowed_source_types: def.source_types.clone(),
1233 allowed_target_types: def.target_types.clone(),
1234 suggestion,
1235 })
1236}
1237
1238#[derive(Debug, Clone)]
1247pub enum CrossMemRelCheck {
1248 Ok,
1252 EdgeNotDeclared,
1256 Invalid(ValidationError),
1261}
1262
1263pub fn validate_cross_mem_edge(
1285 rel_type: &str,
1286 from_type: &str,
1287 to_type: Option<&str>,
1288 source_schema: &Schema,
1289 target_schema_ref: &memstead_schema::SchemaRef,
1290) -> CrossMemRelCheck {
1291 let entries = source_schema.cross_mem_entries(&target_schema_ref.name);
1297 if entries.is_empty() {
1298 return CrossMemRelCheck::EdgeNotDeclared;
1299 }
1300
1301 let Some(def) = entries
1302 .iter()
1303 .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1304 else {
1305 if !entries.iter().any(|e| e.to_schema != "*") {
1313 return CrossMemRelCheck::EdgeNotDeclared;
1314 }
1315 let allowed: Vec<RelationshipHint> = cross_mem_entries_hints(&entries);
1316 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1317 let suggestion = nearest_str_match(rel_type, &candidate_names);
1318 return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1319 input: rel_type.to_string(),
1320 allowed,
1321 suggestion,
1322 });
1323 };
1324
1325 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1326 let target_ok = def.target_types.is_empty()
1327 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1328 if source_ok && target_ok {
1329 return CrossMemRelCheck::Ok;
1330 }
1331 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1332 let suggestion = entries
1333 .iter()
1334 .find_map(|entry| cross_mem_suggest_shape(entry, from_type, to_type));
1335 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1336 rel_type: rel_type.to_string(),
1337 from_type: from_type.to_string(),
1338 to_type: to_for_err,
1339 allowed_source_types: def.source_types.clone(),
1340 allowed_target_types: def.target_types.clone(),
1341 suggestion,
1342 })
1343}
1344
1345fn cross_mem_entries_hints(entries: &[&CrossMemRelationshipEntry]) -> Vec<RelationshipHint> {
1349 let mut out: Vec<RelationshipHint> = Vec::new();
1350 for entry in entries {
1351 for hint in cross_mem_entry_hints(entry) {
1352 if !out.iter().any(|h| h.name == hint.name) {
1353 out.push(hint);
1354 }
1355 }
1356 }
1357 out.sort_by(|a, b| a.name.cmp(&b.name));
1358 out
1359}
1360
1361fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
1366 let mut out: Vec<RelationshipHint> = entry
1367 .definitions
1368 .iter()
1369 .filter(|d| d.name != "_default")
1370 .map(|d| RelationshipHint {
1371 name: d.name.clone(),
1372 when_to_use: d.when_to_use.clone(),
1373 })
1374 .collect();
1375 out.sort_by(|a, b| a.name.cmp(&b.name));
1376 out
1377}
1378
1379fn cross_mem_suggest_shape(
1384 entry: &CrossMemRelationshipEntry,
1385 from_type: &str,
1386 to_type: Option<&str>,
1387) -> Option<RelationshipHint> {
1388 entry
1389 .definitions
1390 .iter()
1391 .filter(|d| d.name != "_default")
1392 .find(|d| cross_mem_def_admits(d, from_type, to_type))
1393 .map(|d| RelationshipHint {
1394 name: d.name.clone(),
1395 when_to_use: d.when_to_use.clone(),
1396 })
1397}
1398
1399fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
1400 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1401 let tgt_ok =
1402 d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1403 src_ok && tgt_ok
1404}
1405
1406fn suggest_shape_admitting(
1412 from_type: &str,
1413 to_type: Option<&str>,
1414 schema: &Schema,
1415) -> Option<RelationshipHint> {
1416 schema
1417 .manifest
1418 .relationships
1419 .definitions
1420 .iter()
1421 .filter(|d| d.name != "_default")
1422 .find(|d| {
1423 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1424 let tgt_ok = d.target_types.is_empty()
1425 || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1426 src_ok && tgt_ok
1427 })
1428 .map(|d| RelationshipHint {
1429 name: d.name.clone(),
1430 when_to_use: d.when_to_use.clone(),
1431 })
1432}
1433
1434fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
1439 let mut out: Vec<RelationshipHint> = schema
1440 .manifest
1441 .relationships
1442 .definitions
1443 .iter()
1444 .filter(|d| d.name != "_default")
1445 .map(|d| RelationshipHint {
1446 name: d.name.clone(),
1447 when_to_use: d.when_to_use.clone(),
1448 })
1449 .collect();
1450 out.sort_by(|a, b| a.name.cmp(&b.name));
1451 out
1452}
1453
1454fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
1459 let noise_floor = (needle.chars().count() / 2).max(1);
1460 let mut best: Option<(usize, String)> = None;
1461 for cand in candidates {
1462 let d = strsim::levenshtein(needle, cand);
1463 if d == 0 || d > noise_floor {
1464 continue;
1465 }
1466 match &best {
1467 Some((bd, _)) if *bd <= d => {}
1468 _ => best = Some((d, cand.clone())),
1469 }
1470 }
1471 best.map(|(_, name)| name)
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476 use super::*;
1477
1478 fn shape_test_schema() -> std::sync::Arc<Schema> {
1483 let manifest_yaml = r#"name: tests-rel-shape
1484version: 0.1.0
1485description: rel-shape test schema
1486when_to_use: tests
1487types:
1488 - step
1489 - decision
1490 - note
1491relationships:
1492 mode: strict
1493 definitions:
1494 - name: PART_OF
1495 description: parent containment
1496 default_weight: 3.0
1497 acyclic: true
1498 - name: USES
1499 description: shape-free reference
1500 default_weight: 1.0
1501 - name: EXECUTES
1502 description: step carries out decision
1503 default_weight: 2.5
1504 source_types: [step]
1505 target_types: [decision]
1506 - name: _default
1507 description: fallback
1508 default_weight: 1.0
1509community:
1510 resolution: 1.0
1511 seed: 42
1512"#;
1513 let body_section = r#"sections:
1514 - key: body
1515 heading: Body
1516 required: true
1517 search_weight: 10.0
1518 catch_all: true
1519 write_rules: []
1520metadata_fields: []
1521title_weight: 100.0
1522text_fields:
1523 - body
1524hierarchy_relationship: PART_OF
1525no_self_loop_relationships: []
1526updatable_fields:
1527 - title
1528 - body
1529health_required_fields:
1530 - body
1531staleness_threshold_days: 90
1532write_rules: []
1533"#;
1534 let make_type =
1535 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1536 std::sync::Arc::new(
1537 memstead_schema::load_schema_from_memory(
1538 manifest_yaml,
1539 &[
1540 ("step".to_string(), make_type("step")),
1541 ("decision".to_string(), make_type("decision")),
1542 ("note".to_string(), make_type("note")),
1543 ],
1544 )
1545 .expect("test schema must load"),
1546 )
1547 }
1548
1549 #[test]
1550 fn reserved_metadata_gate_refuses_the_underscore_namespace_on_set() {
1551 for key in ["_hash", "_tokens", "_anything"] {
1556 assert!(matches!(
1557 validate_reserved_metadata_key(key),
1558 Err(ValidationError::ReadOnlyField { .. })
1559 ));
1560 }
1561 assert!(validate_reserved_metadata_key("level").is_ok());
1562 assert!(matches!(
1563 validate_reserved_metadata_key("type"),
1564 Err(ValidationError::ReadOnlyField { .. })
1565 ));
1566 }
1567
1568 #[test]
1569 fn rel_shape_admits_pair_in_declared_source_target() {
1570 let schema = shape_test_schema();
1571 assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
1573 }
1574
1575 #[test]
1576 fn rel_shape_rejects_violating_source() {
1577 let schema = shape_test_schema();
1578 let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
1580 match err {
1581 ValidationError::InvalidRelationshipShape {
1582 rel_type,
1583 from_type,
1584 to_type,
1585 allowed_source_types,
1586 allowed_target_types,
1587 ..
1588 } => {
1589 assert_eq!(rel_type, "EXECUTES");
1590 assert_eq!(from_type, "note");
1591 assert_eq!(to_type, "decision");
1592 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1593 assert_eq!(allowed_target_types, vec!["decision".to_string()]);
1594 }
1595 other => panic!("expected InvalidRelationshipShape, got {other:?}"),
1596 }
1597 }
1598
1599 #[test]
1600 fn rel_shape_rejects_violating_target() {
1601 let schema = shape_test_schema();
1602 let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
1604 assert!(matches!(
1605 err,
1606 ValidationError::InvalidRelationshipShape { .. }
1607 ));
1608 }
1609
1610 #[test]
1611 fn rel_shape_admits_shape_free_relationship() {
1612 let schema = shape_test_schema();
1613 assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
1615 }
1616
1617 #[test]
1618 fn rel_shape_skips_target_check_when_target_type_unknown() {
1619 let schema = shape_test_schema();
1620 assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
1623 }
1624
1625 #[test]
1626 fn rel_shape_no_op_for_unknown_rel_name() {
1627 let schema = shape_test_schema();
1628 assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
1631 }
1632
1633 fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
1646 let manifest_yaml = r#"name: source-cv
1647version: 0.1.0
1648description: cross-mem source schema
1649when_to_use: tests
1650types:
1651 - step
1652 - decision
1653relationships:
1654 mode: strict
1655 definitions:
1656 - name: IMPLEMENTS
1657 description: intra-mem only
1658 default_weight: 1.0
1659 - name: _default
1660 description: fallback
1661 default_weight: 1.0
1662cross_mem_relationships:
1663 - to_schema: other
1664 definitions:
1665 - name: ADDRESSES
1666 description: outbound shape-pinned
1667 default_weight: 1.0
1668 source_types: [step]
1669 target_types: [requirement]
1670 - name: MENTIONS
1671 description: outbound shape-free
1672 default_weight: 0.5
1673community:
1674 resolution: 1.0
1675 seed: 42
1676"#;
1677 let body_section = r#"sections:
1678 - key: body
1679 heading: Body
1680 required: true
1681 search_weight: 10.0
1682 catch_all: true
1683 write_rules: []
1684metadata_fields: []
1685title_weight: 100.0
1686text_fields:
1687 - body
1688hierarchy_relationship: _default
1689no_self_loop_relationships: []
1690updatable_fields:
1691 - title
1692 - body
1693health_required_fields:
1694 - body
1695staleness_threshold_days: 90
1696write_rules: []
1697"#;
1698 let make_type =
1699 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1700 std::sync::Arc::new(
1701 memstead_schema::load_schema_from_memory(
1702 manifest_yaml,
1703 &[
1704 ("step".to_string(), make_type("step")),
1705 ("decision".to_string(), make_type("decision")),
1706 ],
1707 )
1708 .expect("cross-mem source schema must load"),
1709 )
1710 }
1711
1712 fn other_target_ref() -> memstead_schema::SchemaRef {
1713 memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
1714 }
1715
1716 #[test]
1717 fn cross_mem_admits_declared_shape() {
1718 let src = cross_mem_source_schema();
1719 let target = other_target_ref();
1720 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1721 CrossMemRelCheck::Ok => {}
1722 other => panic!("expected Ok, got {other:?}"),
1723 }
1724 }
1725
1726 #[test]
1727 fn cross_mem_no_matching_entry_returns_edge_not_declared() {
1728 let src = cross_mem_source_schema();
1729 let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
1733 match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
1734 CrossMemRelCheck::EdgeNotDeclared => {}
1735 other => panic!("expected EdgeNotDeclared, got {other:?}"),
1736 }
1737 }
1738
1739 #[test]
1740 fn cross_mem_entry_matches_any_target_version() {
1741 let src = cross_mem_source_schema();
1745 for version in [
1746 semver::Version::new(1, 0, 0),
1747 semver::Version::new(1, 1, 0),
1748 semver::Version::new(2, 5, 0),
1749 ] {
1750 let target = memstead_schema::SchemaRef::new("other", version.clone());
1751 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1752 CrossMemRelCheck::Ok => {}
1753 other => panic!("expected Ok against other@{version}, got {other:?}"),
1754 }
1755 }
1756 }
1757
1758 #[test]
1759 fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
1760 let src = cross_mem_source_schema();
1761 let target = other_target_ref();
1762 match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
1765 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1766 input,
1767 allowed,
1768 ..
1769 }) => {
1770 assert_eq!(input, "IMPLEMENTS");
1771 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
1773 assert!(names.iter().any(|n| n == "ADDRESSES"));
1774 assert!(names.iter().any(|n| n == "MENTIONS"));
1775 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
1777 }
1778 other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
1779 }
1780 }
1781
1782 #[test]
1783 fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
1784 let src = cross_mem_source_schema();
1785 let target = other_target_ref();
1786 match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
1791 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1792 rel_type,
1793 from_type,
1794 allowed_source_types,
1795 allowed_target_types,
1796 ..
1797 }) => {
1798 assert_eq!(rel_type, "ADDRESSES");
1799 assert_eq!(from_type, "decision");
1800 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1801 assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
1802 }
1803 other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
1804 }
1805 }
1806
1807 #[test]
1808 fn cross_mem_shape_free_rel_type_admits_any_pair() {
1809 let src = cross_mem_source_schema();
1810 let target = other_target_ref();
1811 assert!(matches!(
1813 validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
1814 CrossMemRelCheck::Ok
1815 ));
1816 }
1817
1818 fn wildcard_source_schema() -> std::sync::Arc<Schema> {
1822 let manifest_yaml = r#"name: source-wc
1823version: 0.1.0
1824description: wildcard cross-mem source schema
1825when_to_use: tests
1826types:
1827 - step
1828 - decision
1829relationships:
1830 mode: strict
1831 definitions:
1832 - name: SOFT_REF
1833 description: alias-emitted soft reference
1834 default_weight: 0.5
1835 - name: ADDRESSES
1836 description: structural
1837 default_weight: 1.0
1838 - name: _default
1839 description: fallback
1840 default_weight: 1.0
1841alias_target_rel_type: SOFT_REF
1842cross_mem_relationships:
1843 - to_schema: other
1844 definitions:
1845 - name: ADDRESSES
1846 description: structural, per-schema
1847 default_weight: 1.0
1848 source_types: [step]
1849 target_types: [requirement]
1850 - to_schema: "*"
1851 definitions:
1852 - name: SOFT_REF
1853 description: soft reference anywhere
1854 default_weight: 0.5
1855 source_types: [step]
1856community:
1857 resolution: 1.0
1858 seed: 42
1859"#;
1860 let body_section = r#"description: t
1861when_to_use: tests
1862sections:
1863 - key: body
1864 heading: Body
1865 required: true
1866 search_weight: 10.0
1867 catch_all: true
1868 write_rules: []
1869metadata_fields: []
1870title_weight: 100.0
1871text_fields:
1872 - body
1873hierarchy_relationship: _default
1874no_self_loop_relationships: []
1875updatable_fields:
1876 - title
1877 - body
1878health_required_fields:
1879 - body
1880staleness_threshold_days: 90
1881write_rules: []
1882"#;
1883 let types = vec![
1884 ("step".to_string(), format!("name: step\n{body_section}")),
1885 (
1886 "decision".to_string(),
1887 format!("name: decision\n{body_section}"),
1888 ),
1889 ];
1890 std::sync::Arc::new(
1891 memstead_schema::load_schema_from_memory(manifest_yaml, &types)
1892 .expect("wildcard schema loads"),
1893 )
1894 }
1895
1896 #[test]
1900 fn cross_mem_wildcard_admits_alias_edge_to_any_schema() {
1901 let src = wildcard_source_schema();
1902 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1904 assert!(matches!(
1905 validate_cross_mem_edge("SOFT_REF", "step", Some("argument"), &src, &user),
1906 CrossMemRelCheck::Ok
1907 ));
1908 let other = memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0));
1910 assert!(matches!(
1911 validate_cross_mem_edge("SOFT_REF", "step", Some("requirement"), &src, &other),
1912 CrossMemRelCheck::Ok
1913 ));
1914 assert!(matches!(
1915 validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &other),
1916 CrossMemRelCheck::Ok
1917 ));
1918 }
1919
1920 #[test]
1925 fn cross_mem_wildcard_keeps_source_type_gate_and_structural_refusal() {
1926 let src = wildcard_source_schema();
1927 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1928 match validate_cross_mem_edge("SOFT_REF", "decision", Some("argument"), &src, &user) {
1930 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1931 from_type,
1932 allowed_source_types,
1933 ..
1934 }) => {
1935 assert_eq!(from_type, "decision");
1936 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1937 }
1938 other => panic!("expected shape refusal on source-type gate, got {other:?}"),
1939 }
1940 assert!(matches!(
1944 validate_cross_mem_edge("ADDRESSES", "step", Some("argument"), &src, &user),
1945 CrossMemRelCheck::EdgeNotDeclared
1946 ));
1947 }
1948
1949 #[test]
1954 fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
1955 let err = ValidationError::UnknownSection {
1956 key: "implimentation".to_string(),
1957 entity_type: "spec".to_string(),
1958 declared: (0..8).map(|i| format!("sec{i}")).collect(),
1959 suggestion: Some("sec0".to_string()),
1960 };
1961 let prose = err.prose_render();
1962 for d in (0..8).map(|i| format!("sec{i}")) {
1963 assert!(prose.contains(&d), "missing {d} in: {prose}");
1964 }
1965 assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
1966 assert!(!prose.contains("see details"), "got: {prose}");
1967 }
1968
1969 #[test]
1970 fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
1971 let err = ValidationError::InvalidEnumValue {
1972 field: "level".to_string(),
1973 value: "M7".to_string(),
1974 allowed: (0..7).map(|i| format!("M{i}")).collect(),
1975 field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
1976 suggestion: Some("M6".to_string()),
1977 type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
1978 entity_type: "spec".to_string(),
1979 };
1980 let prose = err.prose_render();
1981 assert!(prose.contains("M0"), "got: {prose}");
1982 assert!(prose.contains("M6"), "got: {prose}");
1983 assert!(
1984 prose.contains("maturity rung"),
1985 "field_description missing: {prose}"
1986 );
1987 assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
1988 assert!(
1989 prose.contains("specs land at M0"),
1990 "type_write_rules missing: {prose}"
1991 );
1992 assert!(!prose.contains("see details"), "got: {prose}");
1993 }
1994
1995 #[test]
1996 fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
1997 let err = ValidationError::InvalidRelationshipShape {
1998 rel_type: "OWNS".to_string(),
1999 from_type: "spec".to_string(),
2000 to_type: "spec".to_string(),
2001 allowed_source_types: vec!["actor".to_string()],
2002 allowed_target_types: vec![],
2003 suggestion: None,
2004 };
2005 let prose = err.prose_render();
2006 assert!(prose.contains("allowed sources: actor"), "got: {prose}");
2010 assert!(prose.contains("allowed targets: any"), "got: {prose}");
2011 assert!(!prose.contains("see details"), "got: {prose}");
2012 }
2013
2014 fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
2026 let manifest_yaml = r#"name: tests-typed-fields
2027version: 0.1.0
2028description: typed-field test schema
2029when_to_use: tests
2030types:
2031 - widget
2032relationships:
2033 mode: strict
2034 definitions:
2035 - name: _default
2036 description: fallback
2037 default_weight: 1.0
2038community:
2039 resolution: 1.0
2040 seed: 42
2041"#;
2042 let type_yaml = r#"name: widget
2043description: t
2044when_to_use: Here
2045sections:
2046 - key: body
2047 heading: Body
2048 required: true
2049 search_weight: 10.0
2050 catch_all: true
2051 write_rules: []
2052metadata_fields:
2053 - key: verified_on
2054 description: ISO YYYY-MM-DD date the widget was verified
2055 field_type: date
2056 optional: true
2057 - key: order
2058 description: numeric ordering within a plan
2059 field_type: number
2060 optional: true
2061 - key: note
2062 description: free-form note
2063 field_type: string
2064 optional: true
2065title_weight: 100.0
2066text_fields:
2067 - body
2068hierarchy_relationship: _default
2069no_self_loop_relationships: []
2070updatable_fields:
2071 - title
2072 - body
2073health_required_fields:
2074 - body
2075staleness_threshold_days: 90
2076write_rules: []
2077"#;
2078 let schema = memstead_schema::load_schema_from_memory(
2079 manifest_yaml,
2080 &[("widget".to_string(), type_yaml.to_string())],
2081 )
2082 .expect("typed-field test schema must load");
2083 schema.get_type("widget").expect("widget type present")
2084 }
2085
2086 #[test]
2087 fn date_field_rejects_non_date_value() {
2088 let ty = typed_field_type();
2089 let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
2090 assert_eq!(err.code(), "INVALID_FIELD_VALUE");
2091 match err {
2092 ValidationError::InvalidFieldValue {
2093 field,
2094 value,
2095 expected_type,
2096 entity_type,
2097 ..
2098 } => {
2099 assert_eq!(field, "verified_on");
2100 assert_eq!(value, "not-a-real-date");
2101 assert_eq!(expected_type, "Date");
2102 assert_eq!(entity_type, "widget");
2103 }
2104 other => panic!("expected InvalidFieldValue, got {other:?}"),
2105 }
2106 }
2107
2108 #[test]
2109 fn date_field_rejects_empty_string() {
2110 let ty = typed_field_type();
2111 let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
2112 assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
2113 }
2114
2115 #[test]
2116 fn date_field_accepts_iso_date_and_datetime() {
2117 let ty = typed_field_type();
2118 match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
2119 MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
2120 other => panic!("expected String, got {other:?}"),
2121 }
2122 assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
2124 }
2125
2126 #[test]
2127 fn number_field_rejects_non_numeric_value() {
2128 let ty = typed_field_type();
2129 let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
2130 match err {
2131 ValidationError::InvalidFieldValue {
2132 field,
2133 expected_type,
2134 ..
2135 } => {
2136 assert_eq!(field, "order");
2137 assert_eq!(expected_type, "Number");
2138 }
2139 other => panic!("expected InvalidFieldValue, got {other:?}"),
2140 }
2141 }
2142
2143 #[test]
2144 fn number_field_accepts_integer_and_float() {
2145 let ty = typed_field_type();
2146 assert!(matches!(
2147 parse_metadata_value("order", "3", &ty).unwrap(),
2148 MetadataValue::Integer(3)
2149 ));
2150 assert!(matches!(
2151 parse_metadata_value("order", "2.5", &ty).unwrap(),
2152 MetadataValue::Float(_)
2153 ));
2154 }
2155
2156 #[test]
2157 fn string_field_accepts_any_value() {
2158 let ty = typed_field_type();
2159 assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
2161 }
2162
2163 #[test]
2164 fn invalid_field_value_prose_inlines_format_and_purpose() {
2165 let err = ValidationError::InvalidFieldValue {
2166 field: "verified_on".to_string(),
2167 value: "not-a-real-date".to_string(),
2168 expected_type: "Date".to_string(),
2169 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
2170 field_description: Some("date the widget was verified".to_string()),
2171 entity_type: "widget".to_string(),
2172 };
2173 let prose = err.prose_render();
2174 assert!(prose.contains("not-a-real-date"), "got: {prose}");
2175 assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
2176 assert!(
2177 prose.contains("date the widget was verified"),
2178 "purpose missing: {prose}"
2179 );
2180 assert!(!prose.contains("see details"), "got: {prose}");
2181 }
2182
2183 #[test]
2184 fn is_date_shaped_matches_strict_validator_contract() {
2185 assert!(is_date_shaped("2024-06-01"));
2186 assert!(is_date_shaped("2024-06-01T12:30:00Z"));
2187 assert!(!is_date_shaped(""));
2188 assert!(!is_date_shaped("not-a-real-date"));
2189 assert!(!is_date_shaped("2024-6-1"));
2190 assert!(!is_date_shaped("2024-06-01 extra"));
2191 }
2192
2193 #[test]
2194 fn section_content_refuses_nul_byte() {
2195 let err = validate_section_content([("body", "line1\u{0}line2")].into_iter(), None)
2196 .expect_err("NUL in a section body must be refused");
2197 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2198 match &err {
2199 ValidationError::SectionContentControlByte {
2200 section,
2201 control_char,
2202 codepoint,
2203 byte_offset,
2204 } => {
2205 assert_eq!(section, "body");
2206 assert_eq!(*control_char, '\u{0}');
2207 assert_eq!(*codepoint, 0);
2208 assert_eq!(*byte_offset, 5);
2210 }
2211 other => panic!("expected SectionContentControlByte, got {other:?}"),
2212 }
2213 let details = err.details();
2215 assert_eq!(details["codepoint"], 0);
2216 assert_eq!(details["byte_offset"], 5);
2217 assert_eq!(details["section"], "body");
2218 }
2219
2220 #[test]
2221 fn section_content_refuses_other_c0_controls_and_cr() {
2222 for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
2225 let body = format!("ok{bad}more");
2226 let err = validate_section_content([("s", body.as_str())].into_iter(), None)
2227 .expect_err("control char must be refused");
2228 assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
2229 }
2230 }
2231
2232 #[test]
2233 fn section_content_allows_tab_and_newline() {
2234 validate_section_content(
2237 [("body", "line1\nline2\n\tindented\tcols\n")].into_iter(),
2238 None,
2239 )
2240 .expect("tab and newline must stay legal in section bodies");
2241 }
2242
2243 #[test]
2248 fn the_catch_all_accepts_back_the_value_the_engine_emits() {
2249 let declared = ["Body", "Notes"];
2250 let ctx = CatchAllContext {
2251 key: "notes",
2252 entity_type: "doc",
2253 declared_headings: &declared,
2254 };
2255 validate_section_content(
2257 [("notes", "## Field Notes\n\nsomething useful\n")].into_iter(),
2258 Some(ctx),
2259 )
2260 .expect("the catch-all re-absorbs an undeclared heading, so writing it back is safe");
2261 }
2262
2263 #[test]
2268 fn the_catch_all_exemption_does_not_weaken_the_guard() {
2269 let declared = ["Body", "Notes"];
2270 let ctx = CatchAllContext {
2271 key: "notes",
2272 entity_type: "doc",
2273 declared_headings: &declared,
2274 };
2275 let err = validate_section_content(
2276 [("notes", "## Body\n\nthis would move to `body` on reparse\n")].into_iter(),
2277 Some(ctx),
2278 )
2279 .expect_err("a declared heading inside the catch-all forks the entity");
2280 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2281
2282 let err = validate_section_content([("body", "## Anything\n")].into_iter(), Some(ctx))
2284 .expect_err("only the catch-all absorbs; every other section still forks");
2285 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2286
2287 let err = validate_section_content([("notes", "# A Title\n")].into_iter(), Some(ctx))
2290 .expect_err("h1 is the entity's title level");
2291 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2292 }
2293
2294 #[test]
2300 fn content_that_would_hide_a_delimiter_is_refused() {
2301 let err = validate_section_content([("body", "```rust\nfn main() {}")].into_iter(), None)
2305 .expect_err("an unterminated fence must be refused");
2306 assert_eq!(err.code(), "UNTERMINATED_FENCE");
2307 assert_eq!(err.details()["section"], "body");
2308 assert_eq!(err.details()["fence"], "```");
2309 let err = validate_section_content([("body", "~~~\nopen")].into_iter(), None)
2311 .expect_err("tilde fences too");
2312 assert_eq!(err.details()["fence"], "~~~");
2313 let err = validate_section_content([("body", "````\n```\nstill inside")].into_iter(), None)
2314 .expect_err("a longer opener needs a longer closer");
2315 assert_eq!(err.details()["fence"], "````");
2316 }
2317
2318 #[test]
2319 fn a_closed_fence_around_headings_is_admitted_unchanged() {
2320 validate_section_content(
2324 [(
2325 "body",
2326 "prose\n\n```md\n## Not A Section\n# Nor This\n```\n\nmore prose",
2327 )]
2328 .into_iter(),
2329 None,
2330 )
2331 .expect("a closed fence containing heading lines is ordinary content");
2332 validate_section_content([("body", "```\n## Hidden\n```")].into_iter(), None)
2334 .expect("closed is closed, wherever the closer sits");
2335 validate_section_content([("body", "> ```\n> quoted")].into_iter(), None)
2337 .expect("a blockquote's fence cannot reach past the quote");
2338 }
2339
2340 #[test]
2341 fn an_ordinary_body_is_untouched_by_the_fence_guard() {
2342 validate_section_content(
2344 [("body", "plain prose\nwith lines\n\nand a paragraph")].into_iter(),
2345 None,
2346 )
2347 .expect("content with no fence at all cannot trip a fence guard");
2348 }
2349
2350 #[test]
2351 fn an_empty_undeclared_heading_is_refused_at_the_write() {
2352 let declared = ["Body", "Notes"];
2353 let ctx = CatchAllContext {
2354 key: "notes",
2355 entity_type: "doc",
2356 declared_headings: &declared,
2357 };
2358 for (body, label) in [
2359 ("## Scratch\n", "bare heading, nothing after it"),
2360 (
2361 "## Scratch\n\n \n",
2362 "heading followed only by blank lines",
2363 ),
2364 (
2365 "## Scratch\n\n## Other\n\nreal content\n",
2366 "heading with the next heading under it",
2367 ),
2368 ] {
2369 let err = validate_section_content([("notes", body)].into_iter(), Some(ctx))
2370 .expect_err(label);
2371 assert_eq!(err.code(), "EMPTY_UNDECLARED_HEADING", "{label}");
2372 let d = err.details();
2373 assert_eq!(d["heading"], "Scratch", "{label}");
2374 assert_eq!(d["entity_type"], "doc", "{label}");
2375 }
2376 }
2377
2378 #[test]
2382 fn an_undeclared_heading_with_a_body_is_not_refused() {
2383 let declared = ["Body", "Notes"];
2384 let ctx = CatchAllContext {
2385 key: "notes",
2386 entity_type: "doc",
2387 declared_headings: &declared,
2388 };
2389 validate_section_content(
2390 [("notes", "## Scratch\n\nsomething\n")].into_iter(),
2391 Some(ctx),
2392 )
2393 .expect("content under the heading survives, so the write is accepted");
2394 }
2395
2396 #[test]
2397 fn section_content_keeps_backslashes_verbatim() {
2398 validate_section_content(
2402 [("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
2403 None,
2404 )
2405 .expect("backslashes are literal content, not control bytes");
2406 }
2407
2408 #[test]
2409 fn section_content_still_refuses_heading_injection() {
2410 let err =
2413 validate_section_content([("body", "intro\n## Injected\ntail")].into_iter(), None)
2414 .expect_err("embedded `## ` heading must still be refused");
2415 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2416 assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
2417 }
2418
2419 #[test]
2424 fn section_content_admits_a_heading_inside_a_code_block() {
2425 for body in [
2426 "intro\n\n```\n## Not A Heading\n```\n",
2427 "intro\n\n~~~\n## Not A Heading\n~~~\n",
2428 "intro\n\n> ```\n> ## Not A Heading\n> ```\n",
2429 "intro\n\n ## Not A Heading\n",
2430 ] {
2431 validate_section_content([("body", body)].into_iter(), None)
2432 .unwrap_or_else(|e| panic!("code-block content must be admitted: {body:?} -> {e}"));
2433 }
2434 }
2435
2436 #[test]
2441 fn section_content_refuses_the_trim_fork() {
2442 let err = validate_section_content(
2443 [("body", " ## Not A Heading\n more\n")].into_iter(),
2444 None,
2445 )
2446 .expect_err("content whose trim exposes a column-0 heading must be refused");
2447 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2448 match err {
2449 ValidationError::SectionContentInvalid {
2450 embedded_heading, ..
2451 } => assert_eq!(
2452 embedded_heading, "## Not A Heading",
2453 "the refusal quotes the line the reparse will see"
2454 ),
2455 other => panic!("unexpected error: {other}"),
2456 }
2457 }
2458
2459 #[test]
2460 fn section_content_refuses_the_trim_fork_for_h1_too() {
2461 let err = validate_section_content([("body", " # Not A Title\n")].into_iter(), None)
2462 .expect_err("h1 exposed by the trim must be refused");
2463 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2464 }
2465}