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("relationships.acyclic_sets is invalid: {reason} — offending entry: '{offender}'")]
158 InvalidAcyclicSet { offender: String, reason: String },
159
160 #[error("relationships.labelling is invalid: {reason} — offending name: '{offender}'")]
161 InvalidLabelling { offender: String, reason: String },
162
163 #[error(
164 "type '{type_name}' section '{section}' format declaration is invalid: {}",
165 problems.join("; ")
166 )]
167 InvalidSectionFormat {
168 type_name: String,
169 section: String,
170 problems: Vec<String>,
174 },
175
176 #[error(
177 "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
178 allowed.join(", ")
179 )]
180 DefaultValueNotInEnum {
181 type_name: String,
182 field: String,
183 default: String,
184 allowed: Vec<String>,
185 },
186
187 #[error(
188 "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
189 )]
190 RedeclaredBaseField { type_name: String, field: String },
191
192 #[error(
193 "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
194 declared.join(", "),
195 format_suggestion(reference, declared)
196 )]
197 UndeclaredRelationshipType {
198 relationship: String,
199 field: &'static str,
200 reference: String,
201 declared: Vec<String>,
202 },
203
204 #[error(
214 "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
215 reserved_keys.join(", ")
216 )]
217 ReservedSchemaKey {
218 type_name: String,
219 kind: &'static str,
220 offending_key: String,
221 reserved_keys: Vec<String>,
222 },
223
224 #[error(
230 "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
231 )]
232 InvalidCrossMemToSchema { value: String, reason: String },
233
234 #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
239 DuplicateCrossMemToSchema { to_schema: String },
240
241 #[error("sealed schema package carries no schema.yaml")]
245 SealedPackageMissingManifest,
246
247 #[error(
252 "cross_mem_relationships declares to_schema '*' but the schema declares no \
253 alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
254 declare alias_target_rel_type, or name each destination schema explicitly"
255 )]
256 CrossMemWildcardWithoutAliasTarget,
257
258 #[error(
264 "cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
265 wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
266 hand-authored structural edges need a per-destination-schema declaration"
267 )]
268 CrossMemWildcardNonAliasRelType {
269 rel_type: String,
270 alias_target: String,
271 },
272
273 #[error(
280 "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
281 declared.join(", "),
282 format_suggestion(reference, declared)
283 )]
284 UndeclaredCrossMemSourceType {
285 to_schema: String,
286 relationship: String,
287 reference: String,
288 declared: Vec<String>,
289 },
290
291 #[error(
296 "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
297 declared.join(", "),
298 format_suggestion(target, declared)
299 )]
300 AliasTargetRelTypeNotDeclared {
301 schema: String,
302 target: String,
303 declared: Vec<String>,
304 },
305
306 #[error(
315 "schema declares section heading(s) that cannot round-trip to their key(s): {}. \
316 Fix: make each heading derive to its key — lowercasing the heading and replacing \
317 spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
318 `Current State`)",
319 format_heading_violations(violations)
320 )]
321 SectionHeadingMismatch {
322 violations: Vec<HeadingKeyViolation>,
323 },
324
325 #[error(
335 "schema has {} violations:\n{}",
336 errors.len(),
337 format_multiple(errors)
338 )]
339 Multiple { errors: Vec<SchemaLoadError> },
340}
341
342fn format_multiple(errors: &[SchemaLoadError]) -> String {
343 errors
344 .iter()
345 .enumerate()
346 .map(|(i, e)| format!(" {}. {e}", i + 1))
347 .collect::<Vec<_>>()
348 .join("\n")
349}
350
351fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
356 debug_assert!(!errors.is_empty());
357 if errors.len() == 1 {
358 errors.remove(0)
359 } else {
360 SchemaLoadError::Multiple { errors }
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct HeadingKeyViolation {
368 pub type_name: String,
369 pub key: String,
370 pub heading: String,
371 pub derived_key: String,
372}
373
374fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
375 violations
376 .iter()
377 .map(|v| {
378 format!(
379 "type '{}' section key '{}' has heading '{}' (derives to '{}')",
380 v.type_name, v.key, v.heading, v.derived_key
381 )
382 })
383 .collect::<Vec<_>>()
384 .join("; ")
385}
386
387pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
400 let mut violations = Vec::new();
401 let mut type_names: Vec<&String> = schema.types.keys().collect();
404 type_names.sort();
405 for type_name in type_names {
406 let t = &schema.types[type_name];
407 for s in &t.sections {
408 let derived_key = crate::types::derive_section_key(&s.heading);
409 if derived_key != s.key {
410 violations.push(HeadingKeyViolation {
411 type_name: type_name.clone(),
412 key: s.key.clone(),
413 heading: s.heading.clone(),
414 derived_key,
415 });
416 }
417 }
418 }
419 if violations.is_empty() {
420 Ok(())
421 } else {
422 Err(SchemaLoadError::SectionHeadingMismatch { violations })
423 }
424}
425
426pub fn reserved_section_keys() -> &'static [&'static str] {
430 &["relationships"]
431}
432
433pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
441 &["type", "mem", "id"]
442}
443
444pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
457 for td in schema.types.values() {
458 for key in &td.declared_metadata_keys {
459 if reserved_metadata_field_keys().contains(&key.as_str()) {
460 return Err(SchemaLoadError::ReservedSchemaKey {
461 type_name: td.name.clone(),
462 kind: "metadata_field",
463 offending_key: key.clone(),
464 reserved_keys: reserved_metadata_field_keys()
465 .iter()
466 .map(|s| s.to_string())
467 .collect(),
468 });
469 }
470 }
471 }
472 Ok(())
473}
474
475fn format_suggestion(needle: &str, candidates: &[String]) -> String {
476 let mut best: Option<(usize, &String)> = None;
477 for cand in candidates {
478 let d = strsim::levenshtein(needle, cand);
479 match best {
480 Some((bd, _)) if bd <= d => {}
481 _ => best = Some((d, cand)),
482 }
483 }
484 match best {
485 Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
486 format!("Did you mean '{cand}'?")
487 }
488 _ => String::new(),
489 }
490}
491
492pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
494 let manifest_path = path.join("schema.yaml");
495 let manifest_text =
496 std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
497 path: manifest_path.clone(),
498 source: e,
499 })?;
500
501 let types_dir = path.join("types");
502 let mut type_files: Vec<(String, String)> = Vec::new();
503 if types_dir.is_dir() {
504 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
505 path: types_dir.clone(),
506 source: e,
507 })?;
508 for entry in entries {
509 let entry = entry.map_err(|e| SchemaLoadError::Io {
510 path: types_dir.clone(),
511 source: e,
512 })?;
513 let p = entry.path();
514 if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
515 continue;
516 }
517 let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
518 continue;
519 };
520 let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
521 path: p.clone(),
522 source: e,
523 })?;
524 type_files.push((stem, contents));
525 }
526 }
527 type_files.sort_by(|a, b| a.0.cmp(&b.0));
530
531 load_with_context(
532 &manifest_text,
533 &type_files,
534 Some(&manifest_path),
535 Some(&types_dir),
536 MetadataPolarityFormat::RequiredOptIn,
538 )
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub enum MetadataPolarityFormat {
555 Legacy,
557 RequiredOptIn,
559}
560
561pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
579
580pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
582
583pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
596 if !files
597 .iter()
598 .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
599 {
600 files.push((
601 SCHEMA_FORMAT_MARKER_FILE.to_string(),
602 SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
603 ));
604 }
605 files
606}
607
608pub fn load_schema_from_memory(
616 manifest_yaml: &str,
617 types_yamls: &[(String, String)],
618) -> Result<Schema, SchemaLoadError> {
619 load_with_context(
620 manifest_yaml,
621 types_yamls,
622 None,
623 None,
624 MetadataPolarityFormat::Legacy,
625 )
626}
627
628pub fn load_sealed_package(files: &[(String, Vec<u8>)]) -> Result<Schema, SchemaLoadError> {
647 let mut manifest: Option<String> = None;
648 let mut types: Vec<(String, String)> = Vec::new();
649 let mut marked = false;
650 for (rel, bytes) in files {
651 if rel == "schema.yaml" {
652 manifest = Some(String::from_utf8_lossy(bytes).into_owned());
653 } else if rel == SCHEMA_FORMAT_MARKER_FILE {
654 marked = true;
655 } else if let Some(stem) = rel
656 .strip_prefix("types/")
657 .and_then(|f| f.strip_suffix(".yaml"))
658 {
659 types.push((
660 stem.to_string(),
661 String::from_utf8_lossy(bytes).into_owned(),
662 ));
663 }
664 }
665 let manifest = manifest.ok_or(SchemaLoadError::SealedPackageMissingManifest)?;
666 types.sort_by(|a, b| a.0.cmp(&b.0));
669 let format = if marked {
670 MetadataPolarityFormat::RequiredOptIn
671 } else {
672 MetadataPolarityFormat::Legacy
673 };
674 load_with_context(&manifest, &types, None, None, format)
675}
676
677pub fn check_package_reauthorable(
687 manifest_yaml: &str,
688 types_yamls: &[(String, String)],
689) -> Result<(), SchemaLoadError> {
690 let strict_context = Path::new("<sealed package>");
694 load_with_context(
695 manifest_yaml,
696 types_yamls,
697 Some(strict_context),
698 Some(strict_context),
699 MetadataPolarityFormat::RequiredOptIn,
700 )
701 .map(|_| ())
702}
703
704pub fn load_schema_from_memory_with_format(
708 manifest_yaml: &str,
709 types_yamls: &[(String, String)],
710 format: MetadataPolarityFormat,
711) -> Result<Schema, SchemaLoadError> {
712 load_with_context(manifest_yaml, types_yamls, None, None, format)
713}
714
715fn load_with_context(
716 manifest_yaml: &str,
717 types_yamls: &[(String, String)],
718 manifest_path: Option<&Path>,
719 types_dir: Option<&Path>,
720 format: MetadataPolarityFormat,
721) -> Result<Schema, SchemaLoadError> {
722 let mut errors: Vec<SchemaLoadError> = Vec::new();
731
732 let mut manifest: SchemaManifest =
733 serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
734 path: manifest_path
735 .map(Path::to_path_buf)
736 .unwrap_or_else(|| PathBuf::from("<memory>")),
737 source: e,
738 })?;
739
740 if let Err(e) = validate_name(&manifest.name) {
741 errors.push(e);
742 }
743
744 let version = match semver::Version::parse(&manifest.version) {
748 Ok(v) => Some(v),
749 Err(_) => {
750 errors.push(SchemaLoadError::InvalidVersion {
751 value: manifest.version.clone(),
752 });
753 None
754 }
755 };
756
757 let mut rel_names: HashSet<String> = HashSet::new();
759 for def in &manifest.relationships.definitions {
760 if !rel_names.insert(def.name.clone()) {
761 errors.push(SchemaLoadError::DuplicateRelationship {
762 name: def.name.clone(),
763 });
764 }
765 }
766 if !rel_names.contains("_default") {
767 errors.push(SchemaLoadError::MissingDefaultWeight);
768 }
769 let available_rels: Vec<String> = manifest
770 .relationships
771 .definitions
772 .iter()
773 .map(|d| d.name.clone())
774 .collect();
775
776 if let Some(target) = &manifest.alias_target_rel_type
781 && !rel_names.contains(target)
782 {
783 let mut declared = available_rels.clone();
784 declared.sort();
785 errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
786 schema: manifest.name.clone(),
787 target: target.clone(),
788 declared,
789 });
790 }
791
792 let mut acyclic_set_member_seen: HashSet<&str> = HashSet::new();
798 for set in &manifest.relationships.acyclic_sets {
799 if set.len() < 2 {
800 errors.push(SchemaLoadError::InvalidAcyclicSet {
801 offender: set.join(", "),
802 reason: "a set needs at least two rel-types (a single member is the \
803 per-definition `acyclic` flag)"
804 .to_string(),
805 });
806 }
807 for name in set {
808 if !rel_names.contains(name.as_str()) {
809 errors.push(SchemaLoadError::InvalidAcyclicSet {
810 offender: name.clone(),
811 reason: "names no declared relationship".to_string(),
812 });
813 }
814 if !acyclic_set_member_seen.insert(name.as_str()) {
815 errors.push(SchemaLoadError::InvalidAcyclicSet {
816 offender: name.clone(),
817 reason: "a rel-type may appear in at most one acyclicity set".to_string(),
818 });
819 }
820 }
821 }
822
823 if let Some(lab) = &manifest.relationships.labelling {
828 if lab.attack.is_empty() {
829 errors.push(SchemaLoadError::InvalidLabelling {
830 offender: "(empty)".to_string(),
831 reason: "`labelling.attack` must name at least one rel-type".to_string(),
832 });
833 }
834 for name in &lab.attack {
835 if !rel_names.contains(name.as_str()) {
836 errors.push(SchemaLoadError::InvalidLabelling {
837 offender: name.clone(),
838 reason: "`labelling.attack` entry names no declared relationship".to_string(),
839 });
840 }
841 }
842 if let Some(sup) = &lab.support {
843 if sup.relationships.is_empty() {
844 errors.push(SchemaLoadError::InvalidLabelling {
845 offender: "(empty)".to_string(),
846 reason: "`labelling.support.relationships` must name at least one rel-type"
847 .to_string(),
848 });
849 }
850 for name in &sup.relationships {
851 if !rel_names.contains(name.as_str()) {
852 errors.push(SchemaLoadError::InvalidLabelling {
853 offender: name.clone(),
854 reason: "`labelling.support.relationships` entry names no declared \
855 relationship"
856 .to_string(),
857 });
858 }
859 }
860 }
861 }
862
863 if let Some(pointer) = manifest.alias_target_rel_type.clone() {
882 for def in &mut manifest.relationships.definitions {
883 if def.name == pointer {
884 def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
885 }
886 }
887 }
888
889 for def in &manifest.relationships.definitions {
894 for t in &def.source_types {
895 if !manifest.types.iter().any(|d| d == t) {
896 errors.push(SchemaLoadError::UndeclaredRelationshipType {
897 relationship: def.name.clone(),
898 field: "source_types",
899 reference: t.clone(),
900 declared: manifest.types.clone(),
901 });
902 }
903 }
904 for t in &def.target_types {
905 if !manifest.types.iter().any(|d| d == t) {
906 errors.push(SchemaLoadError::UndeclaredRelationshipType {
907 relationship: def.name.clone(),
908 field: "target_types",
909 reference: t.clone(),
910 declared: manifest.types.clone(),
911 });
912 }
913 }
914 }
915
916 let mut seen_to_schemas: HashSet<String> = HashSet::new();
927 for entry in &manifest.cross_mem_relationships {
928 if entry.to_schema == "*" {
929 match manifest.alias_target_rel_type.as_deref() {
936 None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
937 Some(alias) => {
938 for def in &entry.definitions {
939 if def.name != alias {
940 errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
941 rel_type: def.name.clone(),
942 alias_target: alias.to_string(),
943 });
944 }
945 }
946 }
947 }
948 } else if entry.to_schema.contains('@') {
949 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
950 value: entry.to_schema.clone(),
951 reason: "must not carry a version or range".into(),
952 });
953 } else if let Err(reason) = name_shape(&entry.to_schema) {
954 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
955 value: entry.to_schema.clone(),
956 reason: reason.into(),
957 });
958 }
959 if !seen_to_schemas.insert(entry.to_schema.clone()) {
960 errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
961 to_schema: entry.to_schema.clone(),
962 });
963 }
964 for def in &entry.definitions {
965 for t in &def.source_types {
966 if !manifest.types.iter().any(|d| d == t) {
967 errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
968 to_schema: entry.to_schema.clone(),
969 relationship: def.name.clone(),
970 reference: t.clone(),
971 declared: manifest.types.clone(),
972 });
973 }
974 }
975 }
976 }
977
978 let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
980 found_stems.sort();
981 let mut declared = manifest.types.clone();
982 declared.sort();
983 if found_stems != declared {
984 errors.push(SchemaLoadError::TypeFileMismatch {
988 declared,
989 found: found_stems,
990 });
991 return Err(collapse(errors));
992 }
993
994 let defaults: IndexMap<String, f32> = manifest
996 .relationships
997 .definitions
998 .iter()
999 .map(|d| (d.name.clone(), d.default_weight))
1000 .collect();
1001
1002 let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
1003 let mut had_type_parse_failure = false;
1004
1005 for (stem, text) in types_yamls {
1006 let type_path = types_dir
1007 .map(|d| d.join(format!("{stem}.yaml")))
1008 .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
1009
1010 let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
1011 Ok(td) => td,
1012 Err(e) => {
1013 errors.push(SchemaLoadError::ParseType {
1018 path: type_path.clone(),
1019 source: e,
1020 });
1021 had_type_parse_failure = true;
1022 continue;
1023 }
1024 };
1025
1026 if td.name != *stem {
1027 errors.push(SchemaLoadError::TypeNameMismatch {
1028 file: stem.clone(),
1029 declared: td.name.clone(),
1030 });
1031 }
1032
1033 if let Some(legacy) = td.legacy_propagating_relationships.take() {
1041 if types_dir.is_some() {
1042 errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
1043 type_name: td.name.clone(),
1044 });
1045 } else if td.no_self_loop_relationships.is_empty() {
1046 td.no_self_loop_relationships = legacy;
1047 }
1048 }
1049
1050 if td.legacy_examples.take().is_some() && types_dir.is_some() {
1056 errors.push(SchemaLoadError::ExamplesRetired {
1057 type_name: td.name.clone(),
1058 });
1059 }
1060
1061 for field in &mut td.metadata_fields {
1068 if matches!(format, MetadataPolarityFormat::RequiredOptIn)
1072 && field.legacy_optional.is_some()
1073 {
1074 errors.push(SchemaLoadError::OptionalRetired {
1075 type_name: td.name.clone(),
1076 field: field.key.clone(),
1077 });
1078 }
1079 field.required_resolved = match (field.required, field.legacy_optional.take()) {
1080 (Some(required), _) => required,
1081 (None, Some(optional)) => !optional,
1082 (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
1083 };
1084 }
1085
1086 td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
1096
1097 for field in &td.metadata_fields {
1102 if base_metadata::is_base_key(&field.key)
1103 && !reserved_metadata_field_keys().contains(&field.key.as_str())
1104 {
1105 errors.push(SchemaLoadError::RedeclaredBaseField {
1106 type_name: td.name.clone(),
1107 field: field.key.clone(),
1108 });
1109 }
1110 }
1111
1112 let mut merged = base_metadata::prefix_fields();
1115 merged.append(&mut td.metadata_fields);
1116 merged.extend(base_metadata::suffix_fields());
1117 td.metadata_fields = merged;
1118
1119 compile_section_formats(&mut td);
1120 validate_type(&td, &rel_names, &available_rels, &mut errors);
1121
1122 let mut weights = defaults.clone();
1124 for (k, v) in &td.edge_weight_overrides {
1125 weights.insert(k.clone(), *v);
1126 }
1127 td.edge_weights = weights;
1128
1129 types_map.insert(stem.clone(), Arc::new(td));
1130 }
1131
1132 if !had_type_parse_failure {
1140 let all_section_keys: HashSet<&str> = types_map
1141 .values()
1142 .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
1143 .collect();
1144 let mut type_names: Vec<&String> = types_map.keys().collect();
1147 type_names.sort();
1148 for type_name in type_names {
1149 let td = &types_map[type_name];
1150 for c in &td.constraints {
1151 if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
1152 && !all_section_keys.contains(section.as_str())
1153 {
1154 errors.push(SchemaLoadError::InvalidConstraint {
1155 type_name: td.name.clone(),
1156 kind: "enum_from_neighbour",
1157 offender: section.clone(),
1158 reason: "`section` names a section key no type of this schema declares"
1159 .to_string(),
1160 });
1161 }
1162 }
1163 for ob in &td.must_reach {
1166 for t in &ob.terminal_types {
1167 if !types_map.contains_key(t.as_str()) {
1168 errors.push(SchemaLoadError::InvalidConstraint {
1169 type_name: td.name.clone(),
1170 kind: "must_reach",
1171 offender: t.clone(),
1172 reason: "`terminal_types` entry names no type of this schema"
1173 .to_string(),
1174 });
1175 }
1176 }
1177 }
1178 for sig in &td.signals {
1184 if let (Some(field), Some(value)) = (&sig.neighbour_field, &sig.neighbour_value) {
1185 let declaring: Vec<&crate::types::MetadataFieldDef> = types_map
1186 .values()
1187 .flat_map(|t| t.metadata_fields.iter())
1188 .filter(|f| f.key == *field && f.enum_values.is_some())
1189 .collect();
1190 if declaring.is_empty() {
1191 errors.push(SchemaLoadError::InvalidConstraint {
1192 type_name: td.name.clone(),
1193 kind: "signal",
1194 offender: field.clone(),
1195 reason: "`neighbour_field` is declared with `enum_values` on no \
1196 type of this schema"
1197 .to_string(),
1198 });
1199 } else if !declaring.iter().any(|f| {
1200 f.enum_values
1201 .as_ref()
1202 .is_some_and(|allowed| allowed.contains(value))
1203 }) {
1204 errors.push(SchemaLoadError::InvalidConstraint {
1205 type_name: td.name.clone(),
1206 kind: "signal",
1207 offender: value.clone(),
1208 reason: format!(
1209 "`neighbour_value` is outside `{field}`'s enum_values on every \
1210 declaring type"
1211 ),
1212 });
1213 }
1214 }
1215 }
1216 }
1217 }
1218
1219 if !had_type_parse_failure
1224 && let Some(lab) = &manifest.relationships.labelling
1225 && let Some(sup) = &lab.support
1226 {
1227 for t in &sup.terminal_types {
1228 if !types_map.contains_key(t.as_str()) {
1229 errors.push(SchemaLoadError::InvalidLabelling {
1230 offender: t.clone(),
1231 reason: "`labelling.support.terminal_types` entry names no type of this \
1232 schema"
1233 .to_string(),
1234 });
1235 }
1236 }
1237 }
1238
1239 if !errors.is_empty() {
1240 return Err(collapse(errors));
1241 }
1242
1243 Ok(Schema {
1244 manifest,
1245 version: version.expect("version parse failure would have accumulated an error"),
1246 types: types_map,
1247 })
1248}
1249
1250fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1251 name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1252 value: name.into(),
1253 reason,
1254 })
1255}
1256
1257pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1263 name_shape(name)
1264}
1265
1266fn name_shape(name: &str) -> Result<(), &'static str> {
1271 if name.is_empty() {
1272 return Err("must not be empty");
1273 }
1274 let mut chars = name.chars();
1275 let first = chars.next().unwrap();
1276 if !first.is_ascii_lowercase() {
1277 return Err("must start with a lowercase letter");
1278 }
1279 for c in chars {
1280 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1281 return Err("must contain only lowercase letters, digits, and hyphens");
1282 }
1283 }
1284 Ok(())
1285}
1286
1287fn compile_section_formats(td: &mut TypeDefinition) {
1295 use crate::content_expr::ContentExpr;
1296 for section in &mut td.sections {
1297 let declares_any = section.content.is_some()
1303 || section.item_pattern.is_some()
1304 || section.table.is_some()
1305 || section.example.is_some()
1306 || section.format_severity != crate::types::ConstraintSeverity::Block;
1307 if !declares_any {
1308 continue;
1309 }
1310 let mut problems: Vec<String> = Vec::new();
1311
1312 let compiled = match §ion.content {
1313 None => {
1314 problems.push(
1315 "`item_pattern` / `table` / `example` require a `content` declaration"
1316 .to_string(),
1317 );
1318 None
1319 }
1320 Some(expr_src) => match ContentExpr::parse(expr_src) {
1321 Ok(expr) => Some(expr),
1322 Err(e) => {
1323 problems.push(format!("`content` is invalid: {e}"));
1324 None
1325 }
1326 },
1327 };
1328
1329 if let Some(pattern) = §ion.item_pattern {
1330 if let Err(e) = regex::Regex::new(pattern) {
1331 problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1332 }
1333 if let Some(expr) = &compiled {
1334 let names = expr.mentioned_names();
1335 let has_list = names.contains(&"list");
1336 let has_paragraph = names.contains(&"paragraph");
1337 if has_list == has_paragraph {
1338 problems.push(
1339 "`item_pattern` requires a `content` expression containing exactly one of `list` / `paragraph` (tables use `column_patterns`)"
1340 .to_string(),
1341 );
1342 }
1343 }
1344 }
1345
1346 if let Some(table) = §ion.table {
1347 if let Some(expr) = &compiled
1348 && !expr.mentioned_names().contains(&"table")
1349 {
1350 problems.push(
1351 "`table` block is only legal when `content` contains `table`".to_string(),
1352 );
1353 }
1354 if table.columns.is_empty() {
1355 problems.push("`table.columns` must name at least one column".to_string());
1356 }
1357 for (column, pattern) in &table.column_patterns {
1358 if !table.columns.contains(column) {
1359 problems.push(format!(
1360 "`column_patterns` names '{column}', which is not in `columns`"
1361 ));
1362 }
1363 if let Err(e) = regex::Regex::new(pattern) {
1364 problems.push(format!(
1365 "`column_patterns.{column}` is not a valid regex: {e}"
1366 ));
1367 }
1368 }
1369 }
1370
1371 if problems.is_empty() {
1372 section.compiled_content = compiled;
1373 } else {
1374 section.format_problems = problems;
1375 }
1376 }
1377}
1378
1379pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1387 let mut first: Option<(String, String)> = None;
1392 let mut problems: Vec<String> = Vec::new();
1393 for td in schema.types.values() {
1394 for section in &td.sections {
1395 if section.format_problems.is_empty() {
1396 continue;
1397 }
1398 if first.is_none() {
1399 first = Some((td.name.clone(), section.key.clone()));
1400 problems.extend(section.format_problems.iter().cloned());
1401 } else {
1402 problems.extend(
1403 section
1404 .format_problems
1405 .iter()
1406 .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1407 );
1408 }
1409 }
1410 }
1411 match first {
1412 Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1413 type_name,
1414 section,
1415 problems,
1416 }),
1417 None => Ok(()),
1418 }
1419}
1420
1421fn validate_type(
1422 td: &TypeDefinition,
1423 rel_names: &HashSet<String>,
1424 available_rels: &[String],
1425 errors: &mut Vec<SchemaLoadError>,
1426) {
1427 for section in &td.sections {
1435 if reserved_section_keys().contains(§ion.key.as_str()) {
1436 errors.push(SchemaLoadError::ReservedSchemaKey {
1437 type_name: td.name.clone(),
1438 kind: "section",
1439 offending_key: section.key.clone(),
1440 reserved_keys: reserved_section_keys()
1441 .iter()
1442 .map(|s| s.to_string())
1443 .collect(),
1444 });
1445 }
1446 }
1447
1448 if let Err(e) = check_rel(
1449 &td.name,
1450 "hierarchy_relationship",
1451 &td.hierarchy_relationship,
1452 rel_names,
1453 available_rels,
1454 ) {
1455 errors.push(e);
1456 }
1457 for r in &td.no_self_loop_relationships {
1458 if let Err(e) = check_rel(
1459 &td.name,
1460 "no_self_loop_relationships",
1461 r,
1462 rel_names,
1463 available_rels,
1464 ) {
1465 errors.push(e);
1466 }
1467 }
1468 for r in td.edge_weight_overrides.keys() {
1469 if let Err(e) = check_rel(
1470 &td.name,
1471 "edge_weight_overrides",
1472 r,
1473 rel_names,
1474 available_rels,
1475 ) {
1476 errors.push(e);
1477 }
1478 }
1479 for block in &td.required_outgoing {
1480 for r in &block.relationships {
1481 if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1482 errors.push(e);
1483 }
1484 }
1485 match (&block.when_field, &block.when_value) {
1493 (None, None) => {}
1494 (Some(f), None) => {
1495 errors.push(SchemaLoadError::InvalidConstraint {
1496 type_name: td.name.clone(),
1497 kind: "required_outgoing",
1498 offender: f.clone(),
1499 reason: "`when_field` requires `when_value` alongside it".to_string(),
1500 });
1501 }
1502 (None, Some(v)) => {
1503 errors.push(SchemaLoadError::InvalidConstraint {
1504 type_name: td.name.clone(),
1505 kind: "required_outgoing",
1506 offender: v.clone(),
1507 reason: "`when_value` requires `when_field` alongside it".to_string(),
1508 });
1509 }
1510 (Some(f), Some(v)) => match td.metadata_fields.iter().find(|mf| mf.key == *f) {
1511 None => {
1512 errors.push(SchemaLoadError::InvalidConstraint {
1513 type_name: td.name.clone(),
1514 kind: "required_outgoing",
1515 offender: f.clone(),
1516 reason: "`when_field` names no metadata field of this type".to_string(),
1517 });
1518 }
1519 Some(when_def) => match &when_def.enum_values {
1520 None => {
1521 errors.push(SchemaLoadError::InvalidConstraint {
1522 type_name: td.name.clone(),
1523 kind: "required_outgoing",
1524 offender: f.clone(),
1525 reason: format!(
1526 "`when_field` must name a metadata field with `enum_values`; `{f}` declares none"
1527 ),
1528 });
1529 }
1530 Some(allowed) if !allowed.contains(v) => {
1531 errors.push(SchemaLoadError::InvalidConstraint {
1532 type_name: td.name.clone(),
1533 kind: "required_outgoing",
1534 offender: v.clone(),
1535 reason: format!(
1536 "`when_value` is not in `{f}`'s enum_values [{}]",
1537 allowed.join(", ")
1538 ),
1539 });
1540 }
1541 Some(_) => {}
1542 },
1543 },
1544 }
1545 }
1546
1547 for ob in &td.must_reach {
1550 if ob.relationships.is_empty() {
1551 errors.push(SchemaLoadError::InvalidConstraint {
1552 type_name: td.name.clone(),
1553 kind: "must_reach",
1554 offender: "(empty)".to_string(),
1555 reason: "`relationships` must name at least one relationship".to_string(),
1556 });
1557 }
1558 for r in &ob.relationships {
1559 if let Err(e) = check_rel(&td.name, "must_reach", r, rel_names, available_rels) {
1560 errors.push(e);
1561 }
1562 }
1563 if ob.terminal_types.is_empty() {
1564 errors.push(SchemaLoadError::InvalidConstraint {
1565 type_name: td.name.clone(),
1566 kind: "must_reach",
1567 offender: "(empty)".to_string(),
1568 reason: "`terminal_types` must name at least one type".to_string(),
1569 });
1570 }
1571 if ob.max_depth == Some(0) {
1572 errors.push(SchemaLoadError::InvalidConstraint {
1573 type_name: td.name.clone(),
1574 kind: "must_reach",
1575 offender: "0".to_string(),
1576 reason: "`max_depth` must be at least 1 — nothing is reachable in zero hops"
1577 .to_string(),
1578 });
1579 }
1580 if ob.severity == crate::types::ConstraintSeverity::Block {
1581 errors.push(SchemaLoadError::InvalidConstraint {
1586 type_name: td.name.clone(),
1587 kind: "must_reach",
1588 offender: "block".to_string(),
1589 reason: "must_reach is always warn-tier — a reachability gap is created by \
1590 writes on other entities, so no single write can be refused for it"
1591 .to_string(),
1592 });
1593 }
1594 }
1595
1596 let mut signal_names_seen: HashSet<&str> = HashSet::new();
1600 for sig in &td.signals {
1601 let name_ok = sig
1602 .name
1603 .chars()
1604 .next()
1605 .is_some_and(|c| c.is_ascii_lowercase())
1606 && sig
1607 .name
1608 .chars()
1609 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1610 if !name_ok {
1611 errors.push(SchemaLoadError::InvalidConstraint {
1612 type_name: td.name.clone(),
1613 kind: "signal",
1614 offender: sig.name.clone(),
1615 reason: "`name` must match [a-z][a-z0-9_]*".to_string(),
1616 });
1617 }
1618 if !signal_names_seen.insert(sig.name.as_str()) {
1619 errors.push(SchemaLoadError::InvalidConstraint {
1620 type_name: td.name.clone(),
1621 kind: "signal",
1622 offender: sig.name.clone(),
1623 reason: "duplicate signal name on this type".to_string(),
1624 });
1625 }
1626 if sig.relationships.is_empty() {
1627 errors.push(SchemaLoadError::InvalidConstraint {
1628 type_name: td.name.clone(),
1629 kind: "signal",
1630 offender: sig.name.clone(),
1631 reason: "`relationships` must name at least one relationship".to_string(),
1632 });
1633 }
1634 for r in &sig.relationships {
1635 if let Err(e) = check_rel(&td.name, "signals", r, rel_names, available_rels) {
1636 errors.push(e);
1637 }
1638 }
1639 if sig.thresholds.is_empty() {
1640 errors.push(SchemaLoadError::InvalidConstraint {
1641 type_name: td.name.clone(),
1642 kind: "signal",
1643 offender: sig.name.clone(),
1644 reason: "`thresholds` must declare at least one step".to_string(),
1645 });
1646 }
1647 for pair in sig.thresholds.windows(2) {
1648 if pair[1].at_least <= pair[0].at_least {
1649 errors.push(SchemaLoadError::InvalidConstraint {
1650 type_name: td.name.clone(),
1651 kind: "signal",
1652 offender: pair[1].at_least.to_string(),
1653 reason: "`thresholds` must have strictly increasing `at_least` values"
1654 .to_string(),
1655 });
1656 }
1657 }
1658 match (&sig.neighbour_field, &sig.neighbour_value) {
1659 (None, None) | (Some(_), Some(_)) => {}
1660 (Some(f), None) => {
1661 errors.push(SchemaLoadError::InvalidConstraint {
1662 type_name: td.name.clone(),
1663 kind: "signal",
1664 offender: f.clone(),
1665 reason: "`neighbour_field` requires `neighbour_value` alongside it".to_string(),
1666 });
1667 }
1668 (None, Some(v)) => {
1669 errors.push(SchemaLoadError::InvalidConstraint {
1670 type_name: td.name.clone(),
1671 kind: "signal",
1672 offender: v.clone(),
1673 reason: "`neighbour_value` requires `neighbour_field` alongside it".to_string(),
1674 });
1675 }
1676 }
1677 }
1678
1679 let field_keys: std::collections::HashSet<&str> =
1683 td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1684 let section_keys: std::collections::HashSet<&str> =
1685 td.sections.iter().map(|sec| sec.key.as_str()).collect();
1686 for c in &td.constraints {
1687 match c {
1688 crate::types::ConstraintDef::RequiresWhen {
1689 field,
1690 when_field,
1691 when_value,
1692 ..
1693 } => {
1694 if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1695 errors.push(SchemaLoadError::InvalidConstraint {
1696 type_name: td.name.clone(),
1697 kind: "requires_when",
1698 offender: field.clone(),
1699 reason: "`field` names neither a metadata field nor a section of this type"
1700 .to_string(),
1701 });
1702 }
1703 let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1704 else {
1705 errors.push(SchemaLoadError::InvalidConstraint {
1706 type_name: td.name.clone(),
1707 kind: "requires_when",
1708 offender: when_field.clone(),
1709 reason: "`when_field` names no metadata field of this type".to_string(),
1710 });
1711 continue;
1712 };
1713 if let Some(allowed) = &when_def.enum_values
1714 && !allowed.contains(when_value)
1715 {
1716 errors.push(SchemaLoadError::InvalidConstraint {
1717 type_name: td.name.clone(),
1718 kind: "requires_when",
1719 offender: when_value.clone(),
1720 reason: format!(
1721 "`when_value` is not in `{when_field}`'s enum_values [{}]",
1722 allowed.join(", ")
1723 ),
1724 });
1725 }
1726 }
1727 crate::types::ConstraintDef::Unique { fields, .. } => {
1728 if fields.is_empty() {
1729 errors.push(SchemaLoadError::InvalidConstraint {
1730 type_name: td.name.clone(),
1731 kind: "unique",
1732 offender: "(empty)".to_string(),
1733 reason: "`fields` must name at least one metadata field".to_string(),
1734 });
1735 }
1736 for f in fields {
1737 if !field_keys.contains(f.as_str()) {
1738 errors.push(SchemaLoadError::InvalidConstraint {
1739 type_name: td.name.clone(),
1740 kind: "unique",
1741 offender: f.clone(),
1742 reason: "`fields` entry names no metadata field of this type"
1743 .to_string(),
1744 });
1745 }
1746 }
1747 }
1748 crate::types::ConstraintDef::EnumFromNeighbour {
1749 field, rel_type, ..
1750 } => {
1751 if !field_keys.contains(field.as_str()) {
1752 errors.push(SchemaLoadError::InvalidConstraint {
1753 type_name: td.name.clone(),
1754 kind: "enum_from_neighbour",
1755 offender: field.clone(),
1756 reason: "`field` names no metadata field of this type".to_string(),
1757 });
1758 }
1759 if !rel_names.contains(rel_type) {
1760 errors.push(SchemaLoadError::InvalidConstraint {
1761 type_name: td.name.clone(),
1762 kind: "enum_from_neighbour",
1763 offender: rel_type.clone(),
1764 reason: "`rel_type` is not in the schema's relationship vocabulary"
1765 .to_string(),
1766 });
1767 }
1768 }
1772 crate::types::ConstraintDef::StatusPropagation {
1773 field,
1774 value,
1775 rel_type,
1776 rel_types,
1777 severity,
1778 ..
1779 } => {
1780 match td.metadata_fields.iter().find(|f| f.key == *field) {
1781 None => {
1782 errors.push(SchemaLoadError::InvalidConstraint {
1783 type_name: td.name.clone(),
1784 kind: "status_propagation",
1785 offender: field.clone(),
1786 reason: "`field` names no metadata field of this type".to_string(),
1787 });
1788 }
1789 Some(field_def) => {
1790 if let Some(allowed) = &field_def.enum_values
1791 && !allowed.contains(value)
1792 {
1793 errors.push(SchemaLoadError::InvalidConstraint {
1794 type_name: td.name.clone(),
1795 kind: "status_propagation",
1796 offender: value.clone(),
1797 reason: format!(
1798 "`value` is not in `{field}`'s enum_values [{}]",
1799 allowed.join(", ")
1800 ),
1801 });
1802 }
1803 }
1804 }
1805 match (rel_type, rel_types) {
1808 (Some(_), Some(_)) => {
1809 errors.push(SchemaLoadError::InvalidConstraint {
1810 type_name: td.name.clone(),
1811 kind: "status_propagation",
1812 offender: "rel_type".to_string(),
1813 reason: "declare `rel_type` or `rel_types`, not both".to_string(),
1814 });
1815 }
1816 (None, None) => {
1817 errors.push(SchemaLoadError::InvalidConstraint {
1818 type_name: td.name.clone(),
1819 kind: "status_propagation",
1820 offender: "(missing)".to_string(),
1821 reason: "one of `rel_type` / `rel_types` is required".to_string(),
1822 });
1823 }
1824 (Some(single), None) => {
1825 if !rel_names.contains(single) {
1826 errors.push(SchemaLoadError::InvalidConstraint {
1827 type_name: td.name.clone(),
1828 kind: "status_propagation",
1829 offender: single.clone(),
1830 reason: "`rel_type` is not in the schema's relationship vocabulary"
1831 .to_string(),
1832 });
1833 }
1834 }
1835 (None, Some(set)) => {
1836 if set.is_empty() {
1837 errors.push(SchemaLoadError::InvalidConstraint {
1838 type_name: td.name.clone(),
1839 kind: "status_propagation",
1840 offender: "(empty)".to_string(),
1841 reason: "`rel_types` must name at least one relationship"
1842 .to_string(),
1843 });
1844 }
1845 for name in set {
1846 if !rel_names.contains(name) {
1847 errors.push(SchemaLoadError::InvalidConstraint {
1848 type_name: td.name.clone(),
1849 kind: "status_propagation",
1850 offender: name.clone(),
1851 reason: "`rel_types` entry is not in the schema's \
1852 relationship vocabulary"
1853 .to_string(),
1854 });
1855 }
1856 }
1857 }
1858 }
1859 if *severity == crate::types::ConstraintSeverity::Block {
1860 errors.push(SchemaLoadError::InvalidConstraint {
1866 type_name: td.name.clone(),
1867 kind: "status_propagation",
1868 offender: "block".to_string(),
1869 reason: "status_propagation is always warn-tier — a parent falling after \
1870 the child was written cannot retroactively make the child's \
1871 write illegal"
1872 .to_string(),
1873 });
1874 }
1875 }
1876 }
1877 }
1878
1879 if let Some(due) = &td.due {
1882 match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
1883 None => errors.push(SchemaLoadError::InvalidDueAxis {
1884 type_name: td.name.clone(),
1885 offender: due.date_field.clone(),
1886 reason: "`date_field` names no metadata field of this type".to_string(),
1887 }),
1888 Some(f) if f.field_type != crate::types::FieldType::Date => {
1889 errors.push(SchemaLoadError::InvalidDueAxis {
1890 type_name: td.name.clone(),
1891 offender: due.date_field.clone(),
1892 reason: "`date_field` must name a date-typed metadata field".to_string(),
1893 })
1894 }
1895 Some(_) => {}
1896 }
1897 match td
1898 .metadata_fields
1899 .iter()
1900 .find(|f| f.key == due.status_field)
1901 {
1902 None => errors.push(SchemaLoadError::InvalidDueAxis {
1903 type_name: td.name.clone(),
1904 offender: due.status_field.clone(),
1905 reason: "`status_field` names no metadata field of this type".to_string(),
1906 }),
1907 Some(f) => match &f.enum_values {
1908 None => errors.push(SchemaLoadError::InvalidDueAxis {
1909 type_name: td.name.clone(),
1910 offender: due.status_field.clone(),
1911 reason: "`status_field` must name an enum-typed metadata field \
1912 (declare enum_values)"
1913 .to_string(),
1914 }),
1915 Some(allowed) => {
1916 for v in &due.open_values {
1917 if !allowed.contains(v) {
1918 errors.push(SchemaLoadError::InvalidDueAxis {
1919 type_name: td.name.clone(),
1920 offender: v.clone(),
1921 reason: format!(
1922 "`open_values` entry is not in `{}`'s enum_values [{}]",
1923 due.status_field,
1924 allowed.join(", ")
1925 ),
1926 });
1927 }
1928 }
1929 }
1930 },
1931 }
1932 if due.open_values.is_empty() {
1933 errors.push(SchemaLoadError::InvalidDueAxis {
1934 type_name: td.name.clone(),
1935 offender: "(empty)".to_string(),
1936 reason: "`open_values` must name at least one open status value".to_string(),
1937 });
1938 }
1939 if let Some(lead) = &due.lead_section
1940 && !td.sections.iter().any(|s| s.key == *lead)
1941 {
1942 errors.push(SchemaLoadError::InvalidDueAxis {
1943 type_name: td.name.clone(),
1944 offender: lead.clone(),
1945 reason: "`lead_section` names no section of this type".to_string(),
1946 });
1947 }
1948 }
1949
1950 let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
1952 if catch_all_count != 1 {
1953 errors.push(SchemaLoadError::CatchAllViolation {
1954 type_name: td.name.clone(),
1955 count: catch_all_count,
1956 });
1957 }
1958
1959 let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
1961 let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
1962
1963 for f in &td.text_fields {
1964 if !section_keys.contains(f.as_str()) {
1966 errors.push(SchemaLoadError::UnknownFieldReference {
1967 type_name: td.name.clone(),
1968 field: "text_fields",
1969 reference: f.clone(),
1970 });
1971 }
1972 }
1973 for f in &td.health_required_fields {
1974 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1975 errors.push(SchemaLoadError::UnknownFieldReference {
1976 type_name: td.name.clone(),
1977 field: "health_required_fields",
1978 reference: f.clone(),
1979 });
1980 }
1981 }
1982 for f in &td.updatable_fields {
1983 if f == "title" {
1985 continue;
1986 }
1987 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1988 errors.push(SchemaLoadError::UnknownFieldReference {
1989 type_name: td.name.clone(),
1990 field: "updatable_fields",
1991 reference: f.clone(),
1992 });
1993 }
1994 }
1995
1996 for m in &td.metadata_fields {
1998 if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
1999 && !allowed.contains(default)
2000 {
2001 errors.push(SchemaLoadError::DefaultValueNotInEnum {
2002 type_name: td.name.clone(),
2003 field: m.key.clone(),
2004 default: default.clone(),
2005 allowed: allowed.clone(),
2006 });
2007 }
2008 }
2009}
2010
2011fn check_rel(
2012 type_name: &str,
2013 field: &'static str,
2014 relationship: &str,
2015 rel_names: &HashSet<String>,
2016 available: &[String],
2017) -> Result<(), SchemaLoadError> {
2018 if rel_names.contains(relationship) {
2019 return Ok(());
2020 }
2021 Err(SchemaLoadError::UndeclaredRelationship {
2022 type_name: type_name.into(),
2023 field,
2024 relationship: relationship.into(),
2025 available: available.to_vec(),
2026 })
2027}