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> {
646 if READ_ONLY_METADATA_KEYS.contains(&key) {
647 return Err(ValidationError::ReadOnlyField {
648 field: key.to_string(),
649 });
650 }
651 Ok(())
652}
653
654pub fn validate_writable_metadata_key(
665 key: &str,
666 schema: &TypeDefinition,
667) -> Result<(), ValidationError> {
668 validate_reserved_metadata_key(key)?;
669 if let Some(field) = schema.metadata_field(key)
670 && (field.init_timestamp || field.auto_timestamp)
671 {
672 return Err(ValidationError::ReadOnlyField {
673 field: key.to_string(),
674 });
675 }
676 Ok(())
677}
678
679pub fn validate_unsettable_metadata_key(
691 key: &str,
692 schema: &TypeDefinition,
693) -> Result<(), ValidationError> {
694 if let Some(field) = schema.metadata_field(key)
695 && (field.init_timestamp || field.auto_timestamp)
696 {
697 return Err(ValidationError::ReadOnlyField {
698 field: key.to_string(),
699 });
700 }
701 Ok(())
702}
703
704pub fn validate_updatable_section(
710 section: &str,
711 schema: &TypeDefinition,
712) -> Result<(), ValidationError> {
713 if section == "relationships" {
714 return Err(ValidationError::SectionNotUpdatable {
715 section: section.to_string(),
716 entity_type: schema.name.clone(),
717 });
718 }
719 if !schema.updatable_fields.is_empty() && !schema.updatable_fields.iter().any(|f| f == section)
720 {
721 return Err(ValidationError::SectionNotUpdatable {
722 section: section.to_string(),
723 entity_type: schema.name.clone(),
724 });
725 }
726 Ok(())
727}
728
729#[derive(Debug, Clone)]
736pub struct MissingRequiredSection {
737 pub entity_type: String,
738 pub key: String,
739 pub heading: String,
740 pub write_rules: Vec<String>,
741}
742
743#[derive(Debug, Clone, Copy)]
745pub struct CatchAllContext<'a> {
746 pub key: &'a str,
748 pub entity_type: &'a str,
751 pub declared_headings: &'a [&'a str],
754}
755
756pub fn catch_all_context<'a>(
759 type_def: &'a memstead_schema::TypeDefinition,
760 buf: &'a mut Vec<&'a str>,
761) -> Option<CatchAllContext<'a>> {
762 let key = type_def.catch_all_section()?.key.as_str();
763 buf.extend(type_def.sections.iter().map(|s| s.heading.as_str()));
764 Some(CatchAllContext {
765 key,
766 entity_type: type_def.name.as_str(),
767 declared_headings: buf,
768 })
769}
770
771fn heading_body_is_empty(body: &str, heading_line: &str) -> bool {
774 let mut lines = body.lines().skip_while(|l| *l != heading_line);
775 lines.next();
776 for line in lines {
777 if line.starts_with("## ") {
778 return true;
779 }
780 if !line.trim().is_empty() {
781 return false;
782 }
783 }
784 true
785}
786
787pub fn validate_section_content<'a>(
825 sections: impl Iterator<Item = (&'a str, &'a str)>,
826 catch_all: Option<CatchAllContext<'_>>,
827) -> Result<(), ValidationError> {
828 for (key, value) in sections {
829 if let Some((byte_offset, ch)) = value
841 .char_indices()
842 .find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
843 {
844 return Err(ValidationError::SectionContentControlByte {
845 section: key.to_string(),
846 control_char: ch,
847 codepoint: ch as u32,
848 byte_offset,
849 });
850 }
851 if let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) {
856 return Err(ValidationError::UnterminatedFence {
857 section: key.to_string(),
858 fence,
859 });
860 }
861 let stored = value.trim();
866 let masked = crate::markdown::mask_code_blocks(stored);
867 for (line, masked_line) in stored.lines().zip(masked.lines()) {
868 let is_h2 = masked_line.starts_with("## ") && masked_line.len() > 3;
876 let is_h1 = masked_line.starts_with("# ") && masked_line.len() > 2;
877 if is_h2
885 && catch_all.is_some_and(|c| {
886 c.key == key && !c.declared_headings.contains(&&masked_line[3..])
887 })
888 {
889 if heading_body_is_empty(stored, line) {
890 return Err(ValidationError::EmptyUndeclaredHeading {
891 section: key.to_string(),
892 heading: masked_line[3..].to_string(),
893 entity_type: catch_all
894 .map(|c| c.entity_type.to_string())
895 .unwrap_or_default(),
896 });
897 }
898 continue;
899 }
900 if is_h2 || is_h1 {
901 return Err(ValidationError::SectionContentInvalid {
902 section: key.to_string(),
903 embedded_heading: line.to_string(),
904 });
905 }
906 }
907 }
908 Ok(())
909}
910
911pub fn validate_section_keys<'a>(
921 provided: impl Iterator<Item = &'a str>,
922 schema: &TypeDefinition,
923) -> Result<(), ValidationError> {
924 let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
925 declared.sort();
926 let declared_set: std::collections::HashSet<&str> =
927 schema.sections.iter().map(|s| s.key.as_str()).collect();
928 let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
929
930 for key in provided {
931 if key == "relationships" {
932 continue;
933 }
934 if declared_set.contains(key) {
935 continue;
936 }
937 let suggestion = schema
938 .suggest_section(key)
939 .or_else(|| catch_all_key.clone());
940 return Err(ValidationError::UnknownSection {
941 key: key.to_string(),
942 entity_type: schema.name.clone(),
943 declared: declared.clone(),
944 suggestion,
945 });
946 }
947 Ok(())
948}
949
950pub fn parse_metadata_value(
960 key: &str,
961 value: &str,
962 schema: &TypeDefinition,
963) -> Result<MetadataValue, ValidationError> {
964 let Some(field_def) = schema.metadata_field(key) else {
965 let mut declared: Vec<String> = schema
966 .metadata_fields
967 .iter()
968 .map(|f| f.key.clone())
969 .collect();
970 declared.sort();
971 return Err(ValidationError::UnknownMetadata {
972 key: key.to_string(),
973 entity_type: schema.name.clone(),
974 declared,
975 suggestion: schema.suggest_metadata_field(key),
976 });
977 };
978
979 if let Some(ref allowed) = field_def.enum_values
980 && !allowed.iter().any(|v| v == value)
981 {
982 let suggestion = nearest_str_match(value, allowed);
983 return Err(ValidationError::InvalidEnumValue {
984 field: key.to_string(),
985 value: value.to_string(),
986 allowed: allowed.clone(),
987 field_description: Some(field_def.description.clone()),
988 suggestion,
989 type_write_rules: schema.write_rules.clone(),
990 entity_type: schema.name.clone(),
991 });
992 }
993
994 Ok(match field_def.field_type {
995 FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
996 FieldType::Number => {
997 if let Ok(n) = value.parse::<i64>() {
998 MetadataValue::Integer(n)
999 } else if let Ok(f) = value.parse::<f64>() {
1000 MetadataValue::Float(f)
1001 } else {
1002 return Err(ValidationError::InvalidFieldValue {
1007 field: key.to_string(),
1008 value: value.to_string(),
1009 expected_type: "Number".to_string(),
1010 expected_format: Some("an integer or decimal number".to_string()),
1011 field_description: Some(field_def.description.clone()),
1012 entity_type: schema.name.clone(),
1013 });
1014 }
1015 }
1016 FieldType::Date => {
1017 if !is_date_shaped(value) {
1025 return Err(ValidationError::InvalidFieldValue {
1026 field: key.to_string(),
1027 value: value.to_string(),
1028 expected_type: "Date".to_string(),
1029 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
1030 field_description: Some(field_def.description.clone()),
1031 entity_type: schema.name.clone(),
1032 });
1033 }
1034 MetadataValue::String(value.to_string())
1035 }
1036 _ => MetadataValue::String(value.to_string()),
1037 })
1038}
1039
1040pub fn is_date_shaped(s: &str) -> bool {
1049 static RE: OnceLock<Regex> = OnceLock::new();
1050 RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
1051 .is_match(s)
1052}
1053
1054#[derive(Debug, Clone)]
1062pub struct MissingRequiredField {
1063 pub entity_type: String,
1064 pub key: String,
1065 pub description: String,
1066 pub enum_values: Vec<String>,
1067}
1068
1069pub fn missing_required_fields(
1080 schema: &TypeDefinition,
1081 supplied: &IndexMap<String, String>,
1082) -> Vec<MissingRequiredField> {
1083 schema
1084 .metadata_fields
1085 .iter()
1086 .filter(|f| {
1087 !READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
1091 && f.is_required()
1092 && f.default_value.is_none()
1093 && !f.init_timestamp
1094 && !f.auto_timestamp
1095 && !supplied.contains_key(f.key.as_str())
1096 })
1097 .map(|f| MissingRequiredField {
1098 entity_type: schema.name.clone(),
1099 key: f.key.clone(),
1100 description: f.description.clone(),
1101 enum_values: f.enum_values.clone().unwrap_or_default(),
1102 })
1103 .collect()
1104}
1105
1106pub fn missing_required_sections(
1110 schema: &TypeDefinition,
1111 sections: &IndexMap<String, String>,
1112) -> Vec<MissingRequiredSection> {
1113 schema
1114 .required_sections()
1115 .filter_map(|sec| {
1116 let is_empty = sections
1117 .get(sec.key.as_str())
1118 .is_none_or(|c| c.trim().is_empty());
1119 is_empty.then(|| MissingRequiredSection {
1120 entity_type: schema.name.clone(),
1121 key: sec.key.clone(),
1122 heading: sec.heading.clone(),
1123 write_rules: sec.write_rules.clone(),
1124 })
1125 })
1126 .collect()
1127}
1128
1129#[derive(Debug, Clone)]
1134pub enum RelationshipCheck {
1135 Ok,
1137 OpenWarning(String),
1140}
1141
1142pub fn validate_rel_type(
1152 rel_type: &str,
1153 schema: &Schema,
1154) -> Result<RelationshipCheck, ValidationError> {
1155 if schema.relationship_known(rel_type) {
1156 return Ok(RelationshipCheck::Ok);
1157 }
1158 match schema.mode() {
1159 RelationshipMode::Strict => {
1160 let allowed = declared_relationship_hints(schema);
1161 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1162 let suggestion = nearest_str_match(rel_type, &candidate_names);
1163 Err(ValidationError::InvalidRelationshipType {
1164 input: rel_type.to_string(),
1165 allowed,
1166 suggestion,
1167 })
1168 }
1169 RelationshipMode::Open => {
1170 let declared: Vec<String> = declared_relationship_hints(schema)
1171 .into_iter()
1172 .map(|h| h.name)
1173 .collect();
1174 let suggestion = schema
1175 .suggest_relationship(rel_type)
1176 .map(|s| format!(" Did you mean '{s}'?"))
1177 .unwrap_or_default();
1178 let (schema_name, schema_version) = schema.id();
1179 Ok(RelationshipCheck::OpenWarning(format!(
1180 "relationship '{rel_type}' is not declared in schema \
1181 '{schema_name}@{schema_version}' (mode: open). \
1182 Accepted with default weight. Declared: [{}].{suggestion}",
1183 declared.join(", "),
1184 )))
1185 }
1186 }
1187}
1188
1189pub fn validate_rel_shape(
1203 rel_type: &str,
1204 from_type: &str,
1205 to_type: Option<&str>,
1206 schema: &Schema,
1207) -> Result<(), ValidationError> {
1208 let Some(def) = schema.relationship_def(rel_type) else {
1209 return Ok(());
1210 };
1211 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1212 let target_ok = def.target_types.is_empty()
1213 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1214 if source_ok && target_ok {
1215 return Ok(());
1216 }
1217 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1218 let suggestion = suggest_shape_admitting(from_type, to_type, schema);
1219 Err(ValidationError::InvalidRelationshipShape {
1220 rel_type: rel_type.to_string(),
1221 from_type: from_type.to_string(),
1222 to_type: to_for_err,
1223 allowed_source_types: def.source_types.clone(),
1224 allowed_target_types: def.target_types.clone(),
1225 suggestion,
1226 })
1227}
1228
1229#[derive(Debug, Clone)]
1238pub enum CrossMemRelCheck {
1239 Ok,
1243 EdgeNotDeclared,
1247 Invalid(ValidationError),
1252}
1253
1254pub fn validate_cross_mem_edge(
1276 rel_type: &str,
1277 from_type: &str,
1278 to_type: Option<&str>,
1279 source_schema: &Schema,
1280 target_schema_ref: &memstead_schema::SchemaRef,
1281) -> CrossMemRelCheck {
1282 let entries = source_schema.cross_mem_entries(&target_schema_ref.name);
1288 if entries.is_empty() {
1289 return CrossMemRelCheck::EdgeNotDeclared;
1290 }
1291
1292 let Some(def) = entries
1293 .iter()
1294 .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1295 else {
1296 if !entries.iter().any(|e| e.to_schema != "*") {
1304 return CrossMemRelCheck::EdgeNotDeclared;
1305 }
1306 let allowed: Vec<RelationshipHint> = cross_mem_entries_hints(&entries);
1307 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1308 let suggestion = nearest_str_match(rel_type, &candidate_names);
1309 return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1310 input: rel_type.to_string(),
1311 allowed,
1312 suggestion,
1313 });
1314 };
1315
1316 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1317 let target_ok = def.target_types.is_empty()
1318 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1319 if source_ok && target_ok {
1320 return CrossMemRelCheck::Ok;
1321 }
1322 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1323 let suggestion = entries
1324 .iter()
1325 .find_map(|entry| cross_mem_suggest_shape(entry, from_type, to_type));
1326 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1327 rel_type: rel_type.to_string(),
1328 from_type: from_type.to_string(),
1329 to_type: to_for_err,
1330 allowed_source_types: def.source_types.clone(),
1331 allowed_target_types: def.target_types.clone(),
1332 suggestion,
1333 })
1334}
1335
1336fn cross_mem_entries_hints(entries: &[&CrossMemRelationshipEntry]) -> Vec<RelationshipHint> {
1340 let mut out: Vec<RelationshipHint> = Vec::new();
1341 for entry in entries {
1342 for hint in cross_mem_entry_hints(entry) {
1343 if !out.iter().any(|h| h.name == hint.name) {
1344 out.push(hint);
1345 }
1346 }
1347 }
1348 out.sort_by(|a, b| a.name.cmp(&b.name));
1349 out
1350}
1351
1352fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
1357 let mut out: Vec<RelationshipHint> = entry
1358 .definitions
1359 .iter()
1360 .filter(|d| d.name != "_default")
1361 .map(|d| RelationshipHint {
1362 name: d.name.clone(),
1363 when_to_use: d.when_to_use.clone(),
1364 })
1365 .collect();
1366 out.sort_by(|a, b| a.name.cmp(&b.name));
1367 out
1368}
1369
1370fn cross_mem_suggest_shape(
1375 entry: &CrossMemRelationshipEntry,
1376 from_type: &str,
1377 to_type: Option<&str>,
1378) -> Option<RelationshipHint> {
1379 entry
1380 .definitions
1381 .iter()
1382 .filter(|d| d.name != "_default")
1383 .find(|d| cross_mem_def_admits(d, from_type, to_type))
1384 .map(|d| RelationshipHint {
1385 name: d.name.clone(),
1386 when_to_use: d.when_to_use.clone(),
1387 })
1388}
1389
1390fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
1391 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1392 let tgt_ok =
1393 d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1394 src_ok && tgt_ok
1395}
1396
1397fn suggest_shape_admitting(
1403 from_type: &str,
1404 to_type: Option<&str>,
1405 schema: &Schema,
1406) -> Option<RelationshipHint> {
1407 schema
1408 .manifest
1409 .relationships
1410 .definitions
1411 .iter()
1412 .filter(|d| d.name != "_default")
1413 .find(|d| {
1414 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1415 let tgt_ok = d.target_types.is_empty()
1416 || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1417 src_ok && tgt_ok
1418 })
1419 .map(|d| RelationshipHint {
1420 name: d.name.clone(),
1421 when_to_use: d.when_to_use.clone(),
1422 })
1423}
1424
1425fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
1430 let mut out: Vec<RelationshipHint> = schema
1431 .manifest
1432 .relationships
1433 .definitions
1434 .iter()
1435 .filter(|d| d.name != "_default")
1436 .map(|d| RelationshipHint {
1437 name: d.name.clone(),
1438 when_to_use: d.when_to_use.clone(),
1439 })
1440 .collect();
1441 out.sort_by(|a, b| a.name.cmp(&b.name));
1442 out
1443}
1444
1445fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
1450 let noise_floor = (needle.chars().count() / 2).max(1);
1451 let mut best: Option<(usize, String)> = None;
1452 for cand in candidates {
1453 let d = strsim::levenshtein(needle, cand);
1454 if d == 0 || d > noise_floor {
1455 continue;
1456 }
1457 match &best {
1458 Some((bd, _)) if *bd <= d => {}
1459 _ => best = Some((d, cand.clone())),
1460 }
1461 }
1462 best.map(|(_, name)| name)
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467 use super::*;
1468
1469 fn shape_test_schema() -> std::sync::Arc<Schema> {
1474 let manifest_yaml = r#"name: tests-rel-shape
1475version: 0.1.0
1476description: rel-shape test schema
1477when_to_use: tests
1478types:
1479 - step
1480 - decision
1481 - note
1482relationships:
1483 mode: strict
1484 definitions:
1485 - name: PART_OF
1486 description: parent containment
1487 default_weight: 3.0
1488 acyclic: true
1489 - name: USES
1490 description: shape-free reference
1491 default_weight: 1.0
1492 - name: EXECUTES
1493 description: step carries out decision
1494 default_weight: 2.5
1495 source_types: [step]
1496 target_types: [decision]
1497 - name: _default
1498 description: fallback
1499 default_weight: 1.0
1500community:
1501 resolution: 1.0
1502 seed: 42
1503"#;
1504 let body_section = r#"sections:
1505 - key: body
1506 heading: Body
1507 required: true
1508 search_weight: 10.0
1509 catch_all: true
1510 write_rules: []
1511metadata_fields: []
1512title_weight: 100.0
1513text_fields:
1514 - body
1515hierarchy_relationship: PART_OF
1516no_self_loop_relationships: []
1517updatable_fields:
1518 - title
1519 - body
1520health_required_fields:
1521 - body
1522staleness_threshold_days: 90
1523write_rules: []
1524"#;
1525 let make_type =
1526 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1527 std::sync::Arc::new(
1528 memstead_schema::load_schema_from_memory(
1529 manifest_yaml,
1530 &[
1531 ("step".to_string(), make_type("step")),
1532 ("decision".to_string(), make_type("decision")),
1533 ("note".to_string(), make_type("note")),
1534 ],
1535 )
1536 .expect("test schema must load"),
1537 )
1538 }
1539
1540 #[test]
1541 fn rel_shape_admits_pair_in_declared_source_target() {
1542 let schema = shape_test_schema();
1543 assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
1545 }
1546
1547 #[test]
1548 fn rel_shape_rejects_violating_source() {
1549 let schema = shape_test_schema();
1550 let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
1552 match err {
1553 ValidationError::InvalidRelationshipShape {
1554 rel_type,
1555 from_type,
1556 to_type,
1557 allowed_source_types,
1558 allowed_target_types,
1559 ..
1560 } => {
1561 assert_eq!(rel_type, "EXECUTES");
1562 assert_eq!(from_type, "note");
1563 assert_eq!(to_type, "decision");
1564 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1565 assert_eq!(allowed_target_types, vec!["decision".to_string()]);
1566 }
1567 other => panic!("expected InvalidRelationshipShape, got {other:?}"),
1568 }
1569 }
1570
1571 #[test]
1572 fn rel_shape_rejects_violating_target() {
1573 let schema = shape_test_schema();
1574 let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
1576 assert!(matches!(
1577 err,
1578 ValidationError::InvalidRelationshipShape { .. }
1579 ));
1580 }
1581
1582 #[test]
1583 fn rel_shape_admits_shape_free_relationship() {
1584 let schema = shape_test_schema();
1585 assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
1587 }
1588
1589 #[test]
1590 fn rel_shape_skips_target_check_when_target_type_unknown() {
1591 let schema = shape_test_schema();
1592 assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
1595 }
1596
1597 #[test]
1598 fn rel_shape_no_op_for_unknown_rel_name() {
1599 let schema = shape_test_schema();
1600 assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
1603 }
1604
1605 fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
1618 let manifest_yaml = r#"name: source-cv
1619version: 0.1.0
1620description: cross-mem source schema
1621when_to_use: tests
1622types:
1623 - step
1624 - decision
1625relationships:
1626 mode: strict
1627 definitions:
1628 - name: IMPLEMENTS
1629 description: intra-mem only
1630 default_weight: 1.0
1631 - name: _default
1632 description: fallback
1633 default_weight: 1.0
1634cross_mem_relationships:
1635 - to_schema: other
1636 definitions:
1637 - name: ADDRESSES
1638 description: outbound shape-pinned
1639 default_weight: 1.0
1640 source_types: [step]
1641 target_types: [requirement]
1642 - name: MENTIONS
1643 description: outbound shape-free
1644 default_weight: 0.5
1645community:
1646 resolution: 1.0
1647 seed: 42
1648"#;
1649 let body_section = r#"sections:
1650 - key: body
1651 heading: Body
1652 required: true
1653 search_weight: 10.0
1654 catch_all: true
1655 write_rules: []
1656metadata_fields: []
1657title_weight: 100.0
1658text_fields:
1659 - body
1660hierarchy_relationship: _default
1661no_self_loop_relationships: []
1662updatable_fields:
1663 - title
1664 - body
1665health_required_fields:
1666 - body
1667staleness_threshold_days: 90
1668write_rules: []
1669"#;
1670 let make_type =
1671 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1672 std::sync::Arc::new(
1673 memstead_schema::load_schema_from_memory(
1674 manifest_yaml,
1675 &[
1676 ("step".to_string(), make_type("step")),
1677 ("decision".to_string(), make_type("decision")),
1678 ],
1679 )
1680 .expect("cross-mem source schema must load"),
1681 )
1682 }
1683
1684 fn other_target_ref() -> memstead_schema::SchemaRef {
1685 memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
1686 }
1687
1688 #[test]
1689 fn cross_mem_admits_declared_shape() {
1690 let src = cross_mem_source_schema();
1691 let target = other_target_ref();
1692 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1693 CrossMemRelCheck::Ok => {}
1694 other => panic!("expected Ok, got {other:?}"),
1695 }
1696 }
1697
1698 #[test]
1699 fn cross_mem_no_matching_entry_returns_edge_not_declared() {
1700 let src = cross_mem_source_schema();
1701 let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
1705 match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
1706 CrossMemRelCheck::EdgeNotDeclared => {}
1707 other => panic!("expected EdgeNotDeclared, got {other:?}"),
1708 }
1709 }
1710
1711 #[test]
1712 fn cross_mem_entry_matches_any_target_version() {
1713 let src = cross_mem_source_schema();
1717 for version in [
1718 semver::Version::new(1, 0, 0),
1719 semver::Version::new(1, 1, 0),
1720 semver::Version::new(2, 5, 0),
1721 ] {
1722 let target = memstead_schema::SchemaRef::new("other", version.clone());
1723 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1724 CrossMemRelCheck::Ok => {}
1725 other => panic!("expected Ok against other@{version}, got {other:?}"),
1726 }
1727 }
1728 }
1729
1730 #[test]
1731 fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
1732 let src = cross_mem_source_schema();
1733 let target = other_target_ref();
1734 match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
1737 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1738 input,
1739 allowed,
1740 ..
1741 }) => {
1742 assert_eq!(input, "IMPLEMENTS");
1743 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
1745 assert!(names.iter().any(|n| n == "ADDRESSES"));
1746 assert!(names.iter().any(|n| n == "MENTIONS"));
1747 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
1749 }
1750 other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
1751 }
1752 }
1753
1754 #[test]
1755 fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
1756 let src = cross_mem_source_schema();
1757 let target = other_target_ref();
1758 match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
1763 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1764 rel_type,
1765 from_type,
1766 allowed_source_types,
1767 allowed_target_types,
1768 ..
1769 }) => {
1770 assert_eq!(rel_type, "ADDRESSES");
1771 assert_eq!(from_type, "decision");
1772 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1773 assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
1774 }
1775 other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
1776 }
1777 }
1778
1779 #[test]
1780 fn cross_mem_shape_free_rel_type_admits_any_pair() {
1781 let src = cross_mem_source_schema();
1782 let target = other_target_ref();
1783 assert!(matches!(
1785 validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
1786 CrossMemRelCheck::Ok
1787 ));
1788 }
1789
1790 fn wildcard_source_schema() -> std::sync::Arc<Schema> {
1794 let manifest_yaml = r#"name: source-wc
1795version: 0.1.0
1796description: wildcard cross-mem source schema
1797when_to_use: tests
1798types:
1799 - step
1800 - decision
1801relationships:
1802 mode: strict
1803 definitions:
1804 - name: SOFT_REF
1805 description: alias-emitted soft reference
1806 default_weight: 0.5
1807 - name: ADDRESSES
1808 description: structural
1809 default_weight: 1.0
1810 - name: _default
1811 description: fallback
1812 default_weight: 1.0
1813alias_target_rel_type: SOFT_REF
1814cross_mem_relationships:
1815 - to_schema: other
1816 definitions:
1817 - name: ADDRESSES
1818 description: structural, per-schema
1819 default_weight: 1.0
1820 source_types: [step]
1821 target_types: [requirement]
1822 - to_schema: "*"
1823 definitions:
1824 - name: SOFT_REF
1825 description: soft reference anywhere
1826 default_weight: 0.5
1827 source_types: [step]
1828community:
1829 resolution: 1.0
1830 seed: 42
1831"#;
1832 let body_section = r#"description: t
1833when_to_use: tests
1834sections:
1835 - key: body
1836 heading: Body
1837 required: true
1838 search_weight: 10.0
1839 catch_all: true
1840 write_rules: []
1841metadata_fields: []
1842title_weight: 100.0
1843text_fields:
1844 - body
1845hierarchy_relationship: _default
1846no_self_loop_relationships: []
1847updatable_fields:
1848 - title
1849 - body
1850health_required_fields:
1851 - body
1852staleness_threshold_days: 90
1853write_rules: []
1854"#;
1855 let types = vec![
1856 ("step".to_string(), format!("name: step\n{body_section}")),
1857 (
1858 "decision".to_string(),
1859 format!("name: decision\n{body_section}"),
1860 ),
1861 ];
1862 std::sync::Arc::new(
1863 memstead_schema::load_schema_from_memory(manifest_yaml, &types)
1864 .expect("wildcard schema loads"),
1865 )
1866 }
1867
1868 #[test]
1872 fn cross_mem_wildcard_admits_alias_edge_to_any_schema() {
1873 let src = wildcard_source_schema();
1874 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1876 assert!(matches!(
1877 validate_cross_mem_edge("SOFT_REF", "step", Some("argument"), &src, &user),
1878 CrossMemRelCheck::Ok
1879 ));
1880 let other = memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0));
1882 assert!(matches!(
1883 validate_cross_mem_edge("SOFT_REF", "step", Some("requirement"), &src, &other),
1884 CrossMemRelCheck::Ok
1885 ));
1886 assert!(matches!(
1887 validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &other),
1888 CrossMemRelCheck::Ok
1889 ));
1890 }
1891
1892 #[test]
1897 fn cross_mem_wildcard_keeps_source_type_gate_and_structural_refusal() {
1898 let src = wildcard_source_schema();
1899 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1900 match validate_cross_mem_edge("SOFT_REF", "decision", Some("argument"), &src, &user) {
1902 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1903 from_type,
1904 allowed_source_types,
1905 ..
1906 }) => {
1907 assert_eq!(from_type, "decision");
1908 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1909 }
1910 other => panic!("expected shape refusal on source-type gate, got {other:?}"),
1911 }
1912 assert!(matches!(
1916 validate_cross_mem_edge("ADDRESSES", "step", Some("argument"), &src, &user),
1917 CrossMemRelCheck::EdgeNotDeclared
1918 ));
1919 }
1920
1921 #[test]
1926 fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
1927 let err = ValidationError::UnknownSection {
1928 key: "implimentation".to_string(),
1929 entity_type: "spec".to_string(),
1930 declared: (0..8).map(|i| format!("sec{i}")).collect(),
1931 suggestion: Some("sec0".to_string()),
1932 };
1933 let prose = err.prose_render();
1934 for d in (0..8).map(|i| format!("sec{i}")) {
1935 assert!(prose.contains(&d), "missing {d} in: {prose}");
1936 }
1937 assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
1938 assert!(!prose.contains("see details"), "got: {prose}");
1939 }
1940
1941 #[test]
1942 fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
1943 let err = ValidationError::InvalidEnumValue {
1944 field: "level".to_string(),
1945 value: "M7".to_string(),
1946 allowed: (0..7).map(|i| format!("M{i}")).collect(),
1947 field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
1948 suggestion: Some("M6".to_string()),
1949 type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
1950 entity_type: "spec".to_string(),
1951 };
1952 let prose = err.prose_render();
1953 assert!(prose.contains("M0"), "got: {prose}");
1954 assert!(prose.contains("M6"), "got: {prose}");
1955 assert!(
1956 prose.contains("maturity rung"),
1957 "field_description missing: {prose}"
1958 );
1959 assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
1960 assert!(
1961 prose.contains("specs land at M0"),
1962 "type_write_rules missing: {prose}"
1963 );
1964 assert!(!prose.contains("see details"), "got: {prose}");
1965 }
1966
1967 #[test]
1968 fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
1969 let err = ValidationError::InvalidRelationshipShape {
1970 rel_type: "OWNS".to_string(),
1971 from_type: "spec".to_string(),
1972 to_type: "spec".to_string(),
1973 allowed_source_types: vec!["actor".to_string()],
1974 allowed_target_types: vec![],
1975 suggestion: None,
1976 };
1977 let prose = err.prose_render();
1978 assert!(prose.contains("allowed sources: actor"), "got: {prose}");
1982 assert!(prose.contains("allowed targets: any"), "got: {prose}");
1983 assert!(!prose.contains("see details"), "got: {prose}");
1984 }
1985
1986 fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
1998 let manifest_yaml = r#"name: tests-typed-fields
1999version: 0.1.0
2000description: typed-field test schema
2001when_to_use: tests
2002types:
2003 - widget
2004relationships:
2005 mode: strict
2006 definitions:
2007 - name: _default
2008 description: fallback
2009 default_weight: 1.0
2010community:
2011 resolution: 1.0
2012 seed: 42
2013"#;
2014 let type_yaml = r#"name: widget
2015description: t
2016when_to_use: Here
2017sections:
2018 - key: body
2019 heading: Body
2020 required: true
2021 search_weight: 10.0
2022 catch_all: true
2023 write_rules: []
2024metadata_fields:
2025 - key: verified_on
2026 description: ISO YYYY-MM-DD date the widget was verified
2027 field_type: date
2028 optional: true
2029 - key: order
2030 description: numeric ordering within a plan
2031 field_type: number
2032 optional: true
2033 - key: note
2034 description: free-form note
2035 field_type: string
2036 optional: true
2037title_weight: 100.0
2038text_fields:
2039 - body
2040hierarchy_relationship: _default
2041no_self_loop_relationships: []
2042updatable_fields:
2043 - title
2044 - body
2045health_required_fields:
2046 - body
2047staleness_threshold_days: 90
2048write_rules: []
2049"#;
2050 let schema = memstead_schema::load_schema_from_memory(
2051 manifest_yaml,
2052 &[("widget".to_string(), type_yaml.to_string())],
2053 )
2054 .expect("typed-field test schema must load");
2055 schema.get_type("widget").expect("widget type present")
2056 }
2057
2058 #[test]
2059 fn date_field_rejects_non_date_value() {
2060 let ty = typed_field_type();
2061 let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
2062 assert_eq!(err.code(), "INVALID_FIELD_VALUE");
2063 match err {
2064 ValidationError::InvalidFieldValue {
2065 field,
2066 value,
2067 expected_type,
2068 entity_type,
2069 ..
2070 } => {
2071 assert_eq!(field, "verified_on");
2072 assert_eq!(value, "not-a-real-date");
2073 assert_eq!(expected_type, "Date");
2074 assert_eq!(entity_type, "widget");
2075 }
2076 other => panic!("expected InvalidFieldValue, got {other:?}"),
2077 }
2078 }
2079
2080 #[test]
2081 fn date_field_rejects_empty_string() {
2082 let ty = typed_field_type();
2083 let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
2084 assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
2085 }
2086
2087 #[test]
2088 fn date_field_accepts_iso_date_and_datetime() {
2089 let ty = typed_field_type();
2090 match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
2091 MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
2092 other => panic!("expected String, got {other:?}"),
2093 }
2094 assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
2096 }
2097
2098 #[test]
2099 fn number_field_rejects_non_numeric_value() {
2100 let ty = typed_field_type();
2101 let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
2102 match err {
2103 ValidationError::InvalidFieldValue {
2104 field,
2105 expected_type,
2106 ..
2107 } => {
2108 assert_eq!(field, "order");
2109 assert_eq!(expected_type, "Number");
2110 }
2111 other => panic!("expected InvalidFieldValue, got {other:?}"),
2112 }
2113 }
2114
2115 #[test]
2116 fn number_field_accepts_integer_and_float() {
2117 let ty = typed_field_type();
2118 assert!(matches!(
2119 parse_metadata_value("order", "3", &ty).unwrap(),
2120 MetadataValue::Integer(3)
2121 ));
2122 assert!(matches!(
2123 parse_metadata_value("order", "2.5", &ty).unwrap(),
2124 MetadataValue::Float(_)
2125 ));
2126 }
2127
2128 #[test]
2129 fn string_field_accepts_any_value() {
2130 let ty = typed_field_type();
2131 assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
2133 }
2134
2135 #[test]
2136 fn invalid_field_value_prose_inlines_format_and_purpose() {
2137 let err = ValidationError::InvalidFieldValue {
2138 field: "verified_on".to_string(),
2139 value: "not-a-real-date".to_string(),
2140 expected_type: "Date".to_string(),
2141 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
2142 field_description: Some("date the widget was verified".to_string()),
2143 entity_type: "widget".to_string(),
2144 };
2145 let prose = err.prose_render();
2146 assert!(prose.contains("not-a-real-date"), "got: {prose}");
2147 assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
2148 assert!(
2149 prose.contains("date the widget was verified"),
2150 "purpose missing: {prose}"
2151 );
2152 assert!(!prose.contains("see details"), "got: {prose}");
2153 }
2154
2155 #[test]
2156 fn is_date_shaped_matches_strict_validator_contract() {
2157 assert!(is_date_shaped("2024-06-01"));
2158 assert!(is_date_shaped("2024-06-01T12:30:00Z"));
2159 assert!(!is_date_shaped(""));
2160 assert!(!is_date_shaped("not-a-real-date"));
2161 assert!(!is_date_shaped("2024-6-1"));
2162 assert!(!is_date_shaped("2024-06-01 extra"));
2163 }
2164
2165 #[test]
2166 fn section_content_refuses_nul_byte() {
2167 let err = validate_section_content([("body", "line1\u{0}line2")].into_iter(), None)
2168 .expect_err("NUL in a section body must be refused");
2169 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2170 match &err {
2171 ValidationError::SectionContentControlByte {
2172 section,
2173 control_char,
2174 codepoint,
2175 byte_offset,
2176 } => {
2177 assert_eq!(section, "body");
2178 assert_eq!(*control_char, '\u{0}');
2179 assert_eq!(*codepoint, 0);
2180 assert_eq!(*byte_offset, 5);
2182 }
2183 other => panic!("expected SectionContentControlByte, got {other:?}"),
2184 }
2185 let details = err.details();
2187 assert_eq!(details["codepoint"], 0);
2188 assert_eq!(details["byte_offset"], 5);
2189 assert_eq!(details["section"], "body");
2190 }
2191
2192 #[test]
2193 fn section_content_refuses_other_c0_controls_and_cr() {
2194 for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
2197 let body = format!("ok{bad}more");
2198 let err = validate_section_content([("s", body.as_str())].into_iter(), None)
2199 .expect_err("control char must be refused");
2200 assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
2201 }
2202 }
2203
2204 #[test]
2205 fn section_content_allows_tab_and_newline() {
2206 validate_section_content(
2209 [("body", "line1\nline2\n\tindented\tcols\n")].into_iter(),
2210 None,
2211 )
2212 .expect("tab and newline must stay legal in section bodies");
2213 }
2214
2215 #[test]
2220 fn the_catch_all_accepts_back_the_value_the_engine_emits() {
2221 let declared = ["Body", "Notes"];
2222 let ctx = CatchAllContext {
2223 key: "notes",
2224 entity_type: "doc",
2225 declared_headings: &declared,
2226 };
2227 validate_section_content(
2229 [("notes", "## Field Notes\n\nsomething useful\n")].into_iter(),
2230 Some(ctx),
2231 )
2232 .expect("the catch-all re-absorbs an undeclared heading, so writing it back is safe");
2233 }
2234
2235 #[test]
2240 fn the_catch_all_exemption_does_not_weaken_the_guard() {
2241 let declared = ["Body", "Notes"];
2242 let ctx = CatchAllContext {
2243 key: "notes",
2244 entity_type: "doc",
2245 declared_headings: &declared,
2246 };
2247 let err = validate_section_content(
2248 [("notes", "## Body\n\nthis would move to `body` on reparse\n")].into_iter(),
2249 Some(ctx),
2250 )
2251 .expect_err("a declared heading inside the catch-all forks the entity");
2252 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2253
2254 let err = validate_section_content([("body", "## Anything\n")].into_iter(), Some(ctx))
2256 .expect_err("only the catch-all absorbs; every other section still forks");
2257 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2258
2259 let err = validate_section_content([("notes", "# A Title\n")].into_iter(), Some(ctx))
2262 .expect_err("h1 is the entity's title level");
2263 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2264 }
2265
2266 #[test]
2272 fn content_that_would_hide_a_delimiter_is_refused() {
2273 let err = validate_section_content([("body", "```rust\nfn main() {}")].into_iter(), None)
2277 .expect_err("an unterminated fence must be refused");
2278 assert_eq!(err.code(), "UNTERMINATED_FENCE");
2279 assert_eq!(err.details()["section"], "body");
2280 assert_eq!(err.details()["fence"], "```");
2281 let err = validate_section_content([("body", "~~~\nopen")].into_iter(), None)
2283 .expect_err("tilde fences too");
2284 assert_eq!(err.details()["fence"], "~~~");
2285 let err = validate_section_content([("body", "````\n```\nstill inside")].into_iter(), None)
2286 .expect_err("a longer opener needs a longer closer");
2287 assert_eq!(err.details()["fence"], "````");
2288 }
2289
2290 #[test]
2291 fn a_closed_fence_around_headings_is_admitted_unchanged() {
2292 validate_section_content(
2296 [(
2297 "body",
2298 "prose\n\n```md\n## Not A Section\n# Nor This\n```\n\nmore prose",
2299 )]
2300 .into_iter(),
2301 None,
2302 )
2303 .expect("a closed fence containing heading lines is ordinary content");
2304 validate_section_content([("body", "```\n## Hidden\n```")].into_iter(), None)
2306 .expect("closed is closed, wherever the closer sits");
2307 validate_section_content([("body", "> ```\n> quoted")].into_iter(), None)
2309 .expect("a blockquote's fence cannot reach past the quote");
2310 }
2311
2312 #[test]
2313 fn an_ordinary_body_is_untouched_by_the_fence_guard() {
2314 validate_section_content(
2316 [("body", "plain prose\nwith lines\n\nand a paragraph")].into_iter(),
2317 None,
2318 )
2319 .expect("content with no fence at all cannot trip a fence guard");
2320 }
2321
2322 #[test]
2323 fn an_empty_undeclared_heading_is_refused_at_the_write() {
2324 let declared = ["Body", "Notes"];
2325 let ctx = CatchAllContext {
2326 key: "notes",
2327 entity_type: "doc",
2328 declared_headings: &declared,
2329 };
2330 for (body, label) in [
2331 ("## Scratch\n", "bare heading, nothing after it"),
2332 (
2333 "## Scratch\n\n \n",
2334 "heading followed only by blank lines",
2335 ),
2336 (
2337 "## Scratch\n\n## Other\n\nreal content\n",
2338 "heading with the next heading under it",
2339 ),
2340 ] {
2341 let err = validate_section_content([("notes", body)].into_iter(), Some(ctx))
2342 .expect_err(label);
2343 assert_eq!(err.code(), "EMPTY_UNDECLARED_HEADING", "{label}");
2344 let d = err.details();
2345 assert_eq!(d["heading"], "Scratch", "{label}");
2346 assert_eq!(d["entity_type"], "doc", "{label}");
2347 }
2348 }
2349
2350 #[test]
2354 fn an_undeclared_heading_with_a_body_is_not_refused() {
2355 let declared = ["Body", "Notes"];
2356 let ctx = CatchAllContext {
2357 key: "notes",
2358 entity_type: "doc",
2359 declared_headings: &declared,
2360 };
2361 validate_section_content(
2362 [("notes", "## Scratch\n\nsomething\n")].into_iter(),
2363 Some(ctx),
2364 )
2365 .expect("content under the heading survives, so the write is accepted");
2366 }
2367
2368 #[test]
2369 fn section_content_keeps_backslashes_verbatim() {
2370 validate_section_content(
2374 [("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
2375 None,
2376 )
2377 .expect("backslashes are literal content, not control bytes");
2378 }
2379
2380 #[test]
2381 fn section_content_still_refuses_heading_injection() {
2382 let err =
2385 validate_section_content([("body", "intro\n## Injected\ntail")].into_iter(), None)
2386 .expect_err("embedded `## ` heading must still be refused");
2387 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2388 assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
2389 }
2390
2391 #[test]
2396 fn section_content_admits_a_heading_inside_a_code_block() {
2397 for body in [
2398 "intro\n\n```\n## Not A Heading\n```\n",
2399 "intro\n\n~~~\n## Not A Heading\n~~~\n",
2400 "intro\n\n> ```\n> ## Not A Heading\n> ```\n",
2401 "intro\n\n ## Not A Heading\n",
2402 ] {
2403 validate_section_content([("body", body)].into_iter(), None)
2404 .unwrap_or_else(|e| panic!("code-block content must be admitted: {body:?} -> {e}"));
2405 }
2406 }
2407
2408 #[test]
2413 fn section_content_refuses_the_trim_fork() {
2414 let err = validate_section_content(
2415 [("body", " ## Not A Heading\n more\n")].into_iter(),
2416 None,
2417 )
2418 .expect_err("content whose trim exposes a column-0 heading must be refused");
2419 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2420 match err {
2421 ValidationError::SectionContentInvalid {
2422 embedded_heading, ..
2423 } => assert_eq!(
2424 embedded_heading, "## Not A Heading",
2425 "the refusal quotes the line the reparse will see"
2426 ),
2427 other => panic!("unexpected error: {other}"),
2428 }
2429 }
2430
2431 #[test]
2432 fn section_content_refuses_the_trim_fork_for_h1_too() {
2433 let err = validate_section_content([("body", " # Not A Title\n")].into_iter(), None)
2434 .expect_err("h1 exposed by the trim must be refused");
2435 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2436 }
2437}