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>(
683 sections: impl Iterator<Item = (&'a str, &'a str)>,
684) -> Result<(), ValidationError> {
685 for (key, value) in sections {
686 if let Some((byte_offset, ch)) = value
698 .char_indices()
699 .find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
700 {
701 return Err(ValidationError::SectionContentControlByte {
702 section: key.to_string(),
703 control_char: ch,
704 codepoint: ch as u32,
705 byte_offset,
706 });
707 }
708 let stored = value.trim();
713 let masked = crate::markdown::mask_code_blocks(stored);
714 for (line, masked_line) in stored.lines().zip(masked.lines()) {
715 if (masked_line.starts_with("## ") && masked_line.len() > 3)
723 || (masked_line.starts_with("# ") && masked_line.len() > 2)
724 {
725 return Err(ValidationError::SectionContentInvalid {
726 section: key.to_string(),
727 embedded_heading: line.to_string(),
728 });
729 }
730 }
731 }
732 Ok(())
733}
734
735pub fn validate_section_keys<'a>(
745 provided: impl Iterator<Item = &'a str>,
746 schema: &TypeDefinition,
747) -> Result<(), ValidationError> {
748 let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
749 declared.sort();
750 let declared_set: std::collections::HashSet<&str> =
751 schema.sections.iter().map(|s| s.key.as_str()).collect();
752 let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
753
754 for key in provided {
755 if key == "relationships" {
756 continue;
757 }
758 if declared_set.contains(key) {
759 continue;
760 }
761 let suggestion = schema
762 .suggest_section(key)
763 .or_else(|| catch_all_key.clone());
764 return Err(ValidationError::UnknownSection {
765 key: key.to_string(),
766 entity_type: schema.name.clone(),
767 declared: declared.clone(),
768 suggestion,
769 });
770 }
771 Ok(())
772}
773
774pub fn parse_metadata_value(
784 key: &str,
785 value: &str,
786 schema: &TypeDefinition,
787) -> Result<MetadataValue, ValidationError> {
788 let Some(field_def) = schema.metadata_field(key) else {
789 let mut declared: Vec<String> = schema
790 .metadata_fields
791 .iter()
792 .map(|f| f.key.clone())
793 .collect();
794 declared.sort();
795 return Err(ValidationError::UnknownMetadata {
796 key: key.to_string(),
797 entity_type: schema.name.clone(),
798 declared,
799 suggestion: schema.suggest_metadata_field(key),
800 });
801 };
802
803 if let Some(ref allowed) = field_def.enum_values
804 && !allowed.iter().any(|v| v == value)
805 {
806 let suggestion = nearest_str_match(value, allowed);
807 return Err(ValidationError::InvalidEnumValue {
808 field: key.to_string(),
809 value: value.to_string(),
810 allowed: allowed.clone(),
811 field_description: Some(field_def.description.clone()),
812 suggestion,
813 type_write_rules: schema.write_rules.clone(),
814 entity_type: schema.name.clone(),
815 });
816 }
817
818 Ok(match field_def.field_type {
819 FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
820 FieldType::Number => {
821 if let Ok(n) = value.parse::<i64>() {
822 MetadataValue::Integer(n)
823 } else if let Ok(f) = value.parse::<f64>() {
824 MetadataValue::Float(f)
825 } else {
826 return Err(ValidationError::InvalidFieldValue {
831 field: key.to_string(),
832 value: value.to_string(),
833 expected_type: "Number".to_string(),
834 expected_format: Some("an integer or decimal number".to_string()),
835 field_description: Some(field_def.description.clone()),
836 entity_type: schema.name.clone(),
837 });
838 }
839 }
840 FieldType::Date => {
841 if !is_date_shaped(value) {
849 return Err(ValidationError::InvalidFieldValue {
850 field: key.to_string(),
851 value: value.to_string(),
852 expected_type: "Date".to_string(),
853 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
854 field_description: Some(field_def.description.clone()),
855 entity_type: schema.name.clone(),
856 });
857 }
858 MetadataValue::String(value.to_string())
859 }
860 _ => MetadataValue::String(value.to_string()),
861 })
862}
863
864pub fn is_date_shaped(s: &str) -> bool {
873 static RE: OnceLock<Regex> = OnceLock::new();
874 RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
875 .is_match(s)
876}
877
878#[derive(Debug, Clone)]
886pub struct MissingRequiredField {
887 pub entity_type: String,
888 pub key: String,
889 pub description: String,
890 pub enum_values: Vec<String>,
891}
892
893pub fn missing_required_fields(
904 schema: &TypeDefinition,
905 supplied: &IndexMap<String, String>,
906) -> Vec<MissingRequiredField> {
907 schema
908 .metadata_fields
909 .iter()
910 .filter(|f| {
911 !READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
915 && f.is_required()
916 && f.default_value.is_none()
917 && !f.init_timestamp
918 && !f.auto_timestamp
919 && !supplied.contains_key(f.key.as_str())
920 })
921 .map(|f| MissingRequiredField {
922 entity_type: schema.name.clone(),
923 key: f.key.clone(),
924 description: f.description.clone(),
925 enum_values: f.enum_values.clone().unwrap_or_default(),
926 })
927 .collect()
928}
929
930pub fn missing_required_sections(
934 schema: &TypeDefinition,
935 sections: &IndexMap<String, String>,
936) -> Vec<MissingRequiredSection> {
937 schema
938 .required_sections()
939 .filter_map(|sec| {
940 let is_empty = sections
941 .get(sec.key.as_str())
942 .is_none_or(|c| c.trim().is_empty());
943 is_empty.then(|| MissingRequiredSection {
944 entity_type: schema.name.clone(),
945 key: sec.key.clone(),
946 heading: sec.heading.clone(),
947 write_rules: sec.write_rules.clone(),
948 })
949 })
950 .collect()
951}
952
953#[derive(Debug, Clone)]
958pub enum RelationshipCheck {
959 Ok,
961 OpenWarning(String),
964}
965
966pub fn validate_rel_type(
976 rel_type: &str,
977 schema: &Schema,
978) -> Result<RelationshipCheck, ValidationError> {
979 if schema.relationship_known(rel_type) {
980 return Ok(RelationshipCheck::Ok);
981 }
982 match schema.mode() {
983 RelationshipMode::Strict => {
984 let allowed = declared_relationship_hints(schema);
985 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
986 let suggestion = nearest_str_match(rel_type, &candidate_names);
987 Err(ValidationError::InvalidRelationshipType {
988 input: rel_type.to_string(),
989 allowed,
990 suggestion,
991 })
992 }
993 RelationshipMode::Open => {
994 let declared: Vec<String> = declared_relationship_hints(schema)
995 .into_iter()
996 .map(|h| h.name)
997 .collect();
998 let suggestion = schema
999 .suggest_relationship(rel_type)
1000 .map(|s| format!(" Did you mean '{s}'?"))
1001 .unwrap_or_default();
1002 let (schema_name, schema_version) = schema.id();
1003 Ok(RelationshipCheck::OpenWarning(format!(
1004 "relationship '{rel_type}' is not declared in schema \
1005 '{schema_name}@{schema_version}' (mode: open). \
1006 Accepted with default weight. Declared: [{}].{suggestion}",
1007 declared.join(", "),
1008 )))
1009 }
1010 }
1011}
1012
1013pub fn validate_rel_shape(
1027 rel_type: &str,
1028 from_type: &str,
1029 to_type: Option<&str>,
1030 schema: &Schema,
1031) -> Result<(), ValidationError> {
1032 let Some(def) = schema.relationship_def(rel_type) else {
1033 return Ok(());
1034 };
1035 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1036 let target_ok = def.target_types.is_empty()
1037 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1038 if source_ok && target_ok {
1039 return Ok(());
1040 }
1041 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1042 let suggestion = suggest_shape_admitting(from_type, to_type, schema);
1043 Err(ValidationError::InvalidRelationshipShape {
1044 rel_type: rel_type.to_string(),
1045 from_type: from_type.to_string(),
1046 to_type: to_for_err,
1047 allowed_source_types: def.source_types.clone(),
1048 allowed_target_types: def.target_types.clone(),
1049 suggestion,
1050 })
1051}
1052
1053#[derive(Debug, Clone)]
1062pub enum CrossMemRelCheck {
1063 Ok,
1067 EdgeNotDeclared,
1071 Invalid(ValidationError),
1076}
1077
1078pub fn validate_cross_mem_edge(
1100 rel_type: &str,
1101 from_type: &str,
1102 to_type: Option<&str>,
1103 source_schema: &Schema,
1104 target_schema_ref: &memstead_schema::SchemaRef,
1105) -> CrossMemRelCheck {
1106 let entries = source_schema.cross_mem_entries(&target_schema_ref.name);
1112 if entries.is_empty() {
1113 return CrossMemRelCheck::EdgeNotDeclared;
1114 }
1115
1116 let Some(def) = entries
1117 .iter()
1118 .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1119 else {
1120 if !entries.iter().any(|e| e.to_schema != "*") {
1128 return CrossMemRelCheck::EdgeNotDeclared;
1129 }
1130 let allowed: Vec<RelationshipHint> = cross_mem_entries_hints(&entries);
1131 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1132 let suggestion = nearest_str_match(rel_type, &candidate_names);
1133 return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1134 input: rel_type.to_string(),
1135 allowed,
1136 suggestion,
1137 });
1138 };
1139
1140 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1141 let target_ok = def.target_types.is_empty()
1142 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1143 if source_ok && target_ok {
1144 return CrossMemRelCheck::Ok;
1145 }
1146 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1147 let suggestion = entries
1148 .iter()
1149 .find_map(|entry| cross_mem_suggest_shape(entry, from_type, to_type));
1150 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1151 rel_type: rel_type.to_string(),
1152 from_type: from_type.to_string(),
1153 to_type: to_for_err,
1154 allowed_source_types: def.source_types.clone(),
1155 allowed_target_types: def.target_types.clone(),
1156 suggestion,
1157 })
1158}
1159
1160fn cross_mem_entries_hints(entries: &[&CrossMemRelationshipEntry]) -> Vec<RelationshipHint> {
1164 let mut out: Vec<RelationshipHint> = Vec::new();
1165 for entry in entries {
1166 for hint in cross_mem_entry_hints(entry) {
1167 if !out.iter().any(|h| h.name == hint.name) {
1168 out.push(hint);
1169 }
1170 }
1171 }
1172 out.sort_by(|a, b| a.name.cmp(&b.name));
1173 out
1174}
1175
1176fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
1181 let mut out: Vec<RelationshipHint> = entry
1182 .definitions
1183 .iter()
1184 .filter(|d| d.name != "_default")
1185 .map(|d| RelationshipHint {
1186 name: d.name.clone(),
1187 when_to_use: d.when_to_use.clone(),
1188 })
1189 .collect();
1190 out.sort_by(|a, b| a.name.cmp(&b.name));
1191 out
1192}
1193
1194fn cross_mem_suggest_shape(
1199 entry: &CrossMemRelationshipEntry,
1200 from_type: &str,
1201 to_type: Option<&str>,
1202) -> Option<RelationshipHint> {
1203 entry
1204 .definitions
1205 .iter()
1206 .filter(|d| d.name != "_default")
1207 .find(|d| cross_mem_def_admits(d, from_type, to_type))
1208 .map(|d| RelationshipHint {
1209 name: d.name.clone(),
1210 when_to_use: d.when_to_use.clone(),
1211 })
1212}
1213
1214fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
1215 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1216 let tgt_ok =
1217 d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1218 src_ok && tgt_ok
1219}
1220
1221fn suggest_shape_admitting(
1227 from_type: &str,
1228 to_type: Option<&str>,
1229 schema: &Schema,
1230) -> Option<RelationshipHint> {
1231 schema
1232 .manifest
1233 .relationships
1234 .definitions
1235 .iter()
1236 .filter(|d| d.name != "_default")
1237 .find(|d| {
1238 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1239 let tgt_ok = d.target_types.is_empty()
1240 || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1241 src_ok && tgt_ok
1242 })
1243 .map(|d| RelationshipHint {
1244 name: d.name.clone(),
1245 when_to_use: d.when_to_use.clone(),
1246 })
1247}
1248
1249fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
1254 let mut out: Vec<RelationshipHint> = schema
1255 .manifest
1256 .relationships
1257 .definitions
1258 .iter()
1259 .filter(|d| d.name != "_default")
1260 .map(|d| RelationshipHint {
1261 name: d.name.clone(),
1262 when_to_use: d.when_to_use.clone(),
1263 })
1264 .collect();
1265 out.sort_by(|a, b| a.name.cmp(&b.name));
1266 out
1267}
1268
1269fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
1274 let noise_floor = (needle.chars().count() / 2).max(1);
1275 let mut best: Option<(usize, String)> = None;
1276 for cand in candidates {
1277 let d = strsim::levenshtein(needle, cand);
1278 if d == 0 || d > noise_floor {
1279 continue;
1280 }
1281 match &best {
1282 Some((bd, _)) if *bd <= d => {}
1283 _ => best = Some((d, cand.clone())),
1284 }
1285 }
1286 best.map(|(_, name)| name)
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 use super::*;
1292
1293 fn shape_test_schema() -> std::sync::Arc<Schema> {
1298 let manifest_yaml = r#"name: tests-rel-shape
1299version: 0.1.0
1300description: rel-shape test schema
1301when_to_use: tests
1302types:
1303 - step
1304 - decision
1305 - note
1306relationships:
1307 mode: strict
1308 definitions:
1309 - name: PART_OF
1310 description: parent containment
1311 default_weight: 3.0
1312 acyclic: true
1313 - name: USES
1314 description: shape-free reference
1315 default_weight: 1.0
1316 - name: EXECUTES
1317 description: step carries out decision
1318 default_weight: 2.5
1319 source_types: [step]
1320 target_types: [decision]
1321 - name: _default
1322 description: fallback
1323 default_weight: 1.0
1324community:
1325 resolution: 1.0
1326 seed: 42
1327"#;
1328 let body_section = r#"sections:
1329 - key: body
1330 heading: Body
1331 required: true
1332 search_weight: 10.0
1333 catch_all: true
1334 write_rules: []
1335metadata_fields: []
1336title_weight: 100.0
1337text_fields:
1338 - body
1339hierarchy_relationship: PART_OF
1340no_self_loop_relationships: []
1341updatable_fields:
1342 - title
1343 - body
1344health_required_fields:
1345 - body
1346staleness_threshold_days: 90
1347write_rules: []
1348"#;
1349 let make_type =
1350 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1351 std::sync::Arc::new(
1352 memstead_schema::load_schema_from_memory(
1353 manifest_yaml,
1354 &[
1355 ("step".to_string(), make_type("step")),
1356 ("decision".to_string(), make_type("decision")),
1357 ("note".to_string(), make_type("note")),
1358 ],
1359 )
1360 .expect("test schema must load"),
1361 )
1362 }
1363
1364 #[test]
1365 fn rel_shape_admits_pair_in_declared_source_target() {
1366 let schema = shape_test_schema();
1367 assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
1369 }
1370
1371 #[test]
1372 fn rel_shape_rejects_violating_source() {
1373 let schema = shape_test_schema();
1374 let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
1376 match err {
1377 ValidationError::InvalidRelationshipShape {
1378 rel_type,
1379 from_type,
1380 to_type,
1381 allowed_source_types,
1382 allowed_target_types,
1383 ..
1384 } => {
1385 assert_eq!(rel_type, "EXECUTES");
1386 assert_eq!(from_type, "note");
1387 assert_eq!(to_type, "decision");
1388 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1389 assert_eq!(allowed_target_types, vec!["decision".to_string()]);
1390 }
1391 other => panic!("expected InvalidRelationshipShape, got {other:?}"),
1392 }
1393 }
1394
1395 #[test]
1396 fn rel_shape_rejects_violating_target() {
1397 let schema = shape_test_schema();
1398 let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
1400 assert!(matches!(
1401 err,
1402 ValidationError::InvalidRelationshipShape { .. }
1403 ));
1404 }
1405
1406 #[test]
1407 fn rel_shape_admits_shape_free_relationship() {
1408 let schema = shape_test_schema();
1409 assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
1411 }
1412
1413 #[test]
1414 fn rel_shape_skips_target_check_when_target_type_unknown() {
1415 let schema = shape_test_schema();
1416 assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
1419 }
1420
1421 #[test]
1422 fn rel_shape_no_op_for_unknown_rel_name() {
1423 let schema = shape_test_schema();
1424 assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
1427 }
1428
1429 fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
1442 let manifest_yaml = r#"name: source-cv
1443version: 0.1.0
1444description: cross-mem source schema
1445when_to_use: tests
1446types:
1447 - step
1448 - decision
1449relationships:
1450 mode: strict
1451 definitions:
1452 - name: IMPLEMENTS
1453 description: intra-mem only
1454 default_weight: 1.0
1455 - name: _default
1456 description: fallback
1457 default_weight: 1.0
1458cross_mem_relationships:
1459 - to_schema: other
1460 definitions:
1461 - name: ADDRESSES
1462 description: outbound shape-pinned
1463 default_weight: 1.0
1464 source_types: [step]
1465 target_types: [requirement]
1466 - name: MENTIONS
1467 description: outbound shape-free
1468 default_weight: 0.5
1469community:
1470 resolution: 1.0
1471 seed: 42
1472"#;
1473 let body_section = r#"sections:
1474 - key: body
1475 heading: Body
1476 required: true
1477 search_weight: 10.0
1478 catch_all: true
1479 write_rules: []
1480metadata_fields: []
1481title_weight: 100.0
1482text_fields:
1483 - body
1484hierarchy_relationship: _default
1485no_self_loop_relationships: []
1486updatable_fields:
1487 - title
1488 - body
1489health_required_fields:
1490 - body
1491staleness_threshold_days: 90
1492write_rules: []
1493"#;
1494 let make_type =
1495 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1496 std::sync::Arc::new(
1497 memstead_schema::load_schema_from_memory(
1498 manifest_yaml,
1499 &[
1500 ("step".to_string(), make_type("step")),
1501 ("decision".to_string(), make_type("decision")),
1502 ],
1503 )
1504 .expect("cross-mem source schema must load"),
1505 )
1506 }
1507
1508 fn other_target_ref() -> memstead_schema::SchemaRef {
1509 memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
1510 }
1511
1512 #[test]
1513 fn cross_mem_admits_declared_shape() {
1514 let src = cross_mem_source_schema();
1515 let target = other_target_ref();
1516 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1517 CrossMemRelCheck::Ok => {}
1518 other => panic!("expected Ok, got {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1523 fn cross_mem_no_matching_entry_returns_edge_not_declared() {
1524 let src = cross_mem_source_schema();
1525 let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
1529 match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
1530 CrossMemRelCheck::EdgeNotDeclared => {}
1531 other => panic!("expected EdgeNotDeclared, got {other:?}"),
1532 }
1533 }
1534
1535 #[test]
1536 fn cross_mem_entry_matches_any_target_version() {
1537 let src = cross_mem_source_schema();
1541 for version in [
1542 semver::Version::new(1, 0, 0),
1543 semver::Version::new(1, 1, 0),
1544 semver::Version::new(2, 5, 0),
1545 ] {
1546 let target = memstead_schema::SchemaRef::new("other", version.clone());
1547 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1548 CrossMemRelCheck::Ok => {}
1549 other => panic!("expected Ok against other@{version}, got {other:?}"),
1550 }
1551 }
1552 }
1553
1554 #[test]
1555 fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
1556 let src = cross_mem_source_schema();
1557 let target = other_target_ref();
1558 match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
1561 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1562 input,
1563 allowed,
1564 ..
1565 }) => {
1566 assert_eq!(input, "IMPLEMENTS");
1567 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
1569 assert!(names.iter().any(|n| n == "ADDRESSES"));
1570 assert!(names.iter().any(|n| n == "MENTIONS"));
1571 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
1573 }
1574 other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
1575 }
1576 }
1577
1578 #[test]
1579 fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
1580 let src = cross_mem_source_schema();
1581 let target = other_target_ref();
1582 match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
1587 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1588 rel_type,
1589 from_type,
1590 allowed_source_types,
1591 allowed_target_types,
1592 ..
1593 }) => {
1594 assert_eq!(rel_type, "ADDRESSES");
1595 assert_eq!(from_type, "decision");
1596 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1597 assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
1598 }
1599 other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
1600 }
1601 }
1602
1603 #[test]
1604 fn cross_mem_shape_free_rel_type_admits_any_pair() {
1605 let src = cross_mem_source_schema();
1606 let target = other_target_ref();
1607 assert!(matches!(
1609 validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
1610 CrossMemRelCheck::Ok
1611 ));
1612 }
1613
1614 fn wildcard_source_schema() -> std::sync::Arc<Schema> {
1618 let manifest_yaml = r#"name: source-wc
1619version: 0.1.0
1620description: wildcard cross-mem source schema
1621when_to_use: tests
1622types:
1623 - step
1624 - decision
1625relationships:
1626 mode: strict
1627 definitions:
1628 - name: SOFT_REF
1629 description: alias-emitted soft reference
1630 default_weight: 0.5
1631 - name: ADDRESSES
1632 description: structural
1633 default_weight: 1.0
1634 - name: _default
1635 description: fallback
1636 default_weight: 1.0
1637alias_target_rel_type: SOFT_REF
1638cross_mem_relationships:
1639 - to_schema: other
1640 definitions:
1641 - name: ADDRESSES
1642 description: structural, per-schema
1643 default_weight: 1.0
1644 source_types: [step]
1645 target_types: [requirement]
1646 - to_schema: "*"
1647 definitions:
1648 - name: SOFT_REF
1649 description: soft reference anywhere
1650 default_weight: 0.5
1651 source_types: [step]
1652community:
1653 resolution: 1.0
1654 seed: 42
1655"#;
1656 let body_section = r#"description: t
1657when_to_use: tests
1658sections:
1659 - key: body
1660 heading: Body
1661 required: true
1662 search_weight: 10.0
1663 catch_all: true
1664 write_rules: []
1665metadata_fields: []
1666title_weight: 100.0
1667text_fields:
1668 - body
1669hierarchy_relationship: _default
1670no_self_loop_relationships: []
1671updatable_fields:
1672 - title
1673 - body
1674health_required_fields:
1675 - body
1676staleness_threshold_days: 90
1677write_rules: []
1678"#;
1679 let types = vec![
1680 ("step".to_string(), format!("name: step\n{body_section}")),
1681 (
1682 "decision".to_string(),
1683 format!("name: decision\n{body_section}"),
1684 ),
1685 ];
1686 std::sync::Arc::new(
1687 memstead_schema::load_schema_from_memory(manifest_yaml, &types)
1688 .expect("wildcard schema loads"),
1689 )
1690 }
1691
1692 #[test]
1696 fn cross_mem_wildcard_admits_alias_edge_to_any_schema() {
1697 let src = wildcard_source_schema();
1698 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1700 assert!(matches!(
1701 validate_cross_mem_edge("SOFT_REF", "step", Some("argument"), &src, &user),
1702 CrossMemRelCheck::Ok
1703 ));
1704 let other = memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0));
1706 assert!(matches!(
1707 validate_cross_mem_edge("SOFT_REF", "step", Some("requirement"), &src, &other),
1708 CrossMemRelCheck::Ok
1709 ));
1710 assert!(matches!(
1711 validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &other),
1712 CrossMemRelCheck::Ok
1713 ));
1714 }
1715
1716 #[test]
1721 fn cross_mem_wildcard_keeps_source_type_gate_and_structural_refusal() {
1722 let src = wildcard_source_schema();
1723 let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
1724 match validate_cross_mem_edge("SOFT_REF", "decision", Some("argument"), &src, &user) {
1726 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1727 from_type,
1728 allowed_source_types,
1729 ..
1730 }) => {
1731 assert_eq!(from_type, "decision");
1732 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1733 }
1734 other => panic!("expected shape refusal on source-type gate, got {other:?}"),
1735 }
1736 assert!(matches!(
1740 validate_cross_mem_edge("ADDRESSES", "step", Some("argument"), &src, &user),
1741 CrossMemRelCheck::EdgeNotDeclared
1742 ));
1743 }
1744
1745 #[test]
1750 fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
1751 let err = ValidationError::UnknownSection {
1752 key: "implimentation".to_string(),
1753 entity_type: "spec".to_string(),
1754 declared: (0..8).map(|i| format!("sec{i}")).collect(),
1755 suggestion: Some("sec0".to_string()),
1756 };
1757 let prose = err.prose_render();
1758 for d in (0..8).map(|i| format!("sec{i}")) {
1759 assert!(prose.contains(&d), "missing {d} in: {prose}");
1760 }
1761 assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
1762 assert!(!prose.contains("see details"), "got: {prose}");
1763 }
1764
1765 #[test]
1766 fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
1767 let err = ValidationError::InvalidEnumValue {
1768 field: "level".to_string(),
1769 value: "M7".to_string(),
1770 allowed: (0..7).map(|i| format!("M{i}")).collect(),
1771 field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
1772 suggestion: Some("M6".to_string()),
1773 type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
1774 entity_type: "spec".to_string(),
1775 };
1776 let prose = err.prose_render();
1777 assert!(prose.contains("M0"), "got: {prose}");
1778 assert!(prose.contains("M6"), "got: {prose}");
1779 assert!(
1780 prose.contains("maturity rung"),
1781 "field_description missing: {prose}"
1782 );
1783 assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
1784 assert!(
1785 prose.contains("specs land at M0"),
1786 "type_write_rules missing: {prose}"
1787 );
1788 assert!(!prose.contains("see details"), "got: {prose}");
1789 }
1790
1791 #[test]
1792 fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
1793 let err = ValidationError::InvalidRelationshipShape {
1794 rel_type: "OWNS".to_string(),
1795 from_type: "spec".to_string(),
1796 to_type: "spec".to_string(),
1797 allowed_source_types: vec!["actor".to_string()],
1798 allowed_target_types: vec![],
1799 suggestion: None,
1800 };
1801 let prose = err.prose_render();
1802 assert!(prose.contains("allowed sources: actor"), "got: {prose}");
1806 assert!(prose.contains("allowed targets: any"), "got: {prose}");
1807 assert!(!prose.contains("see details"), "got: {prose}");
1808 }
1809
1810 fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
1822 let manifest_yaml = r#"name: tests-typed-fields
1823version: 0.1.0
1824description: typed-field test schema
1825when_to_use: tests
1826types:
1827 - widget
1828relationships:
1829 mode: strict
1830 definitions:
1831 - name: _default
1832 description: fallback
1833 default_weight: 1.0
1834community:
1835 resolution: 1.0
1836 seed: 42
1837"#;
1838 let type_yaml = r#"name: widget
1839description: t
1840when_to_use: Here
1841sections:
1842 - key: body
1843 heading: Body
1844 required: true
1845 search_weight: 10.0
1846 catch_all: true
1847 write_rules: []
1848metadata_fields:
1849 - key: verified_on
1850 description: ISO YYYY-MM-DD date the widget was verified
1851 field_type: date
1852 optional: true
1853 - key: order
1854 description: numeric ordering within a plan
1855 field_type: number
1856 optional: true
1857 - key: note
1858 description: free-form note
1859 field_type: string
1860 optional: true
1861title_weight: 100.0
1862text_fields:
1863 - body
1864hierarchy_relationship: _default
1865no_self_loop_relationships: []
1866updatable_fields:
1867 - title
1868 - body
1869health_required_fields:
1870 - body
1871staleness_threshold_days: 90
1872write_rules: []
1873"#;
1874 let schema = memstead_schema::load_schema_from_memory(
1875 manifest_yaml,
1876 &[("widget".to_string(), type_yaml.to_string())],
1877 )
1878 .expect("typed-field test schema must load");
1879 schema.get_type("widget").expect("widget type present")
1880 }
1881
1882 #[test]
1883 fn date_field_rejects_non_date_value() {
1884 let ty = typed_field_type();
1885 let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
1886 assert_eq!(err.code(), "INVALID_FIELD_VALUE");
1887 match err {
1888 ValidationError::InvalidFieldValue {
1889 field,
1890 value,
1891 expected_type,
1892 entity_type,
1893 ..
1894 } => {
1895 assert_eq!(field, "verified_on");
1896 assert_eq!(value, "not-a-real-date");
1897 assert_eq!(expected_type, "Date");
1898 assert_eq!(entity_type, "widget");
1899 }
1900 other => panic!("expected InvalidFieldValue, got {other:?}"),
1901 }
1902 }
1903
1904 #[test]
1905 fn date_field_rejects_empty_string() {
1906 let ty = typed_field_type();
1907 let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
1908 assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
1909 }
1910
1911 #[test]
1912 fn date_field_accepts_iso_date_and_datetime() {
1913 let ty = typed_field_type();
1914 match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
1915 MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
1916 other => panic!("expected String, got {other:?}"),
1917 }
1918 assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
1920 }
1921
1922 #[test]
1923 fn number_field_rejects_non_numeric_value() {
1924 let ty = typed_field_type();
1925 let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
1926 match err {
1927 ValidationError::InvalidFieldValue {
1928 field,
1929 expected_type,
1930 ..
1931 } => {
1932 assert_eq!(field, "order");
1933 assert_eq!(expected_type, "Number");
1934 }
1935 other => panic!("expected InvalidFieldValue, got {other:?}"),
1936 }
1937 }
1938
1939 #[test]
1940 fn number_field_accepts_integer_and_float() {
1941 let ty = typed_field_type();
1942 assert!(matches!(
1943 parse_metadata_value("order", "3", &ty).unwrap(),
1944 MetadataValue::Integer(3)
1945 ));
1946 assert!(matches!(
1947 parse_metadata_value("order", "2.5", &ty).unwrap(),
1948 MetadataValue::Float(_)
1949 ));
1950 }
1951
1952 #[test]
1953 fn string_field_accepts_any_value() {
1954 let ty = typed_field_type();
1955 assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
1957 }
1958
1959 #[test]
1960 fn invalid_field_value_prose_inlines_format_and_purpose() {
1961 let err = ValidationError::InvalidFieldValue {
1962 field: "verified_on".to_string(),
1963 value: "not-a-real-date".to_string(),
1964 expected_type: "Date".to_string(),
1965 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
1966 field_description: Some("date the widget was verified".to_string()),
1967 entity_type: "widget".to_string(),
1968 };
1969 let prose = err.prose_render();
1970 assert!(prose.contains("not-a-real-date"), "got: {prose}");
1971 assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
1972 assert!(
1973 prose.contains("date the widget was verified"),
1974 "purpose missing: {prose}"
1975 );
1976 assert!(!prose.contains("see details"), "got: {prose}");
1977 }
1978
1979 #[test]
1980 fn is_date_shaped_matches_strict_validator_contract() {
1981 assert!(is_date_shaped("2024-06-01"));
1982 assert!(is_date_shaped("2024-06-01T12:30:00Z"));
1983 assert!(!is_date_shaped(""));
1984 assert!(!is_date_shaped("not-a-real-date"));
1985 assert!(!is_date_shaped("2024-6-1"));
1986 assert!(!is_date_shaped("2024-06-01 extra"));
1987 }
1988
1989 #[test]
1990 fn section_content_refuses_nul_byte() {
1991 let err = validate_section_content([("body", "line1\u{0}line2")].into_iter())
1992 .expect_err("NUL in a section body must be refused");
1993 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
1994 match &err {
1995 ValidationError::SectionContentControlByte {
1996 section,
1997 control_char,
1998 codepoint,
1999 byte_offset,
2000 } => {
2001 assert_eq!(section, "body");
2002 assert_eq!(*control_char, '\u{0}');
2003 assert_eq!(*codepoint, 0);
2004 assert_eq!(*byte_offset, 5);
2006 }
2007 other => panic!("expected SectionContentControlByte, got {other:?}"),
2008 }
2009 let details = err.details();
2011 assert_eq!(details["codepoint"], 0);
2012 assert_eq!(details["byte_offset"], 5);
2013 assert_eq!(details["section"], "body");
2014 }
2015
2016 #[test]
2017 fn section_content_refuses_other_c0_controls_and_cr() {
2018 for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
2021 let body = format!("ok{bad}more");
2022 let err = validate_section_content([("s", body.as_str())].into_iter())
2023 .expect_err("control char must be refused");
2024 assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
2025 }
2026 }
2027
2028 #[test]
2029 fn section_content_allows_tab_and_newline() {
2030 validate_section_content([("body", "line1\nline2\n\tindented\tcols\n")].into_iter())
2033 .expect("tab and newline must stay legal in section bodies");
2034 }
2035
2036 #[test]
2037 fn section_content_keeps_backslashes_verbatim() {
2038 validate_section_content(
2042 [("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
2043 )
2044 .expect("backslashes are literal content, not control bytes");
2045 }
2046
2047 #[test]
2048 fn section_content_still_refuses_heading_injection() {
2049 let err = validate_section_content([("body", "intro\n## Injected\ntail")].into_iter())
2052 .expect_err("embedded `## ` heading must still be refused");
2053 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2054 assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
2055 }
2056
2057 #[test]
2062 fn section_content_admits_a_heading_inside_a_code_block() {
2063 for body in [
2064 "intro\n\n```\n## Not A Heading\n```\n",
2065 "intro\n\n~~~\n## Not A Heading\n~~~\n",
2066 "intro\n\n> ```\n> ## Not A Heading\n> ```\n",
2067 "intro\n\n ## Not A Heading\n",
2068 ] {
2069 validate_section_content([("body", body)].into_iter())
2070 .unwrap_or_else(|e| panic!("code-block content must be admitted: {body:?} -> {e}"));
2071 }
2072 }
2073
2074 #[test]
2079 fn section_content_refuses_the_trim_fork() {
2080 let err =
2081 validate_section_content([("body", " ## Not A Heading\n more\n")].into_iter())
2082 .expect_err("content whose trim exposes a column-0 heading must be refused");
2083 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2084 match err {
2085 ValidationError::SectionContentInvalid {
2086 embedded_heading, ..
2087 } => assert_eq!(
2088 embedded_heading, "## Not A Heading",
2089 "the refusal quotes the line the reparse will see"
2090 ),
2091 other => panic!("unexpected error: {other}"),
2092 }
2093 }
2094
2095 #[test]
2096 fn section_content_refuses_the_trim_fork_for_h1_too() {
2097 let err = validate_section_content([("body", " # Not A Title\n")].into_iter())
2098 .expect_err("h1 exposed by the trim must be refused");
2099 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
2100 }
2101}