1use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use indexmap::IndexMap;
15use thiserror::Error;
16
17use crate::base_metadata;
18use crate::manifest::SchemaManifest;
19use crate::schema::Schema;
20use crate::types::TypeDefinition;
21
22#[derive(Debug, Error)]
23pub enum SchemaLoadError {
24 #[error("i/o error reading {}: {source}", .path.display())]
25 Io {
26 path: PathBuf,
27 #[source]
28 source: std::io::Error,
29 },
30
31 #[error("failed to parse manifest {}: {source}", .path.display())]
32 ParseManifest {
33 path: PathBuf,
34 #[source]
35 source: serde_yaml_ng::Error,
36 },
37
38 #[error("failed to parse type file {}: {source}", .path.display())]
39 ParseType {
40 path: PathBuf,
41 #[source]
42 source: serde_yaml_ng::Error,
43 },
44
45 #[error("invalid version '{value}': must be semver (e.g. 1.0.0)")]
46 InvalidVersion { value: String },
47
48 #[error("invalid schema name '{value}': {reason}")]
49 InvalidName { value: String, reason: &'static str },
50
51 #[error(
52 "schema type file mismatch — declared in manifest: [{}], found in types/: [{}]",
53 declared.join(", "),
54 found.join(", ")
55 )]
56 TypeFileMismatch {
57 declared: Vec<String>,
58 found: Vec<String>,
59 },
60
61 #[error(
62 "type file '{file}.yaml' has `name: {declared}` — filename and `name` field must match"
63 )]
64 TypeNameMismatch { file: String, declared: String },
65 #[error(
75 "type '{type_name}': `propagating_relationships` was renamed — its only effect is \
76 refusing self-loops on the listed rel-types, so the key is now \
77 `no_self_loop_relationships` (optional; empty lists can simply be deleted). \
78 Rename the key and retry."
79 )]
80 PropagatingRelationshipsRenamed { type_name: String },
81
82 #[error(
83 "type '{type_name}' declares the retired `examples:` list — it was never \
84 validated nor served and is replaced by the engine-validated `exemplar:` \
85 (one canonical entity: title, metadata, sections, relations with \
86 placeholder targets). Move the material into `exemplar:` and retry."
87 )]
88 ExamplesRetired { type_name: String },
89
90 #[error(
97 "type '{type_name}' metadata field '{field}' declares the retired `optional:` key — \
98 fields are optional unless they declare `required: true`. Fix: delete `optional: true`; \
99 replace `optional: false` with `required: true`. Then retry."
100 )]
101 OptionalRetired { type_name: String, field: String },
102
103 #[error("type '{type_name}' due axis is invalid: {reason} — offending name: '{offender}'")]
109 InvalidDueAxis {
110 type_name: String,
111 offender: String,
112 reason: String,
113 },
114
115 #[error("schema relationship vocabulary must include a '_default' definition")]
116 MissingDefaultWeight,
117
118 #[error("duplicate relationship definition: '{name}'")]
119 DuplicateRelationship { name: String },
120
121 #[error(
122 "type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
123 available.join(", "),
124 format_suggestion(relationship, available)
125 )]
126 UndeclaredRelationship {
127 type_name: String,
128 field: &'static str,
129 relationship: String,
130 available: Vec<String>,
131 },
132
133 #[error(
134 "type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
135 )]
136 CatchAllViolation { type_name: String, count: usize },
137
138 #[error(
139 "type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
140 )]
141 UnknownFieldReference {
142 type_name: String,
143 field: &'static str,
144 reference: String,
145 },
146
147 #[error(
148 "type '{type_name}' constraint ({kind}) is invalid: {reason} — offending name: '{offender}'"
149 )]
150 InvalidConstraint {
151 type_name: String,
152 kind: &'static str,
153 offender: String,
154 reason: String,
155 },
156
157 #[error(
158 "type '{type_name}' section '{section}' format declaration is invalid: {}",
159 problems.join("; ")
160 )]
161 InvalidSectionFormat {
162 type_name: String,
163 section: String,
164 problems: Vec<String>,
168 },
169
170 #[error(
171 "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
172 allowed.join(", ")
173 )]
174 DefaultValueNotInEnum {
175 type_name: String,
176 field: String,
177 default: String,
178 allowed: Vec<String>,
179 },
180
181 #[error(
182 "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
183 )]
184 RedeclaredBaseField { type_name: String, field: String },
185
186 #[error(
187 "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
188 declared.join(", "),
189 format_suggestion(reference, declared)
190 )]
191 UndeclaredRelationshipType {
192 relationship: String,
193 field: &'static str,
194 reference: String,
195 declared: Vec<String>,
196 },
197
198 #[error(
208 "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
209 reserved_keys.join(", ")
210 )]
211 ReservedSchemaKey {
212 type_name: String,
213 kind: &'static str,
214 offending_key: String,
215 reserved_keys: Vec<String>,
216 },
217
218 #[error(
224 "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
225 )]
226 InvalidCrossMemToSchema { value: String, reason: String },
227
228 #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
233 DuplicateCrossMemToSchema { to_schema: String },
234
235 #[error(
240 "cross_mem_relationships declares to_schema '*' but the schema declares no \
241 alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
242 declare alias_target_rel_type, or name each destination schema explicitly"
243 )]
244 CrossMemWildcardWithoutAliasTarget,
245
246 #[error(
252 "cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
253 wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
254 hand-authored structural edges need a per-destination-schema declaration"
255 )]
256 CrossMemWildcardNonAliasRelType {
257 rel_type: String,
258 alias_target: String,
259 },
260
261 #[error(
268 "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
269 declared.join(", "),
270 format_suggestion(reference, declared)
271 )]
272 UndeclaredCrossMemSourceType {
273 to_schema: String,
274 relationship: String,
275 reference: String,
276 declared: Vec<String>,
277 },
278
279 #[error(
284 "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
285 declared.join(", "),
286 format_suggestion(target, declared)
287 )]
288 AliasTargetRelTypeNotDeclared {
289 schema: String,
290 target: String,
291 declared: Vec<String>,
292 },
293
294 #[error(
303 "schema declares section heading(s) that cannot round-trip to their key(s): {}. \
304 Fix: make each heading derive to its key — lowercasing the heading and replacing \
305 spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
306 `Current State`)",
307 format_heading_violations(violations)
308 )]
309 SectionHeadingMismatch {
310 violations: Vec<HeadingKeyViolation>,
311 },
312
313 #[error(
323 "schema has {} violations:\n{}",
324 errors.len(),
325 format_multiple(errors)
326 )]
327 Multiple { errors: Vec<SchemaLoadError> },
328}
329
330fn format_multiple(errors: &[SchemaLoadError]) -> String {
331 errors
332 .iter()
333 .enumerate()
334 .map(|(i, e)| format!(" {}. {e}", i + 1))
335 .collect::<Vec<_>>()
336 .join("\n")
337}
338
339fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
344 debug_assert!(!errors.is_empty());
345 if errors.len() == 1 {
346 errors.remove(0)
347 } else {
348 SchemaLoadError::Multiple { errors }
349 }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct HeadingKeyViolation {
356 pub type_name: String,
357 pub key: String,
358 pub heading: String,
359 pub derived_key: String,
360}
361
362fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
363 violations
364 .iter()
365 .map(|v| {
366 format!(
367 "type '{}' section key '{}' has heading '{}' (derives to '{}')",
368 v.type_name, v.key, v.heading, v.derived_key
369 )
370 })
371 .collect::<Vec<_>>()
372 .join("; ")
373}
374
375pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
388 let mut violations = Vec::new();
389 let mut type_names: Vec<&String> = schema.types.keys().collect();
392 type_names.sort();
393 for type_name in type_names {
394 let t = &schema.types[type_name];
395 for s in &t.sections {
396 let derived_key = crate::types::derive_section_key(&s.heading);
397 if derived_key != s.key {
398 violations.push(HeadingKeyViolation {
399 type_name: type_name.clone(),
400 key: s.key.clone(),
401 heading: s.heading.clone(),
402 derived_key,
403 });
404 }
405 }
406 }
407 if violations.is_empty() {
408 Ok(())
409 } else {
410 Err(SchemaLoadError::SectionHeadingMismatch { violations })
411 }
412}
413
414pub fn reserved_section_keys() -> &'static [&'static str] {
418 &["relationships"]
419}
420
421pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
429 &["type", "mem", "id"]
430}
431
432pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
445 for td in schema.types.values() {
446 for key in &td.declared_metadata_keys {
447 if reserved_metadata_field_keys().contains(&key.as_str()) {
448 return Err(SchemaLoadError::ReservedSchemaKey {
449 type_name: td.name.clone(),
450 kind: "metadata_field",
451 offending_key: key.clone(),
452 reserved_keys: reserved_metadata_field_keys()
453 .iter()
454 .map(|s| s.to_string())
455 .collect(),
456 });
457 }
458 }
459 }
460 Ok(())
461}
462
463fn format_suggestion(needle: &str, candidates: &[String]) -> String {
464 let mut best: Option<(usize, &String)> = None;
465 for cand in candidates {
466 let d = strsim::levenshtein(needle, cand);
467 match best {
468 Some((bd, _)) if bd <= d => {}
469 _ => best = Some((d, cand)),
470 }
471 }
472 match best {
473 Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
474 format!("Did you mean '{cand}'?")
475 }
476 _ => String::new(),
477 }
478}
479
480pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
482 let manifest_path = path.join("schema.yaml");
483 let manifest_text =
484 std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
485 path: manifest_path.clone(),
486 source: e,
487 })?;
488
489 let types_dir = path.join("types");
490 let mut type_files: Vec<(String, String)> = Vec::new();
491 if types_dir.is_dir() {
492 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
493 path: types_dir.clone(),
494 source: e,
495 })?;
496 for entry in entries {
497 let entry = entry.map_err(|e| SchemaLoadError::Io {
498 path: types_dir.clone(),
499 source: e,
500 })?;
501 let p = entry.path();
502 if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
503 continue;
504 }
505 let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
506 continue;
507 };
508 let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
509 path: p.clone(),
510 source: e,
511 })?;
512 type_files.push((stem, contents));
513 }
514 }
515 type_files.sort_by(|a, b| a.0.cmp(&b.0));
518
519 load_with_context(
520 &manifest_text,
521 &type_files,
522 Some(&manifest_path),
523 Some(&types_dir),
524 MetadataPolarityFormat::RequiredOptIn,
526 )
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum MetadataPolarityFormat {
543 Legacy,
545 RequiredOptIn,
547}
548
549pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
567
568pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
570
571pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
575 if !files
576 .iter()
577 .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
578 {
579 files.push((
580 SCHEMA_FORMAT_MARKER_FILE.to_string(),
581 SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
582 ));
583 }
584 files
585}
586
587pub fn load_schema_from_memory(
595 manifest_yaml: &str,
596 types_yamls: &[(String, String)],
597) -> Result<Schema, SchemaLoadError> {
598 load_with_context(
599 manifest_yaml,
600 types_yamls,
601 None,
602 None,
603 MetadataPolarityFormat::Legacy,
604 )
605}
606
607pub fn load_schema_from_memory_with_format(
611 manifest_yaml: &str,
612 types_yamls: &[(String, String)],
613 format: MetadataPolarityFormat,
614) -> Result<Schema, SchemaLoadError> {
615 load_with_context(manifest_yaml, types_yamls, None, None, format)
616}
617
618fn load_with_context(
619 manifest_yaml: &str,
620 types_yamls: &[(String, String)],
621 manifest_path: Option<&Path>,
622 types_dir: Option<&Path>,
623 format: MetadataPolarityFormat,
624) -> Result<Schema, SchemaLoadError> {
625 let mut errors: Vec<SchemaLoadError> = Vec::new();
634
635 let mut manifest: SchemaManifest =
636 serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
637 path: manifest_path
638 .map(Path::to_path_buf)
639 .unwrap_or_else(|| PathBuf::from("<memory>")),
640 source: e,
641 })?;
642
643 if let Err(e) = validate_name(&manifest.name) {
644 errors.push(e);
645 }
646
647 let version = match semver::Version::parse(&manifest.version) {
651 Ok(v) => Some(v),
652 Err(_) => {
653 errors.push(SchemaLoadError::InvalidVersion {
654 value: manifest.version.clone(),
655 });
656 None
657 }
658 };
659
660 let mut rel_names: HashSet<String> = HashSet::new();
662 for def in &manifest.relationships.definitions {
663 if !rel_names.insert(def.name.clone()) {
664 errors.push(SchemaLoadError::DuplicateRelationship {
665 name: def.name.clone(),
666 });
667 }
668 }
669 if !rel_names.contains("_default") {
670 errors.push(SchemaLoadError::MissingDefaultWeight);
671 }
672 let available_rels: Vec<String> = manifest
673 .relationships
674 .definitions
675 .iter()
676 .map(|d| d.name.clone())
677 .collect();
678
679 if let Some(target) = &manifest.alias_target_rel_type
684 && !rel_names.contains(target)
685 {
686 let mut declared = available_rels.clone();
687 declared.sort();
688 errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
689 schema: manifest.name.clone(),
690 target: target.clone(),
691 declared,
692 });
693 }
694
695 if let Some(pointer) = manifest.alias_target_rel_type.clone() {
714 for def in &mut manifest.relationships.definitions {
715 if def.name == pointer {
716 def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
717 }
718 }
719 }
720
721 for def in &manifest.relationships.definitions {
726 for t in &def.source_types {
727 if !manifest.types.iter().any(|d| d == t) {
728 errors.push(SchemaLoadError::UndeclaredRelationshipType {
729 relationship: def.name.clone(),
730 field: "source_types",
731 reference: t.clone(),
732 declared: manifest.types.clone(),
733 });
734 }
735 }
736 for t in &def.target_types {
737 if !manifest.types.iter().any(|d| d == t) {
738 errors.push(SchemaLoadError::UndeclaredRelationshipType {
739 relationship: def.name.clone(),
740 field: "target_types",
741 reference: t.clone(),
742 declared: manifest.types.clone(),
743 });
744 }
745 }
746 }
747
748 let mut seen_to_schemas: HashSet<String> = HashSet::new();
759 for entry in &manifest.cross_mem_relationships {
760 if entry.to_schema == "*" {
761 match manifest.alias_target_rel_type.as_deref() {
768 None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
769 Some(alias) => {
770 for def in &entry.definitions {
771 if def.name != alias {
772 errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
773 rel_type: def.name.clone(),
774 alias_target: alias.to_string(),
775 });
776 }
777 }
778 }
779 }
780 } else if entry.to_schema.contains('@') {
781 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
782 value: entry.to_schema.clone(),
783 reason: "must not carry a version or range".into(),
784 });
785 } else if let Err(reason) = name_shape(&entry.to_schema) {
786 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
787 value: entry.to_schema.clone(),
788 reason: reason.into(),
789 });
790 }
791 if !seen_to_schemas.insert(entry.to_schema.clone()) {
792 errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
793 to_schema: entry.to_schema.clone(),
794 });
795 }
796 for def in &entry.definitions {
797 for t in &def.source_types {
798 if !manifest.types.iter().any(|d| d == t) {
799 errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
800 to_schema: entry.to_schema.clone(),
801 relationship: def.name.clone(),
802 reference: t.clone(),
803 declared: manifest.types.clone(),
804 });
805 }
806 }
807 }
808 }
809
810 let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
812 found_stems.sort();
813 let mut declared = manifest.types.clone();
814 declared.sort();
815 if found_stems != declared {
816 errors.push(SchemaLoadError::TypeFileMismatch {
820 declared,
821 found: found_stems,
822 });
823 return Err(collapse(errors));
824 }
825
826 let defaults: IndexMap<String, f32> = manifest
828 .relationships
829 .definitions
830 .iter()
831 .map(|d| (d.name.clone(), d.default_weight))
832 .collect();
833
834 let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
835 let mut had_type_parse_failure = false;
836
837 for (stem, text) in types_yamls {
838 let type_path = types_dir
839 .map(|d| d.join(format!("{stem}.yaml")))
840 .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
841
842 let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
843 Ok(td) => td,
844 Err(e) => {
845 errors.push(SchemaLoadError::ParseType {
850 path: type_path.clone(),
851 source: e,
852 });
853 had_type_parse_failure = true;
854 continue;
855 }
856 };
857
858 if td.name != *stem {
859 errors.push(SchemaLoadError::TypeNameMismatch {
860 file: stem.clone(),
861 declared: td.name.clone(),
862 });
863 }
864
865 if let Some(legacy) = td.legacy_propagating_relationships.take() {
873 if types_dir.is_some() {
874 errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
875 type_name: td.name.clone(),
876 });
877 } else if td.no_self_loop_relationships.is_empty() {
878 td.no_self_loop_relationships = legacy;
879 }
880 }
881
882 if td.legacy_examples.take().is_some() && types_dir.is_some() {
888 errors.push(SchemaLoadError::ExamplesRetired {
889 type_name: td.name.clone(),
890 });
891 }
892
893 for field in &mut td.metadata_fields {
900 if matches!(format, MetadataPolarityFormat::RequiredOptIn)
904 && field.legacy_optional.is_some()
905 {
906 errors.push(SchemaLoadError::OptionalRetired {
907 type_name: td.name.clone(),
908 field: field.key.clone(),
909 });
910 }
911 field.required_resolved = match (field.required, field.legacy_optional.take()) {
912 (Some(required), _) => required,
913 (None, Some(optional)) => !optional,
914 (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
915 };
916 }
917
918 td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
928
929 for field in &td.metadata_fields {
934 if base_metadata::is_base_key(&field.key)
935 && !reserved_metadata_field_keys().contains(&field.key.as_str())
936 {
937 errors.push(SchemaLoadError::RedeclaredBaseField {
938 type_name: td.name.clone(),
939 field: field.key.clone(),
940 });
941 }
942 }
943
944 let mut merged = base_metadata::prefix_fields();
947 merged.append(&mut td.metadata_fields);
948 merged.extend(base_metadata::suffix_fields());
949 td.metadata_fields = merged;
950
951 compile_section_formats(&mut td);
952 validate_type(&td, &rel_names, &available_rels, &mut errors);
953
954 let mut weights = defaults.clone();
956 for (k, v) in &td.edge_weight_overrides {
957 weights.insert(k.clone(), *v);
958 }
959 td.edge_weights = weights;
960
961 types_map.insert(stem.clone(), Arc::new(td));
962 }
963
964 if !had_type_parse_failure {
972 let all_section_keys: HashSet<&str> = types_map
973 .values()
974 .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
975 .collect();
976 let mut type_names: Vec<&String> = types_map.keys().collect();
979 type_names.sort();
980 for type_name in type_names {
981 let td = &types_map[type_name];
982 for c in &td.constraints {
983 if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
984 && !all_section_keys.contains(section.as_str())
985 {
986 errors.push(SchemaLoadError::InvalidConstraint {
987 type_name: td.name.clone(),
988 kind: "enum_from_neighbour",
989 offender: section.clone(),
990 reason: "`section` names a section key no type of this schema declares"
991 .to_string(),
992 });
993 }
994 }
995 }
996 }
997
998 if !errors.is_empty() {
999 return Err(collapse(errors));
1000 }
1001
1002 Ok(Schema {
1003 manifest,
1004 version: version.expect("version parse failure would have accumulated an error"),
1005 types: types_map,
1006 })
1007}
1008
1009fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1010 name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1011 value: name.into(),
1012 reason,
1013 })
1014}
1015
1016pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1022 name_shape(name)
1023}
1024
1025fn name_shape(name: &str) -> Result<(), &'static str> {
1030 if name.is_empty() {
1031 return Err("must not be empty");
1032 }
1033 let mut chars = name.chars();
1034 let first = chars.next().unwrap();
1035 if !first.is_ascii_lowercase() {
1036 return Err("must start with a lowercase letter");
1037 }
1038 for c in chars {
1039 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1040 return Err("must contain only lowercase letters, digits, and hyphens");
1041 }
1042 }
1043 Ok(())
1044}
1045
1046fn compile_section_formats(td: &mut TypeDefinition) {
1054 use crate::content_expr::ContentExpr;
1055 for section in &mut td.sections {
1056 let declares_any = section.content.is_some()
1062 || section.item_pattern.is_some()
1063 || section.table.is_some()
1064 || section.example.is_some()
1065 || section.format_severity != crate::types::ConstraintSeverity::Block;
1066 if !declares_any {
1067 continue;
1068 }
1069 let mut problems: Vec<String> = Vec::new();
1070
1071 let compiled = match §ion.content {
1072 None => {
1073 problems.push(
1074 "`item_pattern` / `table` / `example` require a `content` declaration"
1075 .to_string(),
1076 );
1077 None
1078 }
1079 Some(expr_src) => match ContentExpr::parse(expr_src) {
1080 Ok(expr) => Some(expr),
1081 Err(e) => {
1082 problems.push(format!("`content` is invalid: {e}"));
1083 None
1084 }
1085 },
1086 };
1087
1088 if let Some(pattern) = §ion.item_pattern {
1089 if let Err(e) = regex::Regex::new(pattern) {
1090 problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1091 }
1092 if let Some(expr) = &compiled {
1093 let names = expr.mentioned_names();
1094 let has_list = names.contains(&"list");
1095 let has_paragraph = names.contains(&"paragraph");
1096 if has_list == has_paragraph {
1097 problems.push(
1098 "`item_pattern` requires a `content` expression containing exactly one of `list` / `paragraph` (tables use `column_patterns`)"
1099 .to_string(),
1100 );
1101 }
1102 }
1103 }
1104
1105 if let Some(table) = §ion.table {
1106 if let Some(expr) = &compiled
1107 && !expr.mentioned_names().contains(&"table")
1108 {
1109 problems.push(
1110 "`table` block is only legal when `content` contains `table`".to_string(),
1111 );
1112 }
1113 if table.columns.is_empty() {
1114 problems.push("`table.columns` must name at least one column".to_string());
1115 }
1116 for (column, pattern) in &table.column_patterns {
1117 if !table.columns.contains(column) {
1118 problems.push(format!(
1119 "`column_patterns` names '{column}', which is not in `columns`"
1120 ));
1121 }
1122 if let Err(e) = regex::Regex::new(pattern) {
1123 problems.push(format!(
1124 "`column_patterns.{column}` is not a valid regex: {e}"
1125 ));
1126 }
1127 }
1128 }
1129
1130 if problems.is_empty() {
1131 section.compiled_content = compiled;
1132 } else {
1133 section.format_problems = problems;
1134 }
1135 }
1136}
1137
1138pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1146 let mut first: Option<(String, String)> = None;
1151 let mut problems: Vec<String> = Vec::new();
1152 for td in schema.types.values() {
1153 for section in &td.sections {
1154 if section.format_problems.is_empty() {
1155 continue;
1156 }
1157 if first.is_none() {
1158 first = Some((td.name.clone(), section.key.clone()));
1159 problems.extend(section.format_problems.iter().cloned());
1160 } else {
1161 problems.extend(
1162 section
1163 .format_problems
1164 .iter()
1165 .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1166 );
1167 }
1168 }
1169 }
1170 match first {
1171 Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1172 type_name,
1173 section,
1174 problems,
1175 }),
1176 None => Ok(()),
1177 }
1178}
1179
1180fn validate_type(
1181 td: &TypeDefinition,
1182 rel_names: &HashSet<String>,
1183 available_rels: &[String],
1184 errors: &mut Vec<SchemaLoadError>,
1185) {
1186 for section in &td.sections {
1194 if reserved_section_keys().contains(§ion.key.as_str()) {
1195 errors.push(SchemaLoadError::ReservedSchemaKey {
1196 type_name: td.name.clone(),
1197 kind: "section",
1198 offending_key: section.key.clone(),
1199 reserved_keys: reserved_section_keys()
1200 .iter()
1201 .map(|s| s.to_string())
1202 .collect(),
1203 });
1204 }
1205 }
1206
1207 if let Err(e) = check_rel(
1208 &td.name,
1209 "hierarchy_relationship",
1210 &td.hierarchy_relationship,
1211 rel_names,
1212 available_rels,
1213 ) {
1214 errors.push(e);
1215 }
1216 for r in &td.no_self_loop_relationships {
1217 if let Err(e) = check_rel(
1218 &td.name,
1219 "no_self_loop_relationships",
1220 r,
1221 rel_names,
1222 available_rels,
1223 ) {
1224 errors.push(e);
1225 }
1226 }
1227 for r in td.edge_weight_overrides.keys() {
1228 if let Err(e) = check_rel(
1229 &td.name,
1230 "edge_weight_overrides",
1231 r,
1232 rel_names,
1233 available_rels,
1234 ) {
1235 errors.push(e);
1236 }
1237 }
1238 for block in &td.required_outgoing {
1239 for r in &block.relationships {
1240 if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1241 errors.push(e);
1242 }
1243 }
1244 }
1245
1246 let field_keys: std::collections::HashSet<&str> =
1250 td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1251 let section_keys: std::collections::HashSet<&str> =
1252 td.sections.iter().map(|sec| sec.key.as_str()).collect();
1253 for c in &td.constraints {
1254 match c {
1255 crate::types::ConstraintDef::RequiresWhen {
1256 field,
1257 when_field,
1258 when_value,
1259 ..
1260 } => {
1261 if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1262 errors.push(SchemaLoadError::InvalidConstraint {
1263 type_name: td.name.clone(),
1264 kind: "requires_when",
1265 offender: field.clone(),
1266 reason: "`field` names neither a metadata field nor a section of this type"
1267 .to_string(),
1268 });
1269 }
1270 let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1271 else {
1272 errors.push(SchemaLoadError::InvalidConstraint {
1273 type_name: td.name.clone(),
1274 kind: "requires_when",
1275 offender: when_field.clone(),
1276 reason: "`when_field` names no metadata field of this type".to_string(),
1277 });
1278 continue;
1279 };
1280 if let Some(allowed) = &when_def.enum_values
1281 && !allowed.contains(when_value)
1282 {
1283 errors.push(SchemaLoadError::InvalidConstraint {
1284 type_name: td.name.clone(),
1285 kind: "requires_when",
1286 offender: when_value.clone(),
1287 reason: format!(
1288 "`when_value` is not in `{when_field}`'s enum_values [{}]",
1289 allowed.join(", ")
1290 ),
1291 });
1292 }
1293 }
1294 crate::types::ConstraintDef::Unique { fields, .. } => {
1295 if fields.is_empty() {
1296 errors.push(SchemaLoadError::InvalidConstraint {
1297 type_name: td.name.clone(),
1298 kind: "unique",
1299 offender: "(empty)".to_string(),
1300 reason: "`fields` must name at least one metadata field".to_string(),
1301 });
1302 }
1303 for f in fields {
1304 if !field_keys.contains(f.as_str()) {
1305 errors.push(SchemaLoadError::InvalidConstraint {
1306 type_name: td.name.clone(),
1307 kind: "unique",
1308 offender: f.clone(),
1309 reason: "`fields` entry names no metadata field of this type"
1310 .to_string(),
1311 });
1312 }
1313 }
1314 }
1315 crate::types::ConstraintDef::EnumFromNeighbour {
1316 field, rel_type, ..
1317 } => {
1318 if !field_keys.contains(field.as_str()) {
1319 errors.push(SchemaLoadError::InvalidConstraint {
1320 type_name: td.name.clone(),
1321 kind: "enum_from_neighbour",
1322 offender: field.clone(),
1323 reason: "`field` names no metadata field of this type".to_string(),
1324 });
1325 }
1326 if !rel_names.contains(rel_type) {
1327 errors.push(SchemaLoadError::InvalidConstraint {
1328 type_name: td.name.clone(),
1329 kind: "enum_from_neighbour",
1330 offender: rel_type.clone(),
1331 reason: "`rel_type` is not in the schema's relationship vocabulary"
1332 .to_string(),
1333 });
1334 }
1335 }
1339 crate::types::ConstraintDef::StatusPropagation {
1340 field,
1341 value,
1342 rel_type,
1343 severity,
1344 ..
1345 } => {
1346 match td.metadata_fields.iter().find(|f| f.key == *field) {
1347 None => {
1348 errors.push(SchemaLoadError::InvalidConstraint {
1349 type_name: td.name.clone(),
1350 kind: "status_propagation",
1351 offender: field.clone(),
1352 reason: "`field` names no metadata field of this type".to_string(),
1353 });
1354 }
1355 Some(field_def) => {
1356 if let Some(allowed) = &field_def.enum_values
1357 && !allowed.contains(value)
1358 {
1359 errors.push(SchemaLoadError::InvalidConstraint {
1360 type_name: td.name.clone(),
1361 kind: "status_propagation",
1362 offender: value.clone(),
1363 reason: format!(
1364 "`value` is not in `{field}`'s enum_values [{}]",
1365 allowed.join(", ")
1366 ),
1367 });
1368 }
1369 }
1370 }
1371 if !rel_names.contains(rel_type) {
1372 errors.push(SchemaLoadError::InvalidConstraint {
1373 type_name: td.name.clone(),
1374 kind: "status_propagation",
1375 offender: rel_type.clone(),
1376 reason: "`rel_type` is not in the schema's relationship vocabulary"
1377 .to_string(),
1378 });
1379 }
1380 if *severity == crate::types::ConstraintSeverity::Block {
1381 errors.push(SchemaLoadError::InvalidConstraint {
1387 type_name: td.name.clone(),
1388 kind: "status_propagation",
1389 offender: "block".to_string(),
1390 reason: "status_propagation is always warn-tier — a parent falling after \
1391 the child was written cannot retroactively make the child's \
1392 write illegal"
1393 .to_string(),
1394 });
1395 }
1396 }
1397 }
1398 }
1399
1400 if let Some(due) = &td.due {
1403 match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
1404 None => errors.push(SchemaLoadError::InvalidDueAxis {
1405 type_name: td.name.clone(),
1406 offender: due.date_field.clone(),
1407 reason: "`date_field` names no metadata field of this type".to_string(),
1408 }),
1409 Some(f) if f.field_type != crate::types::FieldType::Date => {
1410 errors.push(SchemaLoadError::InvalidDueAxis {
1411 type_name: td.name.clone(),
1412 offender: due.date_field.clone(),
1413 reason: "`date_field` must name a date-typed metadata field".to_string(),
1414 })
1415 }
1416 Some(_) => {}
1417 }
1418 match td
1419 .metadata_fields
1420 .iter()
1421 .find(|f| f.key == due.status_field)
1422 {
1423 None => errors.push(SchemaLoadError::InvalidDueAxis {
1424 type_name: td.name.clone(),
1425 offender: due.status_field.clone(),
1426 reason: "`status_field` names no metadata field of this type".to_string(),
1427 }),
1428 Some(f) => match &f.enum_values {
1429 None => errors.push(SchemaLoadError::InvalidDueAxis {
1430 type_name: td.name.clone(),
1431 offender: due.status_field.clone(),
1432 reason: "`status_field` must name an enum-typed metadata field \
1433 (declare enum_values)"
1434 .to_string(),
1435 }),
1436 Some(allowed) => {
1437 for v in &due.open_values {
1438 if !allowed.contains(v) {
1439 errors.push(SchemaLoadError::InvalidDueAxis {
1440 type_name: td.name.clone(),
1441 offender: v.clone(),
1442 reason: format!(
1443 "`open_values` entry is not in `{}`'s enum_values [{}]",
1444 due.status_field,
1445 allowed.join(", ")
1446 ),
1447 });
1448 }
1449 }
1450 }
1451 },
1452 }
1453 if due.open_values.is_empty() {
1454 errors.push(SchemaLoadError::InvalidDueAxis {
1455 type_name: td.name.clone(),
1456 offender: "(empty)".to_string(),
1457 reason: "`open_values` must name at least one open status value".to_string(),
1458 });
1459 }
1460 if let Some(lead) = &due.lead_section
1461 && !td.sections.iter().any(|s| s.key == *lead)
1462 {
1463 errors.push(SchemaLoadError::InvalidDueAxis {
1464 type_name: td.name.clone(),
1465 offender: lead.clone(),
1466 reason: "`lead_section` names no section of this type".to_string(),
1467 });
1468 }
1469 }
1470
1471 let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
1473 if catch_all_count != 1 {
1474 errors.push(SchemaLoadError::CatchAllViolation {
1475 type_name: td.name.clone(),
1476 count: catch_all_count,
1477 });
1478 }
1479
1480 let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
1482 let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
1483
1484 for f in &td.text_fields {
1485 if !section_keys.contains(f.as_str()) {
1487 errors.push(SchemaLoadError::UnknownFieldReference {
1488 type_name: td.name.clone(),
1489 field: "text_fields",
1490 reference: f.clone(),
1491 });
1492 }
1493 }
1494 for f in &td.health_required_fields {
1495 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1496 errors.push(SchemaLoadError::UnknownFieldReference {
1497 type_name: td.name.clone(),
1498 field: "health_required_fields",
1499 reference: f.clone(),
1500 });
1501 }
1502 }
1503 for f in &td.updatable_fields {
1504 if f == "title" {
1506 continue;
1507 }
1508 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1509 errors.push(SchemaLoadError::UnknownFieldReference {
1510 type_name: td.name.clone(),
1511 field: "updatable_fields",
1512 reference: f.clone(),
1513 });
1514 }
1515 }
1516
1517 for m in &td.metadata_fields {
1519 if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
1520 && !allowed.contains(default)
1521 {
1522 errors.push(SchemaLoadError::DefaultValueNotInEnum {
1523 type_name: td.name.clone(),
1524 field: m.key.clone(),
1525 default: default.clone(),
1526 allowed: allowed.clone(),
1527 });
1528 }
1529 }
1530}
1531
1532fn check_rel(
1533 type_name: &str,
1534 field: &'static str,
1535 relationship: &str,
1536 rel_names: &HashSet<String>,
1537 available: &[String],
1538) -> Result<(), SchemaLoadError> {
1539 if rel_names.contains(relationship) {
1540 return Ok(());
1541 }
1542 Err(SchemaLoadError::UndeclaredRelationship {
1543 type_name: type_name.into(),
1544 field,
1545 relationship: relationship.into(),
1546 available: available.to_vec(),
1547 })
1548}