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