1use std::sync::OnceLock;
18
19use indexmap::IndexMap;
20use memstead_schema::{
21 CrossMemRelationshipEntry, FieldType, RelationshipDef, RelationshipMode, Schema, TypeDefinition,
22};
23use regex::Regex;
24
25use crate::entity::MetadataValue;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct RelationshipHint {
35 pub name: String,
36 pub when_to_use: Option<String>,
37}
38
39impl std::fmt::Display for RelationshipHint {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.write_str(&self.name)
45 }
46}
47
48#[derive(Debug, Clone, thiserror::Error)]
52pub enum ValidationError {
53 #[error("unknown section '{key}' for type '{entity_type}'")]
56 UnknownSection {
57 key: String,
58 entity_type: String,
59 declared: Vec<String>,
60 suggestion: Option<String>,
61 },
62 #[error("unknown metadata field '{key}' for type '{entity_type}'")]
64 UnknownMetadata {
65 key: String,
66 entity_type: String,
67 declared: Vec<String>,
68 suggestion: Option<String>,
69 },
70 #[error("invalid value '{value}' for field '{field}' on type '{entity_type}'")]
73 InvalidEnumValue {
74 field: String,
75 value: String,
76 allowed: Vec<String>,
77 field_description: Option<String>,
78 suggestion: Option<String>,
79 type_write_rules: Vec<String>,
80 entity_type: String,
81 },
82 #[error("cannot change read-only field '{field}' via update")]
85 ReadOnlyField { field: String },
86 #[error("section '{section}' is not updatable for type '{entity_type}'")]
91 SectionNotUpdatable {
92 section: String,
93 entity_type: String,
94 },
95 #[error("invalid relationship type '{input}'")]
100 InvalidRelationshipType {
101 input: String,
102 allowed: Vec<RelationshipHint>,
103 suggestion: Option<String>,
104 },
105 #[error(
110 "relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape"
111 )]
112 InvalidRelationshipShape {
113 rel_type: String,
114 from_type: String,
115 to_type: String,
116 allowed_source_types: Vec<String>,
117 allowed_target_types: Vec<String>,
118 suggestion: Option<RelationshipHint>,
119 },
120 #[error(
128 "section '{section}' content contains an embedded `^## ` heading line '{embedded_heading}' — \
129 the compose-then-reparse pipeline would split the value at that heading; use `### ` or \
130 deeper for sub-headings"
131 )]
132 SectionContentInvalid {
133 section: String,
134 embedded_heading: String,
135 },
136 #[error(
149 "section '{section}' content contains a disallowed control character U+{codepoint:04X} \
150 at byte offset {byte_offset} — only tab and newline are permitted in section bodies"
151 )]
152 SectionContentControlByte {
153 section: String,
154 control_char: char,
157 codepoint: u32,
160 byte_offset: usize,
163 },
164 #[error(
175 "invalid value '{value}' for field '{field}' on type '{entity_type}' — expected {expected_type}"
176 )]
177 InvalidFieldValue {
178 field: String,
179 value: String,
180 expected_type: String,
181 expected_format: Option<String>,
182 field_description: Option<String>,
183 entity_type: String,
184 },
185}
186
187impl ValidationError {
188 pub fn code(&self) -> &'static str {
193 match self {
194 ValidationError::UnknownSection { .. } => "UNKNOWN_SECTION",
195 ValidationError::UnknownMetadata { .. } => "UNKNOWN_METADATA_FIELD",
196 ValidationError::InvalidEnumValue { .. } => "INVALID_ENUM_VALUE",
197 ValidationError::ReadOnlyField { .. } => "READ_ONLY_FIELD",
198 ValidationError::SectionNotUpdatable { .. } => "SECTION_NOT_UPDATABLE",
199 ValidationError::InvalidRelationshipType { .. } => "INVALID_REL_TYPE",
200 ValidationError::InvalidRelationshipShape { .. } => "INVALID_REL_SHAPE",
201 ValidationError::SectionContentInvalid { .. } => "SECTION_CONTENT_INVALID",
202 ValidationError::SectionContentControlByte { .. } => "SECTION_CONTENT_INVALID",
203 ValidationError::InvalidFieldValue { .. } => "INVALID_FIELD_VALUE",
204 }
205 }
206
207 pub fn details(&self) -> serde_json::Value {
214 match self {
215 ValidationError::UnknownSection {
216 key,
217 entity_type,
218 declared,
219 suggestion,
220 } => serde_json::json!({
221 "key": key,
222 "entity_type": entity_type,
223 "declared": declared,
224 "suggestion": suggestion,
225 }),
226 ValidationError::UnknownMetadata {
227 key,
228 entity_type,
229 declared,
230 suggestion,
231 } => serde_json::json!({
232 "key": key,
233 "entity_type": entity_type,
234 "declared": declared,
235 "suggestion": suggestion,
236 }),
237 ValidationError::InvalidEnumValue {
238 field,
239 value,
240 allowed,
241 field_description,
242 suggestion,
243 type_write_rules,
244 entity_type,
245 } => serde_json::json!({
246 "field": field,
247 "value": value,
248 "allowed": allowed,
249 "field_description": field_description,
250 "suggestion": suggestion,
251 "type_write_rules": type_write_rules,
252 "entity_type": entity_type,
253 }),
254 ValidationError::ReadOnlyField { field } => serde_json::json!({
255 "field": field,
256 }),
257 ValidationError::SectionNotUpdatable {
258 section,
259 entity_type,
260 } => serde_json::json!({
261 "section": section,
262 "entity_type": entity_type,
263 }),
264 ValidationError::InvalidRelationshipType {
265 input,
266 allowed,
267 suggestion,
268 } => {
269 let allowed_json: Vec<serde_json::Value> = allowed
270 .iter()
271 .map(|h| {
272 serde_json::json!({
273 "name": h.name,
274 "when_to_use": h.when_to_use,
275 })
276 })
277 .collect();
278 serde_json::json!({
279 "input": input,
280 "allowed": allowed_json,
281 "suggestion": suggestion,
282 })
283 }
284 ValidationError::InvalidRelationshipShape {
285 rel_type,
286 from_type,
287 to_type,
288 allowed_source_types,
289 allowed_target_types,
290 suggestion,
291 } => {
292 let suggestion_json = suggestion.as_ref().map(|h| {
293 serde_json::json!({
294 "name": h.name,
295 "when_to_use": h.when_to_use,
296 })
297 });
298 let mut details = serde_json::Map::new();
299 details.insert(
300 "rel_type".into(),
301 serde_json::Value::String(rel_type.clone()),
302 );
303 details.insert(
304 "from_type".into(),
305 serde_json::Value::String(from_type.clone()),
306 );
307 details.insert("to_type".into(), serde_json::Value::String(to_type.clone()));
308 if !allowed_source_types.is_empty() {
315 details.insert(
316 "allowed_source_types".into(),
317 serde_json::json!(allowed_source_types),
318 );
319 }
320 if !allowed_target_types.is_empty() {
321 details.insert(
322 "allowed_target_types".into(),
323 serde_json::json!(allowed_target_types),
324 );
325 }
326 details.insert("suggestion".into(), serde_json::json!(suggestion_json));
327 serde_json::Value::Object(details)
328 }
329 ValidationError::SectionContentInvalid {
330 section,
331 embedded_heading,
332 } => serde_json::json!({
333 "section": section,
334 "embedded_heading": embedded_heading,
335 }),
336 ValidationError::SectionContentControlByte {
337 section,
338 control_char,
339 codepoint,
340 byte_offset,
341 } => serde_json::json!({
342 "section": section,
343 "control_char": control_char.to_string(),
344 "codepoint": codepoint,
345 "byte_offset": byte_offset,
346 }),
347 ValidationError::InvalidFieldValue {
348 field,
349 value,
350 expected_type,
351 expected_format,
352 field_description,
353 entity_type,
354 } => serde_json::json!({
355 "field": field,
356 "value": value,
357 "expected_type": expected_type,
358 "expected_format": expected_format,
359 "field_description": field_description,
360 "entity_type": entity_type,
361 }),
362 }
363 }
364
365 pub fn prose_render(&self) -> String {
375 match self {
376 ValidationError::UnknownSection {
377 key,
378 entity_type,
379 declared,
380 suggestion,
381 } => {
382 let declared_inline = if declared.is_empty() {
383 "(none)".to_string()
384 } else {
385 declared.join(", ")
386 };
387 let suggestion_clause = suggestion
388 .as_deref()
389 .map(|s| format!(" Did you mean '{s}'?"))
390 .unwrap_or_default();
391 format!(
392 "unknown section '{key}' for type '{entity_type}' — declared sections: {declared_inline}.{suggestion_clause}"
393 )
394 }
395 ValidationError::UnknownMetadata {
396 key,
397 entity_type,
398 declared,
399 suggestion,
400 } => {
401 let declared_inline = if declared.is_empty() {
402 "(none)".to_string()
403 } else {
404 declared.join(", ")
405 };
406 let suggestion_clause = suggestion
407 .as_deref()
408 .map(|s| format!(" Did you mean '{s}'?"))
409 .unwrap_or_default();
410 format!(
411 "unknown metadata field '{key}' for type '{entity_type}' — declared fields: {declared_inline}.{suggestion_clause}"
412 )
413 }
414 ValidationError::InvalidEnumValue {
415 field,
416 value,
417 allowed,
418 field_description,
419 suggestion,
420 type_write_rules,
421 entity_type,
422 } => {
423 let allowed_inline = if allowed.is_empty() {
424 "(none)".to_string()
425 } else {
426 allowed.join(", ")
427 };
428 let desc_clause = field_description
429 .as_deref()
430 .map(|d| format!(" Field purpose: {d}."))
431 .unwrap_or_default();
432 let suggestion_clause = suggestion
433 .as_deref()
434 .map(|s| format!(" Did you mean '{s}'?"))
435 .unwrap_or_default();
436 let rules_clause = if type_write_rules.is_empty() {
437 String::new()
438 } else {
439 format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
440 };
441 format!(
442 "invalid value '{value}' for field '{field}' on type '{entity_type}' — allowed: {allowed_inline}.{desc_clause}{suggestion_clause}{rules_clause}"
443 )
444 }
445 ValidationError::ReadOnlyField { field } => {
446 format!("cannot change read-only field '{field}' via update")
447 }
448 ValidationError::SectionNotUpdatable {
449 section,
450 entity_type,
451 } => format!("section '{section}' is not updatable for type '{entity_type}'"),
452 ValidationError::InvalidRelationshipType {
453 input,
454 allowed,
455 suggestion,
456 } => {
457 let allowed_inline = if allowed.is_empty() {
458 "(none)".to_string()
459 } else {
460 allowed
461 .iter()
462 .map(|h| h.name.clone())
463 .collect::<Vec<_>>()
464 .join(", ")
465 };
466 let suggestion_clause = suggestion
467 .as_deref()
468 .map(|s| format!(" Did you mean '{s}'?"))
469 .unwrap_or_default();
470 format!(
471 "invalid relationship type '{input}' — must be one of the schema's declared types: {allowed_inline}.{suggestion_clause}"
472 )
473 }
474 ValidationError::InvalidRelationshipShape {
475 rel_type,
476 from_type,
477 to_type,
478 allowed_source_types,
479 allowed_target_types,
480 suggestion,
481 } => {
482 let sources_inline = if allowed_source_types.is_empty() {
483 "any".to_string()
484 } else {
485 allowed_source_types.join(", ")
486 };
487 let targets_inline = if allowed_target_types.is_empty() {
488 "any".to_string()
489 } else {
490 allowed_target_types.join(", ")
491 };
492 let suggestion_clause = suggestion
493 .as_ref()
494 .map(|h| format!(" Suggested rel-type: '{}'.", h.name))
495 .unwrap_or_default();
496 format!(
497 "relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape — allowed sources: {sources_inline}; allowed targets: {targets_inline}.{suggestion_clause}"
498 )
499 }
500 ValidationError::SectionContentInvalid {
501 section,
502 embedded_heading,
503 } => format!(
504 "section '{section}' content contains an embedded `## ` heading line '{embedded_heading}' — use `### ` or deeper for sub-headings"
505 ),
506 ValidationError::SectionContentControlByte {
507 section,
508 codepoint,
509 byte_offset,
510 ..
511 } => format!(
512 "section '{section}' content contains a disallowed control character U+{codepoint:04X} at byte offset {byte_offset} — \
513 only tab (U+0009) and newline (U+000A) are permitted in section bodies. Remove the control character: it would break \
514 the diffable-markdown invariant (a NUL makes git treat the file as binary and text tooling truncates at it)."
515 ),
516 ValidationError::InvalidFieldValue {
517 field,
518 value,
519 expected_type,
520 expected_format,
521 field_description,
522 entity_type,
523 } => {
524 let format_clause = expected_format
525 .as_deref()
526 .map(|f| format!(" Expected format: {f}."))
527 .unwrap_or_default();
528 let desc_clause = field_description
529 .as_deref()
530 .map(|d| format!(" Field purpose: {d}."))
531 .unwrap_or_default();
532 format!(
533 "invalid value '{value}' for field '{field}' on type '{entity_type}' — \
534 not a valid {expected_type}.{format_clause}{desc_clause}"
535 )
536 }
537 }
538 }
539}
540
541pub const READ_ONLY_METADATA_KEYS: &[&str] = &["mem", "id", "type"];
546
547pub fn validate_writable_metadata_key(
556 key: &str,
557 schema: &TypeDefinition,
558) -> Result<(), ValidationError> {
559 if READ_ONLY_METADATA_KEYS.contains(&key) {
560 return Err(ValidationError::ReadOnlyField {
561 field: key.to_string(),
562 });
563 }
564 if let Some(field) = schema.metadata_field(key)
565 && (field.init_timestamp || field.auto_timestamp)
566 {
567 return Err(ValidationError::ReadOnlyField {
568 field: key.to_string(),
569 });
570 }
571 Ok(())
572}
573
574pub fn validate_updatable_section(
580 section: &str,
581 schema: &TypeDefinition,
582) -> Result<(), ValidationError> {
583 if section == "relationships" {
584 return Err(ValidationError::SectionNotUpdatable {
585 section: section.to_string(),
586 entity_type: schema.name.clone(),
587 });
588 }
589 if !schema.updatable_fields.is_empty() && !schema.updatable_fields.iter().any(|f| f == section)
590 {
591 return Err(ValidationError::SectionNotUpdatable {
592 section: section.to_string(),
593 entity_type: schema.name.clone(),
594 });
595 }
596 Ok(())
597}
598
599#[derive(Debug, Clone)]
606pub struct MissingRequiredSection {
607 pub entity_type: String,
608 pub key: String,
609 pub heading: String,
610 pub write_rules: Vec<String>,
611}
612
613pub fn validate_section_content<'a>(
621 sections: impl Iterator<Item = (&'a str, &'a str)>,
622) -> Result<(), ValidationError> {
623 for (key, value) in sections {
624 if let Some((byte_offset, ch)) = value
636 .char_indices()
637 .find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
638 {
639 return Err(ValidationError::SectionContentControlByte {
640 section: key.to_string(),
641 control_char: ch,
642 codepoint: ch as u32,
643 byte_offset,
644 });
645 }
646 for line in value.lines() {
647 if line.starts_with("## ") && line.len() > 3 {
652 return Err(ValidationError::SectionContentInvalid {
653 section: key.to_string(),
654 embedded_heading: line.to_string(),
655 });
656 }
657 }
658 }
659 Ok(())
660}
661
662pub fn validate_section_keys<'a>(
672 provided: impl Iterator<Item = &'a str>,
673 schema: &TypeDefinition,
674) -> Result<(), ValidationError> {
675 let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
676 declared.sort();
677 let declared_set: std::collections::HashSet<&str> =
678 schema.sections.iter().map(|s| s.key.as_str()).collect();
679 let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
680
681 for key in provided {
682 if key == "relationships" {
683 continue;
684 }
685 if declared_set.contains(key) {
686 continue;
687 }
688 let suggestion = schema
689 .suggest_section(key)
690 .or_else(|| catch_all_key.clone());
691 return Err(ValidationError::UnknownSection {
692 key: key.to_string(),
693 entity_type: schema.name.clone(),
694 declared: declared.clone(),
695 suggestion,
696 });
697 }
698 Ok(())
699}
700
701pub fn parse_metadata_value(
711 key: &str,
712 value: &str,
713 schema: &TypeDefinition,
714) -> Result<MetadataValue, ValidationError> {
715 let Some(field_def) = schema.metadata_field(key) else {
716 let mut declared: Vec<String> = schema
717 .metadata_fields
718 .iter()
719 .map(|f| f.key.clone())
720 .collect();
721 declared.sort();
722 return Err(ValidationError::UnknownMetadata {
723 key: key.to_string(),
724 entity_type: schema.name.clone(),
725 declared,
726 suggestion: schema.suggest_metadata_field(key),
727 });
728 };
729
730 if let Some(ref allowed) = field_def.enum_values
731 && !allowed.iter().any(|v| v == value)
732 {
733 let suggestion = nearest_str_match(value, allowed);
734 return Err(ValidationError::InvalidEnumValue {
735 field: key.to_string(),
736 value: value.to_string(),
737 allowed: allowed.clone(),
738 field_description: Some(field_def.description.clone()),
739 suggestion,
740 type_write_rules: schema.write_rules.clone(),
741 entity_type: schema.name.clone(),
742 });
743 }
744
745 Ok(match field_def.field_type {
746 FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
747 FieldType::Number => {
748 if let Ok(n) = value.parse::<i64>() {
749 MetadataValue::Integer(n)
750 } else if let Ok(f) = value.parse::<f64>() {
751 MetadataValue::Float(f)
752 } else {
753 return Err(ValidationError::InvalidFieldValue {
758 field: key.to_string(),
759 value: value.to_string(),
760 expected_type: "Number".to_string(),
761 expected_format: Some("an integer or decimal number".to_string()),
762 field_description: Some(field_def.description.clone()),
763 entity_type: schema.name.clone(),
764 });
765 }
766 }
767 FieldType::Date => {
768 if !is_date_shaped(value) {
776 return Err(ValidationError::InvalidFieldValue {
777 field: key.to_string(),
778 value: value.to_string(),
779 expected_type: "Date".to_string(),
780 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
781 field_description: Some(field_def.description.clone()),
782 entity_type: schema.name.clone(),
783 });
784 }
785 MetadataValue::String(value.to_string())
786 }
787 _ => MetadataValue::String(value.to_string()),
788 })
789}
790
791pub fn is_date_shaped(s: &str) -> bool {
800 static RE: OnceLock<Regex> = OnceLock::new();
801 RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
802 .is_match(s)
803}
804
805#[derive(Debug, Clone)]
813pub struct MissingRequiredField {
814 pub entity_type: String,
815 pub key: String,
816 pub description: String,
817 pub enum_values: Vec<String>,
818}
819
820pub fn missing_required_fields(
831 schema: &TypeDefinition,
832 supplied: &IndexMap<String, String>,
833) -> Vec<MissingRequiredField> {
834 schema
835 .metadata_fields
836 .iter()
837 .filter(|f| {
838 !READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
842 && !f.optional
843 && f.default_value.is_none()
844 && !f.init_timestamp
845 && !f.auto_timestamp
846 && !supplied.contains_key(f.key.as_str())
847 })
848 .map(|f| MissingRequiredField {
849 entity_type: schema.name.clone(),
850 key: f.key.clone(),
851 description: f.description.clone(),
852 enum_values: f.enum_values.clone().unwrap_or_default(),
853 })
854 .collect()
855}
856
857pub fn missing_required_sections(
861 schema: &TypeDefinition,
862 sections: &IndexMap<String, String>,
863) -> Vec<MissingRequiredSection> {
864 schema
865 .required_sections()
866 .filter_map(|sec| {
867 let is_empty = sections
868 .get(sec.key.as_str())
869 .is_none_or(|c| c.trim().is_empty());
870 is_empty.then(|| MissingRequiredSection {
871 entity_type: schema.name.clone(),
872 key: sec.key.clone(),
873 heading: sec.heading.clone(),
874 write_rules: sec.write_rules.clone(),
875 })
876 })
877 .collect()
878}
879
880#[derive(Debug, Clone)]
885pub enum RelationshipCheck {
886 Ok,
888 OpenWarning(String),
891}
892
893pub fn validate_rel_type(
903 rel_type: &str,
904 schema: &Schema,
905) -> Result<RelationshipCheck, ValidationError> {
906 if schema.relationship_known(rel_type) {
907 return Ok(RelationshipCheck::Ok);
908 }
909 match schema.mode() {
910 RelationshipMode::Strict => {
911 let allowed = declared_relationship_hints(schema);
912 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
913 let suggestion = nearest_str_match(rel_type, &candidate_names);
914 Err(ValidationError::InvalidRelationshipType {
915 input: rel_type.to_string(),
916 allowed,
917 suggestion,
918 })
919 }
920 RelationshipMode::Open => {
921 let declared: Vec<String> = declared_relationship_hints(schema)
922 .into_iter()
923 .map(|h| h.name)
924 .collect();
925 let suggestion = schema
926 .suggest_relationship(rel_type)
927 .map(|s| format!(" Did you mean '{s}'?"))
928 .unwrap_or_default();
929 let (schema_name, schema_version) = schema.id();
930 Ok(RelationshipCheck::OpenWarning(format!(
931 "relationship '{rel_type}' is not declared in schema \
932 '{schema_name}@{schema_version}' (mode: open). \
933 Accepted with default weight. Declared: [{}].{suggestion}",
934 declared.join(", "),
935 )))
936 }
937 }
938}
939
940pub fn validate_rel_shape(
954 rel_type: &str,
955 from_type: &str,
956 to_type: Option<&str>,
957 schema: &Schema,
958) -> Result<(), ValidationError> {
959 let Some(def) = schema.relationship_def(rel_type) else {
960 return Ok(());
961 };
962 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
963 let target_ok = def.target_types.is_empty()
964 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
965 if source_ok && target_ok {
966 return Ok(());
967 }
968 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
969 let suggestion = suggest_shape_admitting(from_type, to_type, schema);
970 Err(ValidationError::InvalidRelationshipShape {
971 rel_type: rel_type.to_string(),
972 from_type: from_type.to_string(),
973 to_type: to_for_err,
974 allowed_source_types: def.source_types.clone(),
975 allowed_target_types: def.target_types.clone(),
976 suggestion,
977 })
978}
979
980#[derive(Debug, Clone)]
989pub enum CrossMemRelCheck {
990 Ok,
994 EdgeNotDeclared,
998 Invalid(ValidationError),
1003}
1004
1005pub fn validate_cross_mem_edge(
1027 rel_type: &str,
1028 from_type: &str,
1029 to_type: Option<&str>,
1030 source_schema: &Schema,
1031 target_schema_ref: &memstead_schema::SchemaRef,
1032) -> CrossMemRelCheck {
1033 let Some(entry) = source_schema.cross_mem_entry(&target_schema_ref.name) else {
1034 return CrossMemRelCheck::EdgeNotDeclared;
1035 };
1036
1037 let Some(def) = entry.definitions.iter().find(|d| d.name == rel_type) else {
1038 let allowed: Vec<RelationshipHint> = cross_mem_entry_hints(entry);
1039 let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
1040 let suggestion = nearest_str_match(rel_type, &candidate_names);
1041 return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1042 input: rel_type.to_string(),
1043 allowed,
1044 suggestion,
1045 });
1046 };
1047
1048 let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
1049 let target_ok = def.target_types.is_empty()
1050 || to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
1051 if source_ok && target_ok {
1052 return CrossMemRelCheck::Ok;
1053 }
1054 let to_for_err = to_type.unwrap_or("<unknown>").to_string();
1055 let suggestion = cross_mem_suggest_shape(entry, from_type, to_type);
1056 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1057 rel_type: rel_type.to_string(),
1058 from_type: from_type.to_string(),
1059 to_type: to_for_err,
1060 allowed_source_types: def.source_types.clone(),
1061 allowed_target_types: def.target_types.clone(),
1062 suggestion,
1063 })
1064}
1065
1066fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
1071 let mut out: Vec<RelationshipHint> = entry
1072 .definitions
1073 .iter()
1074 .filter(|d| d.name != "_default")
1075 .map(|d| RelationshipHint {
1076 name: d.name.clone(),
1077 when_to_use: d.when_to_use.clone(),
1078 })
1079 .collect();
1080 out.sort_by(|a, b| a.name.cmp(&b.name));
1081 out
1082}
1083
1084fn cross_mem_suggest_shape(
1089 entry: &CrossMemRelationshipEntry,
1090 from_type: &str,
1091 to_type: Option<&str>,
1092) -> Option<RelationshipHint> {
1093 entry
1094 .definitions
1095 .iter()
1096 .filter(|d| d.name != "_default")
1097 .find(|d| cross_mem_def_admits(d, from_type, to_type))
1098 .map(|d| RelationshipHint {
1099 name: d.name.clone(),
1100 when_to_use: d.when_to_use.clone(),
1101 })
1102}
1103
1104fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
1105 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1106 let tgt_ok =
1107 d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1108 src_ok && tgt_ok
1109}
1110
1111fn suggest_shape_admitting(
1117 from_type: &str,
1118 to_type: Option<&str>,
1119 schema: &Schema,
1120) -> Option<RelationshipHint> {
1121 schema
1122 .manifest
1123 .relationships
1124 .definitions
1125 .iter()
1126 .filter(|d| d.name != "_default")
1127 .find(|d| {
1128 let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
1129 let tgt_ok = d.target_types.is_empty()
1130 || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
1131 src_ok && tgt_ok
1132 })
1133 .map(|d| RelationshipHint {
1134 name: d.name.clone(),
1135 when_to_use: d.when_to_use.clone(),
1136 })
1137}
1138
1139fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
1144 let mut out: Vec<RelationshipHint> = schema
1145 .manifest
1146 .relationships
1147 .definitions
1148 .iter()
1149 .filter(|d| d.name != "_default")
1150 .map(|d| RelationshipHint {
1151 name: d.name.clone(),
1152 when_to_use: d.when_to_use.clone(),
1153 })
1154 .collect();
1155 out.sort_by(|a, b| a.name.cmp(&b.name));
1156 out
1157}
1158
1159fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
1164 let noise_floor = (needle.chars().count() / 2).max(1);
1165 let mut best: Option<(usize, String)> = None;
1166 for cand in candidates {
1167 let d = strsim::levenshtein(needle, cand);
1168 if d == 0 || d > noise_floor {
1169 continue;
1170 }
1171 match &best {
1172 Some((bd, _)) if *bd <= d => {}
1173 _ => best = Some((d, cand.clone())),
1174 }
1175 }
1176 best.map(|(_, name)| name)
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182
1183 fn shape_test_schema() -> std::sync::Arc<Schema> {
1188 let manifest_yaml = r#"name: tests-rel-shape
1189version: 0.1.0
1190description: rel-shape test schema
1191when_to_use: tests
1192types:
1193 - step
1194 - decision
1195 - note
1196relationships:
1197 mode: strict
1198 definitions:
1199 - name: PART_OF
1200 description: parent containment
1201 default_weight: 3.0
1202 acyclic: true
1203 - name: USES
1204 description: shape-free reference
1205 default_weight: 1.0
1206 - name: EXECUTES
1207 description: step carries out decision
1208 default_weight: 2.5
1209 source_types: [step]
1210 target_types: [decision]
1211 - name: _default
1212 description: fallback
1213 default_weight: 1.0
1214community:
1215 resolution: 1.0
1216 seed: 42
1217"#;
1218 let body_section = r#"sections:
1219 - key: body
1220 heading: Body
1221 required: true
1222 search_weight: 10.0
1223 catch_all: true
1224 write_rules: []
1225metadata_fields: []
1226title_weight: 100.0
1227text_fields:
1228 - body
1229hierarchy_relationship: PART_OF
1230propagating_relationships: []
1231updatable_fields:
1232 - title
1233 - body
1234health_required_fields:
1235 - body
1236staleness_threshold_days: 90
1237write_rules: []
1238"#;
1239 let make_type =
1240 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1241 std::sync::Arc::new(
1242 memstead_schema::load_schema_from_memory(
1243 manifest_yaml,
1244 &[
1245 ("step".to_string(), make_type("step")),
1246 ("decision".to_string(), make_type("decision")),
1247 ("note".to_string(), make_type("note")),
1248 ],
1249 )
1250 .expect("test schema must load"),
1251 )
1252 }
1253
1254 #[test]
1255 fn rel_shape_admits_pair_in_declared_source_target() {
1256 let schema = shape_test_schema();
1257 assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
1259 }
1260
1261 #[test]
1262 fn rel_shape_rejects_violating_source() {
1263 let schema = shape_test_schema();
1264 let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
1266 match err {
1267 ValidationError::InvalidRelationshipShape {
1268 rel_type,
1269 from_type,
1270 to_type,
1271 allowed_source_types,
1272 allowed_target_types,
1273 ..
1274 } => {
1275 assert_eq!(rel_type, "EXECUTES");
1276 assert_eq!(from_type, "note");
1277 assert_eq!(to_type, "decision");
1278 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1279 assert_eq!(allowed_target_types, vec!["decision".to_string()]);
1280 }
1281 other => panic!("expected InvalidRelationshipShape, got {other:?}"),
1282 }
1283 }
1284
1285 #[test]
1286 fn rel_shape_rejects_violating_target() {
1287 let schema = shape_test_schema();
1288 let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
1290 assert!(matches!(
1291 err,
1292 ValidationError::InvalidRelationshipShape { .. }
1293 ));
1294 }
1295
1296 #[test]
1297 fn rel_shape_admits_shape_free_relationship() {
1298 let schema = shape_test_schema();
1299 assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
1301 }
1302
1303 #[test]
1304 fn rel_shape_skips_target_check_when_target_type_unknown() {
1305 let schema = shape_test_schema();
1306 assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
1309 }
1310
1311 #[test]
1312 fn rel_shape_no_op_for_unknown_rel_name() {
1313 let schema = shape_test_schema();
1314 assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
1317 }
1318
1319 fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
1332 let manifest_yaml = r#"name: source-cv
1333version: 0.1.0
1334description: cross-mem source schema
1335when_to_use: tests
1336types:
1337 - step
1338 - decision
1339relationships:
1340 mode: strict
1341 definitions:
1342 - name: IMPLEMENTS
1343 description: intra-mem only
1344 default_weight: 1.0
1345 - name: _default
1346 description: fallback
1347 default_weight: 1.0
1348cross_mem_relationships:
1349 - to_schema: other
1350 definitions:
1351 - name: ADDRESSES
1352 description: outbound shape-pinned
1353 default_weight: 1.0
1354 source_types: [step]
1355 target_types: [requirement]
1356 - name: MENTIONS
1357 description: outbound shape-free
1358 default_weight: 0.5
1359community:
1360 resolution: 1.0
1361 seed: 42
1362"#;
1363 let body_section = r#"sections:
1364 - key: body
1365 heading: Body
1366 required: true
1367 search_weight: 10.0
1368 catch_all: true
1369 write_rules: []
1370metadata_fields: []
1371title_weight: 100.0
1372text_fields:
1373 - body
1374hierarchy_relationship: _default
1375propagating_relationships: []
1376updatable_fields:
1377 - title
1378 - body
1379health_required_fields:
1380 - body
1381staleness_threshold_days: 90
1382write_rules: []
1383"#;
1384 let make_type =
1385 |name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
1386 std::sync::Arc::new(
1387 memstead_schema::load_schema_from_memory(
1388 manifest_yaml,
1389 &[
1390 ("step".to_string(), make_type("step")),
1391 ("decision".to_string(), make_type("decision")),
1392 ],
1393 )
1394 .expect("cross-mem source schema must load"),
1395 )
1396 }
1397
1398 fn other_target_ref() -> memstead_schema::SchemaRef {
1399 memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
1400 }
1401
1402 #[test]
1403 fn cross_mem_admits_declared_shape() {
1404 let src = cross_mem_source_schema();
1405 let target = other_target_ref();
1406 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1407 CrossMemRelCheck::Ok => {}
1408 other => panic!("expected Ok, got {other:?}"),
1409 }
1410 }
1411
1412 #[test]
1413 fn cross_mem_no_matching_entry_returns_edge_not_declared() {
1414 let src = cross_mem_source_schema();
1415 let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
1419 match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
1420 CrossMemRelCheck::EdgeNotDeclared => {}
1421 other => panic!("expected EdgeNotDeclared, got {other:?}"),
1422 }
1423 }
1424
1425 #[test]
1426 fn cross_mem_entry_matches_any_target_version() {
1427 let src = cross_mem_source_schema();
1431 for version in [
1432 semver::Version::new(1, 0, 0),
1433 semver::Version::new(1, 1, 0),
1434 semver::Version::new(2, 5, 0),
1435 ] {
1436 let target = memstead_schema::SchemaRef::new("other", version.clone());
1437 match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
1438 CrossMemRelCheck::Ok => {}
1439 other => panic!("expected Ok against other@{version}, got {other:?}"),
1440 }
1441 }
1442 }
1443
1444 #[test]
1445 fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
1446 let src = cross_mem_source_schema();
1447 let target = other_target_ref();
1448 match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
1451 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
1452 input,
1453 allowed,
1454 ..
1455 }) => {
1456 assert_eq!(input, "IMPLEMENTS");
1457 let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
1459 assert!(names.iter().any(|n| n == "ADDRESSES"));
1460 assert!(names.iter().any(|n| n == "MENTIONS"));
1461 assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
1463 }
1464 other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
1465 }
1466 }
1467
1468 #[test]
1469 fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
1470 let src = cross_mem_source_schema();
1471 let target = other_target_ref();
1472 match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
1477 CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
1478 rel_type,
1479 from_type,
1480 allowed_source_types,
1481 allowed_target_types,
1482 ..
1483 }) => {
1484 assert_eq!(rel_type, "ADDRESSES");
1485 assert_eq!(from_type, "decision");
1486 assert_eq!(allowed_source_types, vec!["step".to_string()]);
1487 assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
1488 }
1489 other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
1490 }
1491 }
1492
1493 #[test]
1494 fn cross_mem_shape_free_rel_type_admits_any_pair() {
1495 let src = cross_mem_source_schema();
1496 let target = other_target_ref();
1497 assert!(matches!(
1499 validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
1500 CrossMemRelCheck::Ok
1501 ));
1502 }
1503
1504 #[test]
1509 fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
1510 let err = ValidationError::UnknownSection {
1511 key: "implimentation".to_string(),
1512 entity_type: "spec".to_string(),
1513 declared: (0..8).map(|i| format!("sec{i}")).collect(),
1514 suggestion: Some("sec0".to_string()),
1515 };
1516 let prose = err.prose_render();
1517 for d in (0..8).map(|i| format!("sec{i}")) {
1518 assert!(prose.contains(&d), "missing {d} in: {prose}");
1519 }
1520 assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
1521 assert!(!prose.contains("see details"), "got: {prose}");
1522 }
1523
1524 #[test]
1525 fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
1526 let err = ValidationError::InvalidEnumValue {
1527 field: "level".to_string(),
1528 value: "M7".to_string(),
1529 allowed: (0..7).map(|i| format!("M{i}")).collect(),
1530 field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
1531 suggestion: Some("M6".to_string()),
1532 type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
1533 entity_type: "spec".to_string(),
1534 };
1535 let prose = err.prose_render();
1536 assert!(prose.contains("M0"), "got: {prose}");
1537 assert!(prose.contains("M6"), "got: {prose}");
1538 assert!(
1539 prose.contains("maturity rung"),
1540 "field_description missing: {prose}"
1541 );
1542 assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
1543 assert!(
1544 prose.contains("specs land at M0"),
1545 "type_write_rules missing: {prose}"
1546 );
1547 assert!(!prose.contains("see details"), "got: {prose}");
1548 }
1549
1550 #[test]
1551 fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
1552 let err = ValidationError::InvalidRelationshipShape {
1553 rel_type: "OWNS".to_string(),
1554 from_type: "spec".to_string(),
1555 to_type: "spec".to_string(),
1556 allowed_source_types: vec!["actor".to_string()],
1557 allowed_target_types: vec![],
1558 suggestion: None,
1559 };
1560 let prose = err.prose_render();
1561 assert!(prose.contains("allowed sources: actor"), "got: {prose}");
1565 assert!(prose.contains("allowed targets: any"), "got: {prose}");
1566 assert!(!prose.contains("see details"), "got: {prose}");
1567 }
1568
1569 fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
1581 let manifest_yaml = r#"name: tests-typed-fields
1582version: 0.1.0
1583description: typed-field test schema
1584when_to_use: tests
1585types:
1586 - widget
1587relationships:
1588 mode: strict
1589 definitions:
1590 - name: _default
1591 description: fallback
1592 default_weight: 1.0
1593community:
1594 resolution: 1.0
1595 seed: 42
1596"#;
1597 let type_yaml = r#"name: widget
1598description: t
1599when_to_use: Here
1600sections:
1601 - key: body
1602 heading: Body
1603 required: true
1604 search_weight: 10.0
1605 catch_all: true
1606 write_rules: []
1607metadata_fields:
1608 - key: verified_on
1609 description: ISO YYYY-MM-DD date the widget was verified
1610 field_type: date
1611 optional: true
1612 - key: order
1613 description: numeric ordering within a plan
1614 field_type: number
1615 optional: true
1616 - key: note
1617 description: free-form note
1618 field_type: string
1619 optional: true
1620title_weight: 100.0
1621text_fields:
1622 - body
1623hierarchy_relationship: _default
1624propagating_relationships: []
1625updatable_fields:
1626 - title
1627 - body
1628health_required_fields:
1629 - body
1630staleness_threshold_days: 90
1631write_rules: []
1632"#;
1633 let schema = memstead_schema::load_schema_from_memory(
1634 manifest_yaml,
1635 &[("widget".to_string(), type_yaml.to_string())],
1636 )
1637 .expect("typed-field test schema must load");
1638 schema.get_type("widget").expect("widget type present")
1639 }
1640
1641 #[test]
1642 fn date_field_rejects_non_date_value() {
1643 let ty = typed_field_type();
1644 let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
1645 assert_eq!(err.code(), "INVALID_FIELD_VALUE");
1646 match err {
1647 ValidationError::InvalidFieldValue {
1648 field,
1649 value,
1650 expected_type,
1651 entity_type,
1652 ..
1653 } => {
1654 assert_eq!(field, "verified_on");
1655 assert_eq!(value, "not-a-real-date");
1656 assert_eq!(expected_type, "Date");
1657 assert_eq!(entity_type, "widget");
1658 }
1659 other => panic!("expected InvalidFieldValue, got {other:?}"),
1660 }
1661 }
1662
1663 #[test]
1664 fn date_field_rejects_empty_string() {
1665 let ty = typed_field_type();
1666 let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
1667 assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
1668 }
1669
1670 #[test]
1671 fn date_field_accepts_iso_date_and_datetime() {
1672 let ty = typed_field_type();
1673 match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
1674 MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
1675 other => panic!("expected String, got {other:?}"),
1676 }
1677 assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
1679 }
1680
1681 #[test]
1682 fn number_field_rejects_non_numeric_value() {
1683 let ty = typed_field_type();
1684 let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
1685 match err {
1686 ValidationError::InvalidFieldValue {
1687 field,
1688 expected_type,
1689 ..
1690 } => {
1691 assert_eq!(field, "order");
1692 assert_eq!(expected_type, "Number");
1693 }
1694 other => panic!("expected InvalidFieldValue, got {other:?}"),
1695 }
1696 }
1697
1698 #[test]
1699 fn number_field_accepts_integer_and_float() {
1700 let ty = typed_field_type();
1701 assert!(matches!(
1702 parse_metadata_value("order", "3", &ty).unwrap(),
1703 MetadataValue::Integer(3)
1704 ));
1705 assert!(matches!(
1706 parse_metadata_value("order", "2.5", &ty).unwrap(),
1707 MetadataValue::Float(_)
1708 ));
1709 }
1710
1711 #[test]
1712 fn string_field_accepts_any_value() {
1713 let ty = typed_field_type();
1714 assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
1716 }
1717
1718 #[test]
1719 fn invalid_field_value_prose_inlines_format_and_purpose() {
1720 let err = ValidationError::InvalidFieldValue {
1721 field: "verified_on".to_string(),
1722 value: "not-a-real-date".to_string(),
1723 expected_type: "Date".to_string(),
1724 expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
1725 field_description: Some("date the widget was verified".to_string()),
1726 entity_type: "widget".to_string(),
1727 };
1728 let prose = err.prose_render();
1729 assert!(prose.contains("not-a-real-date"), "got: {prose}");
1730 assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
1731 assert!(
1732 prose.contains("date the widget was verified"),
1733 "purpose missing: {prose}"
1734 );
1735 assert!(!prose.contains("see details"), "got: {prose}");
1736 }
1737
1738 #[test]
1739 fn is_date_shaped_matches_strict_validator_contract() {
1740 assert!(is_date_shaped("2024-06-01"));
1741 assert!(is_date_shaped("2024-06-01T12:30:00Z"));
1742 assert!(!is_date_shaped(""));
1743 assert!(!is_date_shaped("not-a-real-date"));
1744 assert!(!is_date_shaped("2024-6-1"));
1745 assert!(!is_date_shaped("2024-06-01 extra"));
1746 }
1747
1748 #[test]
1749 fn section_content_refuses_nul_byte() {
1750 let err = validate_section_content([("body", "line1\u{0}line2")].into_iter())
1751 .expect_err("NUL in a section body must be refused");
1752 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
1753 match &err {
1754 ValidationError::SectionContentControlByte {
1755 section,
1756 control_char,
1757 codepoint,
1758 byte_offset,
1759 } => {
1760 assert_eq!(section, "body");
1761 assert_eq!(*control_char, '\u{0}');
1762 assert_eq!(*codepoint, 0);
1763 assert_eq!(*byte_offset, 5);
1765 }
1766 other => panic!("expected SectionContentControlByte, got {other:?}"),
1767 }
1768 let details = err.details();
1770 assert_eq!(details["codepoint"], 0);
1771 assert_eq!(details["byte_offset"], 5);
1772 assert_eq!(details["section"], "body");
1773 }
1774
1775 #[test]
1776 fn section_content_refuses_other_c0_controls_and_cr() {
1777 for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
1780 let body = format!("ok{bad}more");
1781 let err = validate_section_content([("s", body.as_str())].into_iter())
1782 .expect_err("control char must be refused");
1783 assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
1784 }
1785 }
1786
1787 #[test]
1788 fn section_content_allows_tab_and_newline() {
1789 validate_section_content([("body", "line1\nline2\n\tindented\tcols\n")].into_iter())
1792 .expect("tab and newline must stay legal in section bodies");
1793 }
1794
1795 #[test]
1796 fn section_content_keeps_backslashes_verbatim() {
1797 validate_section_content(
1801 [("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
1802 )
1803 .expect("backslashes are literal content, not control bytes");
1804 }
1805
1806 #[test]
1807 fn section_content_still_refuses_heading_injection() {
1808 let err = validate_section_content([("body", "intro\n## Injected\ntail")].into_iter())
1811 .expect_err("embedded `## ` heading must still be refused");
1812 assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
1813 assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
1814 }
1815}