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(
150 "section '{section}' content contains a disallowed control character U+{codepoint:04X} \
151 at byte offset {byte_offset} — only tab and newline are permitted in section bodies"
152 )]
153 SectionContentControlByte {
154 section: String,
155 control_char: char,
158 codepoint: u32,
161 byte_offset: usize,
164 },
165 #[error(
176 "invalid value '{value}' for field '{field}' on type '{entity_type}' — expected {expected_type}"
177 )]
178 InvalidFieldValue {
179 field: String,
180 value: String,
181 expected_type: String,
182 expected_format: Option<String>,
183 field_description: Option<String>,
184 entity_type: String,
185 },
186}
187
188impl ValidationError {
189 pub fn code(&self) -> &'static str {
194 match self {
195 ValidationError::UnknownSection { .. } => "UNKNOWN_SECTION",
196 ValidationError::UnknownMetadata { .. } => "UNKNOWN_METADATA_FIELD",
197 ValidationError::InvalidEnumValue { .. } => "INVALID_ENUM_VALUE",
198 ValidationError::ReadOnlyField { .. } => "READ_ONLY_FIELD",
199 ValidationError::SectionNotUpdatable { .. } => "SECTION_NOT_UPDATABLE",
200 ValidationError::InvalidRelationshipType { .. } => "INVALID_REL_TYPE",
201 ValidationError::InvalidRelationshipShape { .. } => "INVALID_REL_SHAPE",
202 ValidationError::SectionContentInvalid { .. } => "SECTION_CONTENT_INVALID",
203 ValidationError::SectionContentControlByte { .. } => "SECTION_CONTENT_INVALID",
204 ValidationError::InvalidFieldValue { .. } => "INVALID_FIELD_VALUE",
205 }
206 }
207
208 pub fn details(&self) -> serde_json::Value {
215 match self {
216 ValidationError::UnknownSection {
217 key,
218 entity_type,
219 declared,
220 suggestion,
221 } => serde_json::json!({
222 "key": key,
223 "entity_type": entity_type,
224 "declared": declared,
225 "suggestion": suggestion,
226 }),
227 ValidationError::UnknownMetadata {
228 key,
229 entity_type,
230 declared,
231 suggestion,
232 } => serde_json::json!({
233 "key": key,
234 "entity_type": entity_type,
235 "declared": declared,
236 "suggestion": suggestion,
237 }),
238 ValidationError::InvalidEnumValue {
239 field,
240 value,
241 allowed,
242 field_description,
243 suggestion,
244 type_write_rules,
245 entity_type,
246 } => serde_json::json!({
247 "field": field,
248 "value": value,
249 "allowed": allowed,
250 "field_description": field_description,
251 "suggestion": suggestion,
252 "type_write_rules": type_write_rules,
253 "entity_type": entity_type,
254 }),
255 ValidationError::ReadOnlyField { field } => serde_json::json!({
256 "field": field,
257 }),
258 ValidationError::SectionNotUpdatable {
259 section,
260 entity_type,
261 } => serde_json::json!({
262 "section": section,
263 "entity_type": entity_type,
264 }),
265 ValidationError::InvalidRelationshipType {
266 input,
267 allowed,
268 suggestion,
269 } => {
270 let allowed_json: Vec<serde_json::Value> = allowed
271 .iter()
272 .map(|h| {
273 serde_json::json!({
274 "name": h.name,
275 "when_to_use": h.when_to_use,
276 })
277 })
278 .collect();
279 serde_json::json!({
280 "input": input,
281 "allowed": allowed_json,
282 "suggestion": suggestion,
283 })
284 }
285 ValidationError::InvalidRelationshipShape {
286 rel_type,
287 from_type,
288 to_type,
289 allowed_source_types,
290 allowed_target_types,
291 suggestion,
292 } => {
293 let suggestion_json = suggestion.as_ref().map(|h| {
294 serde_json::json!({
295 "name": h.name,
296 "when_to_use": h.when_to_use,
297 })
298 });
299 let mut details = serde_json::Map::new();
300 details.insert(
301 "rel_type".into(),
302 serde_json::Value::String(rel_type.clone()),
303 );
304 details.insert(
305 "from_type".into(),
306 serde_json::Value::String(from_type.clone()),
307 );
308 details.insert("to_type".into(), serde_json::Value::String(to_type.clone()));
309 if !allowed_source_types.is_empty() {
316 details.insert(
317 "allowed_source_types".into(),
318 serde_json::json!(allowed_source_types),
319 );
320 }
321 if !allowed_target_types.is_empty() {
322 details.insert(
323 "allowed_target_types".into(),
324 serde_json::json!(allowed_target_types),
325 );
326 }
327 details.insert("suggestion".into(), serde_json::json!(suggestion_json));
328 serde_json::Value::Object(details)
329 }
330 ValidationError::SectionContentInvalid {
331 section,
332 embedded_heading,
333 } => serde_json::json!({
334 "section": section,
335 "embedded_heading": embedded_heading,
336 }),
337 ValidationError::SectionContentControlByte {
338 section,
339 control_char,
340 codepoint,
341 byte_offset,
342 } => serde_json::json!({
343 "section": section,
344 "control_char": control_char.to_string(),
345 "codepoint": codepoint,
346 "byte_offset": byte_offset,
347 }),
348 ValidationError::InvalidFieldValue {
349 field,
350 value,
351 expected_type,
352 expected_format,
353 field_description,
354 entity_type,
355 } => serde_json::json!({
356 "field": field,
357 "value": value,
358 "expected_type": expected_type,
359 "expected_format": expected_format,
360 "field_description": field_description,
361 "entity_type": entity_type,
362 }),
363 }
364 }
365
366 pub fn prose_render(&self) -> String {
376 match self {
377 ValidationError::UnknownSection {
378 key,
379 entity_type,
380 declared,
381 suggestion,
382 } => {
383 let declared_inline = if declared.is_empty() {
384 "(none)".to_string()
385 } else {
386 declared.join(", ")
387 };
388 let suggestion_clause = suggestion
389 .as_deref()
390 .map(|s| format!(" Did you mean '{s}'?"))
391 .unwrap_or_default();
392 format!(
393 "unknown section '{key}' for type '{entity_type}' — declared sections: {declared_inline}.{suggestion_clause}"
394 )
395 }
396 ValidationError::UnknownMetadata {
397 key,
398 entity_type,
399 declared,
400 suggestion,
401 } => {
402 let declared_inline = if declared.is_empty() {
403 "(none)".to_string()
404 } else {
405 declared.join(", ")
406 };
407 let suggestion_clause = suggestion
408 .as_deref()
409 .map(|s| format!(" Did you mean '{s}'?"))
410 .unwrap_or_default();
411 format!(
412 "unknown metadata field '{key}' for type '{entity_type}' — declared fields: {declared_inline}.{suggestion_clause}"
413 )
414 }
415 ValidationError::InvalidEnumValue {
416 field,
417 value,
418 allowed,
419 field_description,
420 suggestion,
421 type_write_rules,
422 entity_type,
423 } => {
424 let allowed_inline = if allowed.is_empty() {
425 "(none)".to_string()
426 } else {
427 allowed.join(", ")
428 };
429 let desc_clause = field_description
430 .as_deref()
431 .map(|d| format!(" Field purpose: {d}."))
432 .unwrap_or_default();
433 let suggestion_clause = suggestion
434 .as_deref()
435 .map(|s| format!(" Did you mean '{s}'?"))
436 .unwrap_or_default();
437 let rules_clause = if type_write_rules.is_empty() {
438 String::new()
439 } else {
440 format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
441 };
442 format!(
443 "invalid value '{value}' for field '{field}' on type '{entity_type}' — allowed: {allowed_inline}.{desc_clause}{suggestion_clause}{rules_clause}"
444 )
445 }
446 ValidationError::ReadOnlyField { field } => {
447 format!("cannot change read-only field '{field}' via update")
448 }
449 ValidationError::SectionNotUpdatable {
450 section,
451 entity_type,
452 } => format!("section '{section}' is not updatable for type '{entity_type}'"),
453 ValidationError::InvalidRelationshipType {
454 input,
455 allowed,
456 suggestion,
457 } => {
458 let allowed_inline = if allowed.is_empty() {
459 "(none)".to_string()
460 } else {
461 allowed
462 .iter()
463 .map(|h| h.name.clone())
464 .collect::<Vec<_>>()
465 .join(", ")
466 };
467 let suggestion_clause = suggestion
468 .as_deref()
469 .map(|s| format!(" Did you mean '{s}'?"))
470 .unwrap_or_default();
471 format!(
472 "invalid relationship type '{input}' — must be one of the schema's declared types: {allowed_inline}.{suggestion_clause}"
473 )
474 }
475 ValidationError::InvalidRelationshipShape {
476 rel_type,
477 from_type,
478 to_type,
479 allowed_source_types,
480 allowed_target_types,
481 suggestion,
482 } => {
483 let sources_inline = if allowed_source_types.is_empty() {
484 "any".to_string()
485 } else {
486 allowed_source_types.join(", ")
487 };
488 let targets_inline = if allowed_target_types.is_empty() {
489 "any".to_string()
490 } else {
491 allowed_target_types.join(", ")
492 };
493 let suggestion_clause = suggestion
494 .as_ref()
495 .map(|h| format!(" Suggested rel-type: '{}'.", h.name))
496 .unwrap_or_default();
497 format!(
498 "relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape — allowed sources: {sources_inline}; allowed targets: {targets_inline}.{suggestion_clause}"
499 )
500 }
501 ValidationError::SectionContentInvalid {
502 section,
503 embedded_heading,
504 } => format!(
505 "section '{section}' content contains an embedded reserved (`# ` / `## `) heading line '{embedded_heading}' — use `### ` or deeper for sub-headings"
506 ),
507 ValidationError::SectionContentControlByte {
508 section,
509 codepoint,
510 byte_offset,
511 ..
512 } => format!(
513 "section '{section}' content contains a disallowed control character U+{codepoint:04X} at byte offset {byte_offset} — \
514 only tab (U+0009) and newline (U+000A) are permitted in section bodies. Remove the control character: it would break \
515 the diffable-markdown invariant (a NUL makes git treat the file as binary and text tooling truncates at it)."
516 ),
517 ValidationError::InvalidFieldValue {
518 field,
519 value,
520 expected_type,
521 expected_format,
522 field_description,
523 entity_type,
524 } => {
525 let format_clause = expected_format
526 .as_deref()
527 .map(|f| format!(" Expected format: {f}."))
528 .unwrap_or_default();
529 let desc_clause = field_description
530 .as_deref()
531 .map(|d| format!(" Field purpose: {d}."))
532 .unwrap_or_default();
533 format!(
534 "invalid value '{value}' for field '{field}' on type '{entity_type}' — \
535 not a valid {expected_type}.{format_clause}{desc_clause}"
536 )
537 }
538 }
539 }
540}
541
542pub const READ_ONLY_METADATA_KEYS: &[&str] = &["mem", "id", "type"];
552
553pub fn validate_reserved_metadata_key(key: &str) -> Result<(), ValidationError> {
563 if READ_ONLY_METADATA_KEYS.contains(&key) {
564 return Err(ValidationError::ReadOnlyField {
565 field: key.to_string(),
566 });
567 }
568 Ok(())
569}
570
571pub fn validate_writable_metadata_key(
582 key: &str,
583 schema: &TypeDefinition,
584) -> Result<(), ValidationError> {
585 validate_reserved_metadata_key(key)?;
586 if let Some(field) = schema.metadata_field(key)
587 && (field.init_timestamp || field.auto_timestamp)
588 {
589 return Err(ValidationError::ReadOnlyField {
590 field: key.to_string(),
591 });
592 }
593 Ok(())
594}
595
596pub fn validate_unsettable_metadata_key(
608 key: &str,
609 schema: &TypeDefinition,
610) -> Result<(), ValidationError> {
611 if let Some(field) = schema.metadata_field(key)
612 && (field.init_timestamp || field.auto_timestamp)
613 {
614 return Err(ValidationError::ReadOnlyField {
615 field: key.to_string(),
616 });
617 }
618 Ok(())
619}
620
621pub fn validate_updatable_section(
627 section: &str,
628 schema: &TypeDefinition,
629) -> Result<(), ValidationError> {
630 if section == "relationships" {
631 return Err(ValidationError::SectionNotUpdatable {
632 section: section.to_string(),
633 entity_type: schema.name.clone(),
634 });
635 }
636 if !schema.updatable_fields.is_empty() && !schema.updatable_fields.iter().any(|f| f == section)
637 {
638 return Err(ValidationError::SectionNotUpdatable {
639 section: section.to_string(),
640 entity_type: schema.name.clone(),
641 });
642 }
643 Ok(())
644}
645
646#[derive(Debug, Clone)]
653pub struct MissingRequiredSection {
654 pub entity_type: String,
655 pub key: String,
656 pub heading: String,
657 pub write_rules: Vec<String>,
658}
659
660pub fn validate_section_content<'a>(
668 sections: impl Iterator<Item = (&'a str, &'a str)>,
669) -> Result<(), ValidationError> {
670 for (key, value) in sections {
671 if let Some((byte_offset, ch)) = value
683 .char_indices()
684 .find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
685 {
686 return Err(ValidationError::SectionContentControlByte {
687 section: key.to_string(),
688 control_char: ch,
689 codepoint: ch as u32,
690 byte_offset,
691 });
692 }
693 for line in value.lines() {
694 if (line.starts_with("## ") && line.len() > 3)
702 || (line.starts_with("# ") && line.len() > 2)
703 {
704 return Err(ValidationError::SectionContentInvalid {
705 section: key.to_string(),
706 embedded_heading: line.to_string(),
707 });
708 }
709 }
710 }
711 Ok(())
712}
713
714pub fn validate_section_keys<'a>(
724 provided: impl Iterator<Item = &'a str>,
725 schema: &TypeDefinition,
726) -> Result<(), ValidationError> {
727 let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
728 declared.sort();
729 let declared_set: std::collections::HashSet<&str> =
730 schema.sections.iter().map(|s| s.key.as_str()).collect();
731 let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
732
733 for key in provided {
734 if key == "relationships" {
735 continue;
736 }
737 if declared_set.contains(key) {
738 continue;
739 }
740 let suggestion = schema
741 .suggest_section(key)
742 .or_else(|| catch_all_key.clone());
743 return Err(ValidationError::UnknownSection {
744 key: key.to_string(),
745 entity_type: schema.name.clone(),
746 declared: declared.clone(),
747 suggestion,
748 });
749 }
750 Ok(())
751}
752
753pub fn parse_metadata_value(
763 key: &str,
764 value: &str,
765 schema: &TypeDefinition,
766) -> Result<MetadataValue, ValidationError> {
767 let Some(field_def) = schema.metadata_field(key) else {
768 let mut declared: Vec<String> = schema
769 .metadata_fields
770 .iter()
771 .map(|f| f.key.clone())
772 .collect();
773 declared.sort();
774 return Err(ValidationError::UnknownMetadata {
775 key: key.to_string(),
776 entity_type: schema.name.clone(),
777 declared,
778 suggestion: schema.suggest_metadata_field(key),
779 });
780 };
781
782 if let Some(ref allowed) = field_def.enum_values
783 && !allowed.iter().any(|v| v == value)
784 {
785 let suggestion = nearest_str_match(value, allowed);
786 return Err(ValidationError::InvalidEnumValue {
787 field: key.to_string(),
788 value: value.to_string(),
789 allowed: allowed.clone(),
790 field_description: Some(field_def.description.clone()),
791 suggestion,
792 type_write_rules: schema.write_rules.clone(),
793 entity_type: schema.name.clone(),
794 });
795 }
796
797 Ok(match field_def.field_type {
798 FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
799 FieldType::Number => {
800 if let Ok(n) = value.parse::<i64>() {
801 MetadataValue::Integer(n)
802 } else if let Ok(f) = value.parse::<f64>() {
803 MetadataValue::Float(f)
804 } else {
805 return Err(ValidationError::InvalidFieldValue {
810 field: key.to_string(),
811 value: value.to_string(),
812 expected_type: "Number".to_string(),
813 expected_format: Some("an integer or decimal number".to_string()),
814 field_description: Some(field_def.description.clone()),
815 entity_type: schema.name.clone(),
816 });
817 }
818 }
819 FieldType::Date => {
820 if !is_date_shaped(value) {
828 return Err(ValidationError::InvalidFieldValue {
829 field: key.to_string(),
830 value: value.to_string(),
831 expected_type: "Date".to_string(),
832 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
833 field_description: Some(field_def.description.clone()),
834 entity_type: schema.name.clone(),
835 });
836 }
837 MetadataValue::String(value.to_string())
838 }
839 _ => MetadataValue::String(value.to_string()),
840 })
841}
842
843pub fn is_date_shaped(s: &str) -> bool {
852 static RE: OnceLock<Regex> = OnceLock::new();
853 RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
854 .is_match(s)
855}
856
857#[derive(Debug, Clone)]
865pub struct MissingRequiredField {
866 pub entity_type: String,
867 pub key: String,
868 pub description: String,
869 pub enum_values: Vec<String>,
870}
871
872pub fn missing_required_fields(
883 schema: &TypeDefinition,
884 supplied: &IndexMap<String, String>,
885) -> Vec<MissingRequiredField> {
886 schema
887 .metadata_fields
888 .iter()
889 .filter(|f| {
890 !READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
894 && f.is_required()
895 && f.default_value.is_none()
896 && !f.init_timestamp
897 && !f.auto_timestamp
898 && !supplied.contains_key(f.key.as_str())
899 })
900 .map(|f| MissingRequiredField {
901 entity_type: schema.name.clone(),
902 key: f.key.clone(),
903 description: f.description.clone(),
904 enum_values: f.enum_values.clone().unwrap_or_default(),
905 })
906 .collect()
907}
908
909pub fn missing_required_sections(
913 schema: &TypeDefinition,
914 sections: &IndexMap<String, String>,
915) -> Vec<MissingRequiredSection> {
916 schema
917 .required_sections()
918 .filter_map(|sec| {
919 let is_empty = sections
920 .get(sec.key.as_str())
921 .is_none_or(|c| c.trim().is_empty());
922 is_empty.then(|| MissingRequiredSection {
923 entity_type: schema.name.clone(),
924 key: sec.key.clone(),
925 heading: sec.heading.clone(),
926 write_rules: sec.write_rules.clone(),
927 })
928 })
929 .collect()
930}
931
932#[derive(Debug, Clone)]
937pub enum RelationshipCheck {
938 Ok,
940 OpenWarning(String),
943}
944
945pub fn validate_rel_type(
955 rel_type: &str,
956 schema: &Schema,
957) -> Result<RelationshipCheck, ValidationError> {
958 if schema.relationship_known(rel_type) {
959 return Ok(RelationshipCheck::Ok);
960 }
961 match schema.mode() {
962 RelationshipMode::Strict => {
963 let allowed = declared_relationship_hints(schema);
964 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
965 let suggestion = nearest_str_match(rel_type, &candidate_names);
966 Err(ValidationError::InvalidRelationshipType {
967 input: rel_type.to_string(),
968 allowed,
969 suggestion,
970 })
971 }
972 RelationshipMode::Open => {
973 let declared: Vec<String> = declared_relationship_hints(schema)
974 .into_iter()
975 .map(|h| h.name)
976 .collect();
977 let suggestion = schema
978 .suggest_relationship(rel_type)
979 .map(|s| format!(" Did you mean '{s}'?"))
980 .unwrap_or_default();
981 let (schema_name, schema_version) = schema.id();
982 Ok(RelationshipCheck::OpenWarning(format!(
983 "relationship '{rel_type}' is not declared in schema \
984 '{schema_name}@{schema_version}' (mode: open). \
985 Accepted with default weight. Declared: [{}].{suggestion}",
986 declared.join(", "),
987 )))
988 }
989 }
990}
991
992pub fn validate_rel_shape(
1006 rel_type: &str,
1007 from_type: &str,
1008 to_type: Option<&str>,
1009 schema: &Schema,
1010) -> Result<(), ValidationError> {
1011 let Some(def) = schema.relationship_def(rel_type) else {
1012 return Ok(());
1013 };
1014 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1015 let target_ok = def.target_types.is_empty()
1016 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1017 if source_ok && target_ok {
1018 return Ok(());
1019 }
1020 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1021 let suggestion = suggest_shape_admitting(from_type, to_type, schema);
1022 Err(ValidationError::InvalidRelationshipShape {
1023 rel_type: rel_type.to_string(),
1024 from_type: from_type.to_string(),
1025 to_type: to_for_err,
1026 allowed_source_types: def.source_types.clone(),
1027 allowed_target_types: def.target_types.clone(),
1028 suggestion,
1029 })
1030}
1031
1032#[derive(Debug, Clone)]
1041pub enum CrossMemRelCheck {
1042 Ok,
1046 EdgeNotDeclared,
1050 Invalid(ValidationError),
1055}
1056
1057pub fn validate_cross_mem_edge(
1079 rel_type: &str,
1080 from_type: &str,
1081 to_type: Option<&str>,
1082 source_schema: &Schema,
1083 target_schema_ref: &memstead_schema::SchemaRef,
1084) -> CrossMemRelCheck {
1085 let entries = source_schema.cross_mem_entries(&target_schema_ref.name);
1091 if entries.is_empty() {
1092 return CrossMemRelCheck::EdgeNotDeclared;
1093 }
1094
1095 let Some(def) = entries
1096 .iter()
1097 .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1098 else {
1099 if !entries.iter().any(|e| e.to_schema != "*") {
1107 return CrossMemRelCheck::EdgeNotDeclared;
1108 }
1109 let allowed: Vec<RelationshipHint> = cross_mem_entries_hints(&entries);
1110 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1111 let suggestion = nearest_str_match(rel_type, &candidate_names);
1112 return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1113 input: rel_type.to_string(),
1114 allowed,
1115 suggestion,
1116 });
1117 };
1118
1119 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1120 let target_ok = def.target_types.is_empty()
1121 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1122 if source_ok && target_ok {
1123 return CrossMemRelCheck::Ok;
1124 }
1125 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1126 let suggestion = entries
1127 .iter()
1128 .find_map(|entry| cross_mem_suggest_shape(entry, from_type, to_type));
1129 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1130 rel_type: rel_type.to_string(),
1131 from_type: from_type.to_string(),
1132 to_type: to_for_err,
1133 allowed_source_types: def.source_types.clone(),
1134 allowed_target_types: def.target_types.clone(),
1135 suggestion,
1136 })
1137}
1138
1139fn cross_mem_entries_hints(entries: &[&CrossMemRelationshipEntry]) -> Vec<RelationshipHint> {
1143 let mut out: Vec<RelationshipHint> = Vec::new();
1144 for entry in entries {
1145 for hint in cross_mem_entry_hints(entry) {
1146 if !out.iter().any(|h| h.name == hint.name) {
1147 out.push(hint);
1148 }
1149 }
1150 }
1151 out.sort_by(|a, b| a.name.cmp(&b.name));
1152 out
1153}
1154
1155fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
1160 let mut out: Vec<RelationshipHint> = entry
1161 .definitions
1162 .iter()
1163 .filter(|d| d.name != "_default")
1164 .map(|d| RelationshipHint {
1165 name: d.name.clone(),
1166 when_to_use: d.when_to_use.clone(),
1167 })
1168 .collect();
1169 out.sort_by(|a, b| a.name.cmp(&b.name));
1170 out
1171}
1172
1173fn cross_mem_suggest_shape(
1178 entry: &CrossMemRelationshipEntry,
1179 from_type: &str,
1180 to_type: Option<&str>,
1181) -> Option<RelationshipHint> {
1182 entry
1183 .definitions
1184 .iter()
1185 .filter(|d| d.name != "_default")
1186 .find(|d| cross_mem_def_admits(d, from_type, to_type))
1187 .map(|d| RelationshipHint {
1188 name: d.name.clone(),
1189 when_to_use: d.when_to_use.clone(),
1190 })
1191}
1192
1193fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
1194 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1195 let tgt_ok =
1196 d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1197 src_ok && tgt_ok
1198}
1199
1200fn suggest_shape_admitting(
1206 from_type: &str,
1207 to_type: Option<&str>,
1208 schema: &Schema,
1209) -> Option<RelationshipHint> {
1210 schema
1211 .manifest
1212 .relationships
1213 .definitions
1214 .iter()
1215 .filter(|d| d.name != "_default")
1216 .find(|d| {
1217 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1218 let tgt_ok = d.target_types.is_empty()
1219 || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1220 src_ok && tgt_ok
1221 })
1222 .map(|d| RelationshipHint {
1223 name: d.name.clone(),
1224 when_to_use: d.when_to_use.clone(),
1225 })
1226}
1227
1228fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
1233 let mut out: Vec<RelationshipHint> = schema
1234 .manifest
1235 .relationships
1236 .definitions
1237 .iter()
1238 .filter(|d| d.name != "_default")
1239 .map(|d| RelationshipHint {
1240 name: d.name.clone(),
1241 when_to_use: d.when_to_use.clone(),
1242 })
1243 .collect();
1244 out.sort_by(|a, b| a.name.cmp(&b.name));
1245 out
1246}
1247
1248fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
1253 let noise_floor = (needle.chars().count() / 2).max(1);
1254 let mut best: Option<(usize, String)> = None;
1255 for cand in candidates {
1256 let d = strsim::levenshtein(needle, cand);
1257 if d == 0 || d > noise_floor {
1258 continue;
1259 }
1260 match &best {
1261 Some((bd, _)) if *bd <= d => {}
1262 _ => best = Some((d, cand.clone())),
1263 }
1264 }
1265 best.map(|(_, name)| name)
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270 use super::*;
1271
1272 fn shape_test_schema() -> std::sync::Arc<Schema> {
1277 let manifest_yaml = r#"name: tests-rel-shape
1278version: 0.1.0
1279description: rel-shape test schema
1280when_to_use: tests
1281types:
1282 - step
1283 - decision
1284 - note
1285relationships:
1286 mode: strict
1287 definitions:
1288 - name: PART_OF
1289 description: parent containment
1290 default_weight: 3.0
1291 acyclic: true
1292 - name: USES
1293 description: shape-free reference
1294 default_weight: 1.0
1295 - name: EXECUTES
1296 description: step carries out decision
1297 default_weight: 2.5
1298 source_types: [step]
1299 target_types: [decision]
1300 - name: _default
1301 description: fallback
1302 default_weight: 1.0
1303community:
1304 resolution: 1.0
1305 seed: 42
1306"#;
1307 let body_section = r#"sections:
1308 - key: body
1309 heading: Body
1310 required: true
1311 search_weight: 10.0
1312 catch_all: true
1313 write_rules: []
1314metadata_fields: []
1315title_weight: 100.0
1316text_fields:
1317 - body
1318hierarchy_relationship: PART_OF
1319no_self_loop_relationships: []
1320updatable_fields:
1321 - title
1322 - body
1323health_required_fields:
1324 - body
1325staleness_threshold_days: 90
1326write_rules: []
1327"#;
1328 let make_type =
1329 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1330 std::sync::Arc::new(
1331 memstead_schema::load_schema_from_memory(
1332 manifest_yaml,
1333 &[
1334 ("step".to_string(), make_type("step")),
1335 ("decision".to_string(), make_type("decision")),
1336 ("note".to_string(), make_type("note")),
1337 ],
1338 )
1339 .expect("test schema must load"),
1340 )
1341 }
1342
1343 #[test]
1344 fn rel_shape_admits_pair_in_declared_source_target() {
1345 let schema = shape_test_schema();
1346 assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
1348 }
1349
1350 #[test]
1351 fn rel_shape_rejects_violating_source() {
1352 let schema = shape_test_schema();
1353 let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
1355 match err {
1356 ValidationError::InvalidRelationshipShape {
1357 rel_type,
1358 from_type,
1359 to_type,
1360 allowed_source_types,
1361 allowed_target_types,
1362 ..
1363 } => {
1364 assert_eq!(rel_type, "EXECUTES");
1365 assert_eq!(from_type, "note");
1366 assert_eq!(to_type, "decision");
1367 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1368 assert_eq!(allowed_target_types, vec!["decision".to_string()]);
1369 }
1370 other => panic!("expected InvalidRelationshipShape, got {other:?}"),
1371 }
1372 }
1373
1374 #[test]
1375 fn rel_shape_rejects_violating_target() {
1376 let schema = shape_test_schema();
1377 let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
1379 assert!(matches!(
1380 err,
1381 ValidationError::InvalidRelationshipShape { .. }
1382 ));
1383 }
1384
1385 #[test]
1386 fn rel_shape_admits_shape_free_relationship() {
1387 let schema = shape_test_schema();
1388 assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
1390 }
1391
1392 #[test]
1393 fn rel_shape_skips_target_check_when_target_type_unknown() {
1394 let schema = shape_test_schema();
1395 assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
1398 }
1399
1400 #[test]
1401 fn rel_shape_no_op_for_unknown_rel_name() {
1402 let schema = shape_test_schema();
1403 assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
1406 }
1407
1408 fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
1421 let manifest_yaml = r#"name: source-cv
1422version: 0.1.0
1423description: cross-mem source schema
1424when_to_use: tests
1425types:
1426 - step
1427 - decision
1428relationships:
1429 mode: strict
1430 definitions:
1431 - name: IMPLEMENTS
1432 description: intra-mem only
1433 default_weight: 1.0
1434 - name: _default
1435 description: fallback
1436 default_weight: 1.0
1437cross_mem_relationships:
1438 - to_schema: other
1439 definitions:
1440 - name: ADDRESSES
1441 description: outbound shape-pinned
1442 default_weight: 1.0
1443 source_types: [step]
1444 target_types: [requirement]
1445 - name: MENTIONS
1446 description: outbound shape-free
1447 default_weight: 0.5
1448community:
1449 resolution: 1.0
1450 seed: 42
1451"#;
1452 let body_section = r#"sections:
1453 - key: body
1454 heading: Body
1455 required: true
1456 search_weight: 10.0
1457 catch_all: true
1458 write_rules: []
1459metadata_fields: []
1460title_weight: 100.0
1461text_fields:
1462 - body
1463hierarchy_relationship: _default
1464no_self_loop_relationships: []
1465updatable_fields:
1466 - title
1467 - body
1468health_required_fields:
1469 - body
1470staleness_threshold_days: 90
1471write_rules: []
1472"#;
1473 let make_type =
1474 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1475 std::sync::Arc::new(
1476 memstead_schema::load_schema_from_memory(
1477 manifest_yaml,
1478 &[
1479 ("step".to_string(), make_type("step")),
1480 ("decision".to_string(), make_type("decision")),
1481 ],
1482 )
1483 .expect("cross-mem source schema must load"),
1484 )
1485 }
1486
1487 fn other_target_ref() -> memstead_schema::SchemaRef {
1488 memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
1489 }
1490
1491 #[test]
1492 fn cross_mem_admits_declared_shape() {
1493 let src = cross_mem_source_schema();
1494 let target = other_target_ref();
1495 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1496 CrossMemRelCheck::Ok => {}
1497 other => panic!("expected Ok, got {other:?}"),
1498 }
1499 }
1500
1501 #[test]
1502 fn cross_mem_no_matching_entry_returns_edge_not_declared() {
1503 let src = cross_mem_source_schema();
1504 let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
1508 match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
1509 CrossMemRelCheck::EdgeNotDeclared => {}
1510 other => panic!("expected EdgeNotDeclared, got {other:?}"),
1511 }
1512 }
1513
1514 #[test]
1515 fn cross_mem_entry_matches_any_target_version() {
1516 let src = cross_mem_source_schema();
1520 for version in [
1521 semver::Version::new(1, 0, 0),
1522 semver::Version::new(1, 1, 0),
1523 semver::Version::new(2, 5, 0),
1524 ] {
1525 let target = memstead_schema::SchemaRef::new("other", version.clone());
1526 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1527 CrossMemRelCheck::Ok => {}
1528 other => panic!("expected Ok against other@{version}, got {other:?}"),
1529 }
1530 }
1531 }
1532
1533 #[test]
1534 fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
1535 let src = cross_mem_source_schema();
1536 let target = other_target_ref();
1537 match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
1540 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1541 input,
1542 allowed,
1543 ..
1544 }) => {
1545 assert_eq!(input, "IMPLEMENTS");
1546 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
1548 assert!(names.iter().any(|n| n == "ADDRESSES"));
1549 assert!(names.iter().any(|n| n == "MENTIONS"));
1550 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
1552 }
1553 other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
1554 }
1555 }
1556
1557 #[test]
1558 fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
1559 let src = cross_mem_source_schema();
1560 let target = other_target_ref();
1561 match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
1566 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1567 rel_type,
1568 from_type,
1569 allowed_source_types,
1570 allowed_target_types,
1571 ..
1572 }) => {
1573 assert_eq!(rel_type, "ADDRESSES");
1574 assert_eq!(from_type, "decision");
1575 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1576 assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
1577 }
1578 other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
1579 }
1580 }
1581
1582 #[test]
1583 fn cross_mem_shape_free_rel_type_admits_any_pair() {
1584 let src = cross_mem_source_schema();
1585 let target = other_target_ref();
1586 assert!(matches!(
1588 validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
1589 CrossMemRelCheck::Ok
1590 ));
1591 }
1592
1593 fn wildcard_source_schema() -> std::sync::Arc<Schema> {
1597 let manifest_yaml = r#"name: source-wc
1598version: 0.1.0
1599description: wildcard cross-mem source schema
1600when_to_use: tests
1601types:
1602 - step
1603 - decision
1604relationships:
1605 mode: strict
1606 definitions:
1607 - name: SOFT_REF
1608 description: alias-emitted soft reference
1609 default_weight: 0.5
1610 - name: ADDRESSES
1611 description: structural
1612 default_weight: 1.0
1613 - name: _default
1614 description: fallback
1615 default_weight: 1.0
1616alias_target_rel_type: SOFT_REF
1617cross_mem_relationships:
1618 - to_schema: other
1619 definitions:
1620 - name: ADDRESSES
1621 description: structural, per-schema
1622 default_weight: 1.0
1623 source_types: [step]
1624 target_types: [requirement]
1625 - to_schema: "*"
1626 definitions:
1627 - name: SOFT_REF
1628 description: soft reference anywhere
1629 default_weight: 0.5
1630 source_types: [step]
1631community:
1632 resolution: 1.0
1633 seed: 42
1634"#;
1635 let body_section = r#"description: t
1636when_to_use: tests
1637sections:
1638 - key: body
1639 heading: Body
1640 required: true
1641 search_weight: 10.0
1642 catch_all: true
1643 write_rules: []
1644metadata_fields: []
1645title_weight: 100.0
1646text_fields:
1647 - body
1648hierarchy_relationship: _default
1649no_self_loop_relationships: []
1650updatable_fields:
1651 - title
1652 - body
1653health_required_fields:
1654 - body
1655staleness_threshold_days: 90
1656write_rules: []
1657"#;
1658 let types = vec![
1659 ("step".to_string(), format!("name: step\n{body_section}")),
1660 (
1661 "decision".to_string(),
1662 format!("name: decision\n{body_section}"),
1663 ),
1664 ];
1665 std::sync::Arc::new(
1666 memstead_schema::load_schema_from_memory(manifest_yaml, &types)
1667 .expect("wildcard schema loads"),
1668 )
1669 }
1670
1671 #[test]
1675 fn cross_mem_wildcard_admits_alias_edge_to_any_schema() {
1676 let src = wildcard_source_schema();
1677 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1679 assert!(matches!(
1680 validate_cross_mem_edge("SOFT_REF", "step", Some("argument"), &src, &user),
1681 CrossMemRelCheck::Ok
1682 ));
1683 let other = memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0));
1685 assert!(matches!(
1686 validate_cross_mem_edge("SOFT_REF", "step", Some("requirement"), &src, &other),
1687 CrossMemRelCheck::Ok
1688 ));
1689 assert!(matches!(
1690 validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &other),
1691 CrossMemRelCheck::Ok
1692 ));
1693 }
1694
1695 #[test]
1700 fn cross_mem_wildcard_keeps_source_type_gate_and_structural_refusal() {
1701 let src = wildcard_source_schema();
1702 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1703 match validate_cross_mem_edge("SOFT_REF", "decision", Some("argument"), &src, &user) {
1705 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1706 from_type,
1707 allowed_source_types,
1708 ..
1709 }) => {
1710 assert_eq!(from_type, "decision");
1711 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1712 }
1713 other => panic!("expected shape refusal on source-type gate, got {other:?}"),
1714 }
1715 assert!(matches!(
1719 validate_cross_mem_edge("ADDRESSES", "step", Some("argument"), &src, &user),
1720 CrossMemRelCheck::EdgeNotDeclared
1721 ));
1722 }
1723
1724 #[test]
1729 fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
1730 let err = ValidationError::UnknownSection {
1731 key: "implimentation".to_string(),
1732 entity_type: "spec".to_string(),
1733 declared: (0..8).map(|i| format!("sec{i}")).collect(),
1734 suggestion: Some("sec0".to_string()),
1735 };
1736 let prose = err.prose_render();
1737 for d in (0..8).map(|i| format!("sec{i}")) {
1738 assert!(prose.contains(&d), "missing {d} in: {prose}");
1739 }
1740 assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
1741 assert!(!prose.contains("see details"), "got: {prose}");
1742 }
1743
1744 #[test]
1745 fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
1746 let err = ValidationError::InvalidEnumValue {
1747 field: "level".to_string(),
1748 value: "M7".to_string(),
1749 allowed: (0..7).map(|i| format!("M{i}")).collect(),
1750 field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
1751 suggestion: Some("M6".to_string()),
1752 type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
1753 entity_type: "spec".to_string(),
1754 };
1755 let prose = err.prose_render();
1756 assert!(prose.contains("M0"), "got: {prose}");
1757 assert!(prose.contains("M6"), "got: {prose}");
1758 assert!(
1759 prose.contains("maturity rung"),
1760 "field_description missing: {prose}"
1761 );
1762 assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
1763 assert!(
1764 prose.contains("specs land at M0"),
1765 "type_write_rules missing: {prose}"
1766 );
1767 assert!(!prose.contains("see details"), "got: {prose}");
1768 }
1769
1770 #[test]
1771 fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
1772 let err = ValidationError::InvalidRelationshipShape {
1773 rel_type: "OWNS".to_string(),
1774 from_type: "spec".to_string(),
1775 to_type: "spec".to_string(),
1776 allowed_source_types: vec!["actor".to_string()],
1777 allowed_target_types: vec![],
1778 suggestion: None,
1779 };
1780 let prose = err.prose_render();
1781 assert!(prose.contains("allowed sources: actor"), "got: {prose}");
1785 assert!(prose.contains("allowed targets: any"), "got: {prose}");
1786 assert!(!prose.contains("see details"), "got: {prose}");
1787 }
1788
1789 fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
1801 let manifest_yaml = r#"name: tests-typed-fields
1802version: 0.1.0
1803description: typed-field test schema
1804when_to_use: tests
1805types:
1806 - widget
1807relationships:
1808 mode: strict
1809 definitions:
1810 - name: _default
1811 description: fallback
1812 default_weight: 1.0
1813community:
1814 resolution: 1.0
1815 seed: 42
1816"#;
1817 let type_yaml = r#"name: widget
1818description: t
1819when_to_use: Here
1820sections:
1821 - key: body
1822 heading: Body
1823 required: true
1824 search_weight: 10.0
1825 catch_all: true
1826 write_rules: []
1827metadata_fields:
1828 - key: verified_on
1829 description: ISO YYYY-MM-DD date the widget was verified
1830 field_type: date
1831 optional: true
1832 - key: order
1833 description: numeric ordering within a plan
1834 field_type: number
1835 optional: true
1836 - key: note
1837 description: free-form note
1838 field_type: string
1839 optional: true
1840title_weight: 100.0
1841text_fields:
1842 - body
1843hierarchy_relationship: _default
1844no_self_loop_relationships: []
1845updatable_fields:
1846 - title
1847 - body
1848health_required_fields:
1849 - body
1850staleness_threshold_days: 90
1851write_rules: []
1852"#;
1853 let schema = memstead_schema::load_schema_from_memory(
1854 manifest_yaml,
1855 &[("widget".to_string(), type_yaml.to_string())],
1856 )
1857 .expect("typed-field test schema must load");
1858 schema.get_type("widget").expect("widget type present")
1859 }
1860
1861 #[test]
1862 fn date_field_rejects_non_date_value() {
1863 let ty = typed_field_type();
1864 let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
1865 assert_eq!(err.code(), "INVALID_FIELD_VALUE");
1866 match err {
1867 ValidationError::InvalidFieldValue {
1868 field,
1869 value,
1870 expected_type,
1871 entity_type,
1872 ..
1873 } => {
1874 assert_eq!(field, "verified_on");
1875 assert_eq!(value, "not-a-real-date");
1876 assert_eq!(expected_type, "Date");
1877 assert_eq!(entity_type, "widget");
1878 }
1879 other => panic!("expected InvalidFieldValue, got {other:?}"),
1880 }
1881 }
1882
1883 #[test]
1884 fn date_field_rejects_empty_string() {
1885 let ty = typed_field_type();
1886 let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
1887 assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
1888 }
1889
1890 #[test]
1891 fn date_field_accepts_iso_date_and_datetime() {
1892 let ty = typed_field_type();
1893 match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
1894 MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
1895 other => panic!("expected String, got {other:?}"),
1896 }
1897 assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
1899 }
1900
1901 #[test]
1902 fn number_field_rejects_non_numeric_value() {
1903 let ty = typed_field_type();
1904 let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
1905 match err {
1906 ValidationError::InvalidFieldValue {
1907 field,
1908 expected_type,
1909 ..
1910 } => {
1911 assert_eq!(field, "order");
1912 assert_eq!(expected_type, "Number");
1913 }
1914 other => panic!("expected InvalidFieldValue, got {other:?}"),
1915 }
1916 }
1917
1918 #[test]
1919 fn number_field_accepts_integer_and_float() {
1920 let ty = typed_field_type();
1921 assert!(matches!(
1922 parse_metadata_value("order", "3", &ty).unwrap(),
1923 MetadataValue::Integer(3)
1924 ));
1925 assert!(matches!(
1926 parse_metadata_value("order", "2.5", &ty).unwrap(),
1927 MetadataValue::Float(_)
1928 ));
1929 }
1930
1931 #[test]
1932 fn string_field_accepts_any_value() {
1933 let ty = typed_field_type();
1934 assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
1936 }
1937
1938 #[test]
1939 fn invalid_field_value_prose_inlines_format_and_purpose() {
1940 let err = ValidationError::InvalidFieldValue {
1941 field: "verified_on".to_string(),
1942 value: "not-a-real-date".to_string(),
1943 expected_type: "Date".to_string(),
1944 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
1945 field_description: Some("date the widget was verified".to_string()),
1946 entity_type: "widget".to_string(),
1947 };
1948 let prose = err.prose_render();
1949 assert!(prose.contains("not-a-real-date"), "got: {prose}");
1950 assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
1951 assert!(
1952 prose.contains("date the widget was verified"),
1953 "purpose missing: {prose}"
1954 );
1955 assert!(!prose.contains("see details"), "got: {prose}");
1956 }
1957
1958 #[test]
1959 fn is_date_shaped_matches_strict_validator_contract() {
1960 assert!(is_date_shaped("2024-06-01"));
1961 assert!(is_date_shaped("2024-06-01T12:30:00Z"));
1962 assert!(!is_date_shaped(""));
1963 assert!(!is_date_shaped("not-a-real-date"));
1964 assert!(!is_date_shaped("2024-6-1"));
1965 assert!(!is_date_shaped("2024-06-01 extra"));
1966 }
1967
1968 #[test]
1969 fn section_content_refuses_nul_byte() {
1970 let err = validate_section_content([("body", "line1\u{0}line2")].into_iter())
1971 .expect_err("NUL in a section body must be refused");
1972 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
1973 match &err {
1974 ValidationError::SectionContentControlByte {
1975 section,
1976 control_char,
1977 codepoint,
1978 byte_offset,
1979 } => {
1980 assert_eq!(section, "body");
1981 assert_eq!(*control_char, '\u{0}');
1982 assert_eq!(*codepoint, 0);
1983 assert_eq!(*byte_offset, 5);
1985 }
1986 other => panic!("expected SectionContentControlByte, got {other:?}"),
1987 }
1988 let details = err.details();
1990 assert_eq!(details["codepoint"], 0);
1991 assert_eq!(details["byte_offset"], 5);
1992 assert_eq!(details["section"], "body");
1993 }
1994
1995 #[test]
1996 fn section_content_refuses_other_c0_controls_and_cr() {
1997 for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
2000 let body = format!("ok{bad}more");
2001 let err = validate_section_content([("s", body.as_str())].into_iter())
2002 .expect_err("control char must be refused");
2003 assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
2004 }
2005 }
2006
2007 #[test]
2008 fn section_content_allows_tab_and_newline() {
2009 validate_section_content([("body", "line1\nline2\n\tindented\tcols\n")].into_iter())
2012 .expect("tab and newline must stay legal in section bodies");
2013 }
2014
2015 #[test]
2016 fn section_content_keeps_backslashes_verbatim() {
2017 validate_section_content(
2021 [("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
2022 )
2023 .expect("backslashes are literal content, not control bytes");
2024 }
2025
2026 #[test]
2027 fn section_content_still_refuses_heading_injection() {
2028 let err = validate_section_content([("body", "intro\n## Injected\ntail")].into_iter())
2031 .expect_err("embedded `## ` heading must still be refused");
2032 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2033 assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
2034 }
2035}