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("sealed schema package carries no schema.yaml")]
239 SealedPackageMissingManifest,
240
241 #[error(
246 "cross_mem_relationships declares to_schema '*' but the schema declares no \
247 alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
248 declare alias_target_rel_type, or name each destination schema explicitly"
249 )]
250 CrossMemWildcardWithoutAliasTarget,
251
252 #[error(
258 "cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
259 wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
260 hand-authored structural edges need a per-destination-schema declaration"
261 )]
262 CrossMemWildcardNonAliasRelType {
263 rel_type: String,
264 alias_target: String,
265 },
266
267 #[error(
274 "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
275 declared.join(", "),
276 format_suggestion(reference, declared)
277 )]
278 UndeclaredCrossMemSourceType {
279 to_schema: String,
280 relationship: String,
281 reference: String,
282 declared: Vec<String>,
283 },
284
285 #[error(
290 "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
291 declared.join(", "),
292 format_suggestion(target, declared)
293 )]
294 AliasTargetRelTypeNotDeclared {
295 schema: String,
296 target: String,
297 declared: Vec<String>,
298 },
299
300 #[error(
309 "schema declares section heading(s) that cannot round-trip to their key(s): {}. \
310 Fix: make each heading derive to its key — lowercasing the heading and replacing \
311 spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
312 `Current State`)",
313 format_heading_violations(violations)
314 )]
315 SectionHeadingMismatch {
316 violations: Vec<HeadingKeyViolation>,
317 },
318
319 #[error(
329 "schema has {} violations:\n{}",
330 errors.len(),
331 format_multiple(errors)
332 )]
333 Multiple { errors: Vec<SchemaLoadError> },
334}
335
336fn format_multiple(errors: &[SchemaLoadError]) -> String {
337 errors
338 .iter()
339 .enumerate()
340 .map(|(i, e)| format!(" {}. {e}", i + 1))
341 .collect::<Vec<_>>()
342 .join("\n")
343}
344
345fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
350 debug_assert!(!errors.is_empty());
351 if errors.len() == 1 {
352 errors.remove(0)
353 } else {
354 SchemaLoadError::Multiple { errors }
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq)]
361pub struct HeadingKeyViolation {
362 pub type_name: String,
363 pub key: String,
364 pub heading: String,
365 pub derived_key: String,
366}
367
368fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
369 violations
370 .iter()
371 .map(|v| {
372 format!(
373 "type '{}' section key '{}' has heading '{}' (derives to '{}')",
374 v.type_name, v.key, v.heading, v.derived_key
375 )
376 })
377 .collect::<Vec<_>>()
378 .join("; ")
379}
380
381pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
394 let mut violations = Vec::new();
395 let mut type_names: Vec<&String> = schema.types.keys().collect();
398 type_names.sort();
399 for type_name in type_names {
400 let t = &schema.types[type_name];
401 for s in &t.sections {
402 let derived_key = crate::types::derive_section_key(&s.heading);
403 if derived_key != s.key {
404 violations.push(HeadingKeyViolation {
405 type_name: type_name.clone(),
406 key: s.key.clone(),
407 heading: s.heading.clone(),
408 derived_key,
409 });
410 }
411 }
412 }
413 if violations.is_empty() {
414 Ok(())
415 } else {
416 Err(SchemaLoadError::SectionHeadingMismatch { violations })
417 }
418}
419
420pub fn reserved_section_keys() -> &'static [&'static str] {
424 &["relationships"]
425}
426
427pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
435 &["type", "mem", "id"]
436}
437
438pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
451 for td in schema.types.values() {
452 for key in &td.declared_metadata_keys {
453 if reserved_metadata_field_keys().contains(&key.as_str()) {
454 return Err(SchemaLoadError::ReservedSchemaKey {
455 type_name: td.name.clone(),
456 kind: "metadata_field",
457 offending_key: key.clone(),
458 reserved_keys: reserved_metadata_field_keys()
459 .iter()
460 .map(|s| s.to_string())
461 .collect(),
462 });
463 }
464 }
465 }
466 Ok(())
467}
468
469fn format_suggestion(needle: &str, candidates: &[String]) -> String {
470 let mut best: Option<(usize, &String)> = None;
471 for cand in candidates {
472 let d = strsim::levenshtein(needle, cand);
473 match best {
474 Some((bd, _)) if bd <= d => {}
475 _ => best = Some((d, cand)),
476 }
477 }
478 match best {
479 Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
480 format!("Did you mean '{cand}'?")
481 }
482 _ => String::new(),
483 }
484}
485
486pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
488 let manifest_path = path.join("schema.yaml");
489 let manifest_text =
490 std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
491 path: manifest_path.clone(),
492 source: e,
493 })?;
494
495 let types_dir = path.join("types");
496 let mut type_files: Vec<(String, String)> = Vec::new();
497 if types_dir.is_dir() {
498 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
499 path: types_dir.clone(),
500 source: e,
501 })?;
502 for entry in entries {
503 let entry = entry.map_err(|e| SchemaLoadError::Io {
504 path: types_dir.clone(),
505 source: e,
506 })?;
507 let p = entry.path();
508 if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
509 continue;
510 }
511 let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
512 continue;
513 };
514 let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
515 path: p.clone(),
516 source: e,
517 })?;
518 type_files.push((stem, contents));
519 }
520 }
521 type_files.sort_by(|a, b| a.0.cmp(&b.0));
524
525 load_with_context(
526 &manifest_text,
527 &type_files,
528 Some(&manifest_path),
529 Some(&types_dir),
530 MetadataPolarityFormat::RequiredOptIn,
532 )
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum MetadataPolarityFormat {
549 Legacy,
551 RequiredOptIn,
553}
554
555pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
573
574pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
576
577pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
581 if !files
582 .iter()
583 .any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
584 {
585 files.push((
586 SCHEMA_FORMAT_MARKER_FILE.to_string(),
587 SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
588 ));
589 }
590 files
591}
592
593pub fn load_schema_from_memory(
601 manifest_yaml: &str,
602 types_yamls: &[(String, String)],
603) -> Result<Schema, SchemaLoadError> {
604 load_with_context(
605 manifest_yaml,
606 types_yamls,
607 None,
608 None,
609 MetadataPolarityFormat::Legacy,
610 )
611}
612
613pub fn load_sealed_package(files: &[(String, Vec<u8>)]) -> Result<Schema, SchemaLoadError> {
632 let mut manifest: Option<String> = None;
633 let mut types: Vec<(String, String)> = Vec::new();
634 let mut marked = false;
635 for (rel, bytes) in files {
636 if rel == "schema.yaml" {
637 manifest = Some(String::from_utf8_lossy(bytes).into_owned());
638 } else if rel == SCHEMA_FORMAT_MARKER_FILE {
639 marked = true;
640 } else if let Some(stem) = rel
641 .strip_prefix("types/")
642 .and_then(|f| f.strip_suffix(".yaml"))
643 {
644 types.push((
645 stem.to_string(),
646 String::from_utf8_lossy(bytes).into_owned(),
647 ));
648 }
649 }
650 let manifest = manifest.ok_or(SchemaLoadError::SealedPackageMissingManifest)?;
651 types.sort_by(|a, b| a.0.cmp(&b.0));
654 let format = if marked {
655 MetadataPolarityFormat::RequiredOptIn
656 } else {
657 MetadataPolarityFormat::Legacy
658 };
659 load_with_context(&manifest, &types, None, None, format)
660}
661
662pub fn load_schema_from_memory_with_format(
666 manifest_yaml: &str,
667 types_yamls: &[(String, String)],
668 format: MetadataPolarityFormat,
669) -> Result<Schema, SchemaLoadError> {
670 load_with_context(manifest_yaml, types_yamls, None, None, format)
671}
672
673fn load_with_context(
674 manifest_yaml: &str,
675 types_yamls: &[(String, String)],
676 manifest_path: Option<&Path>,
677 types_dir: Option<&Path>,
678 format: MetadataPolarityFormat,
679) -> Result<Schema, SchemaLoadError> {
680 let mut errors: Vec<SchemaLoadError> = Vec::new();
689
690 let mut manifest: SchemaManifest =
691 serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
692 path: manifest_path
693 .map(Path::to_path_buf)
694 .unwrap_or_else(|| PathBuf::from("<memory>")),
695 source: e,
696 })?;
697
698 if let Err(e) = validate_name(&manifest.name) {
699 errors.push(e);
700 }
701
702 let version = match semver::Version::parse(&manifest.version) {
706 Ok(v) => Some(v),
707 Err(_) => {
708 errors.push(SchemaLoadError::InvalidVersion {
709 value: manifest.version.clone(),
710 });
711 None
712 }
713 };
714
715 let mut rel_names: HashSet<String> = HashSet::new();
717 for def in &manifest.relationships.definitions {
718 if !rel_names.insert(def.name.clone()) {
719 errors.push(SchemaLoadError::DuplicateRelationship {
720 name: def.name.clone(),
721 });
722 }
723 }
724 if !rel_names.contains("_default") {
725 errors.push(SchemaLoadError::MissingDefaultWeight);
726 }
727 let available_rels: Vec<String> = manifest
728 .relationships
729 .definitions
730 .iter()
731 .map(|d| d.name.clone())
732 .collect();
733
734 if let Some(target) = &manifest.alias_target_rel_type
739 && !rel_names.contains(target)
740 {
741 let mut declared = available_rels.clone();
742 declared.sort();
743 errors.push(SchemaLoadError::AliasTargetRelTypeNotDeclared {
744 schema: manifest.name.clone(),
745 target: target.clone(),
746 declared,
747 });
748 }
749
750 if let Some(pointer) = manifest.alias_target_rel_type.clone() {
769 for def in &mut manifest.relationships.definitions {
770 if def.name == pointer {
771 def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
772 }
773 }
774 }
775
776 for def in &manifest.relationships.definitions {
781 for t in &def.source_types {
782 if !manifest.types.iter().any(|d| d == t) {
783 errors.push(SchemaLoadError::UndeclaredRelationshipType {
784 relationship: def.name.clone(),
785 field: "source_types",
786 reference: t.clone(),
787 declared: manifest.types.clone(),
788 });
789 }
790 }
791 for t in &def.target_types {
792 if !manifest.types.iter().any(|d| d == t) {
793 errors.push(SchemaLoadError::UndeclaredRelationshipType {
794 relationship: def.name.clone(),
795 field: "target_types",
796 reference: t.clone(),
797 declared: manifest.types.clone(),
798 });
799 }
800 }
801 }
802
803 let mut seen_to_schemas: HashSet<String> = HashSet::new();
814 for entry in &manifest.cross_mem_relationships {
815 if entry.to_schema == "*" {
816 match manifest.alias_target_rel_type.as_deref() {
823 None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
824 Some(alias) => {
825 for def in &entry.definitions {
826 if def.name != alias {
827 errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
828 rel_type: def.name.clone(),
829 alias_target: alias.to_string(),
830 });
831 }
832 }
833 }
834 }
835 } else if entry.to_schema.contains('@') {
836 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
837 value: entry.to_schema.clone(),
838 reason: "must not carry a version or range".into(),
839 });
840 } else if let Err(reason) = name_shape(&entry.to_schema) {
841 errors.push(SchemaLoadError::InvalidCrossMemToSchema {
842 value: entry.to_schema.clone(),
843 reason: reason.into(),
844 });
845 }
846 if !seen_to_schemas.insert(entry.to_schema.clone()) {
847 errors.push(SchemaLoadError::DuplicateCrossMemToSchema {
848 to_schema: entry.to_schema.clone(),
849 });
850 }
851 for def in &entry.definitions {
852 for t in &def.source_types {
853 if !manifest.types.iter().any(|d| d == t) {
854 errors.push(SchemaLoadError::UndeclaredCrossMemSourceType {
855 to_schema: entry.to_schema.clone(),
856 relationship: def.name.clone(),
857 reference: t.clone(),
858 declared: manifest.types.clone(),
859 });
860 }
861 }
862 }
863 }
864
865 let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
867 found_stems.sort();
868 let mut declared = manifest.types.clone();
869 declared.sort();
870 if found_stems != declared {
871 errors.push(SchemaLoadError::TypeFileMismatch {
875 declared,
876 found: found_stems,
877 });
878 return Err(collapse(errors));
879 }
880
881 let defaults: IndexMap<String, f32> = manifest
883 .relationships
884 .definitions
885 .iter()
886 .map(|d| (d.name.clone(), d.default_weight))
887 .collect();
888
889 let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
890 let mut had_type_parse_failure = false;
891
892 for (stem, text) in types_yamls {
893 let type_path = types_dir
894 .map(|d| d.join(format!("{stem}.yaml")))
895 .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
896
897 let mut td: TypeDefinition = match serde_yaml_ng::from_str(text) {
898 Ok(td) => td,
899 Err(e) => {
900 errors.push(SchemaLoadError::ParseType {
905 path: type_path.clone(),
906 source: e,
907 });
908 had_type_parse_failure = true;
909 continue;
910 }
911 };
912
913 if td.name != *stem {
914 errors.push(SchemaLoadError::TypeNameMismatch {
915 file: stem.clone(),
916 declared: td.name.clone(),
917 });
918 }
919
920 if let Some(legacy) = td.legacy_propagating_relationships.take() {
928 if types_dir.is_some() {
929 errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
930 type_name: td.name.clone(),
931 });
932 } else if td.no_self_loop_relationships.is_empty() {
933 td.no_self_loop_relationships = legacy;
934 }
935 }
936
937 if td.legacy_examples.take().is_some() && types_dir.is_some() {
943 errors.push(SchemaLoadError::ExamplesRetired {
944 type_name: td.name.clone(),
945 });
946 }
947
948 for field in &mut td.metadata_fields {
955 if matches!(format, MetadataPolarityFormat::RequiredOptIn)
959 && field.legacy_optional.is_some()
960 {
961 errors.push(SchemaLoadError::OptionalRetired {
962 type_name: td.name.clone(),
963 field: field.key.clone(),
964 });
965 }
966 field.required_resolved = match (field.required, field.legacy_optional.take()) {
967 (Some(required), _) => required,
968 (None, Some(optional)) => !optional,
969 (None, None) => matches!(format, MetadataPolarityFormat::Legacy),
970 };
971 }
972
973 td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
983
984 for field in &td.metadata_fields {
989 if base_metadata::is_base_key(&field.key)
990 && !reserved_metadata_field_keys().contains(&field.key.as_str())
991 {
992 errors.push(SchemaLoadError::RedeclaredBaseField {
993 type_name: td.name.clone(),
994 field: field.key.clone(),
995 });
996 }
997 }
998
999 let mut merged = base_metadata::prefix_fields();
1002 merged.append(&mut td.metadata_fields);
1003 merged.extend(base_metadata::suffix_fields());
1004 td.metadata_fields = merged;
1005
1006 compile_section_formats(&mut td);
1007 validate_type(&td, &rel_names, &available_rels, &mut errors);
1008
1009 let mut weights = defaults.clone();
1011 for (k, v) in &td.edge_weight_overrides {
1012 weights.insert(k.clone(), *v);
1013 }
1014 td.edge_weights = weights;
1015
1016 types_map.insert(stem.clone(), Arc::new(td));
1017 }
1018
1019 if !had_type_parse_failure {
1027 let all_section_keys: HashSet<&str> = types_map
1028 .values()
1029 .flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
1030 .collect();
1031 let mut type_names: Vec<&String> = types_map.keys().collect();
1034 type_names.sort();
1035 for type_name in type_names {
1036 let td = &types_map[type_name];
1037 for c in &td.constraints {
1038 if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
1039 && !all_section_keys.contains(section.as_str())
1040 {
1041 errors.push(SchemaLoadError::InvalidConstraint {
1042 type_name: td.name.clone(),
1043 kind: "enum_from_neighbour",
1044 offender: section.clone(),
1045 reason: "`section` names a section key no type of this schema declares"
1046 .to_string(),
1047 });
1048 }
1049 }
1050 }
1051 }
1052
1053 if !errors.is_empty() {
1054 return Err(collapse(errors));
1055 }
1056
1057 Ok(Schema {
1058 manifest,
1059 version: version.expect("version parse failure would have accumulated an error"),
1060 types: types_map,
1061 })
1062}
1063
1064fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
1065 name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
1066 value: name.into(),
1067 reason,
1068 })
1069}
1070
1071pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
1077 name_shape(name)
1078}
1079
1080fn name_shape(name: &str) -> Result<(), &'static str> {
1085 if name.is_empty() {
1086 return Err("must not be empty");
1087 }
1088 let mut chars = name.chars();
1089 let first = chars.next().unwrap();
1090 if !first.is_ascii_lowercase() {
1091 return Err("must start with a lowercase letter");
1092 }
1093 for c in chars {
1094 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
1095 return Err("must contain only lowercase letters, digits, and hyphens");
1096 }
1097 }
1098 Ok(())
1099}
1100
1101fn compile_section_formats(td: &mut TypeDefinition) {
1109 use crate::content_expr::ContentExpr;
1110 for section in &mut td.sections {
1111 let declares_any = section.content.is_some()
1117 || section.item_pattern.is_some()
1118 || section.table.is_some()
1119 || section.example.is_some()
1120 || section.format_severity != crate::types::ConstraintSeverity::Block;
1121 if !declares_any {
1122 continue;
1123 }
1124 let mut problems: Vec<String> = Vec::new();
1125
1126 let compiled = match §ion.content {
1127 None => {
1128 problems.push(
1129 "`item_pattern` / `table` / `example` require a `content` declaration"
1130 .to_string(),
1131 );
1132 None
1133 }
1134 Some(expr_src) => match ContentExpr::parse(expr_src) {
1135 Ok(expr) => Some(expr),
1136 Err(e) => {
1137 problems.push(format!("`content` is invalid: {e}"));
1138 None
1139 }
1140 },
1141 };
1142
1143 if let Some(pattern) = §ion.item_pattern {
1144 if let Err(e) = regex::Regex::new(pattern) {
1145 problems.push(format!("`item_pattern` is not a valid regex: {e}"));
1146 }
1147 if let Some(expr) = &compiled {
1148 let names = expr.mentioned_names();
1149 let has_list = names.contains(&"list");
1150 let has_paragraph = names.contains(&"paragraph");
1151 if has_list == has_paragraph {
1152 problems.push(
1153 "`item_pattern` requires a `content` expression containing exactly one of `list` / `paragraph` (tables use `column_patterns`)"
1154 .to_string(),
1155 );
1156 }
1157 }
1158 }
1159
1160 if let Some(table) = §ion.table {
1161 if let Some(expr) = &compiled
1162 && !expr.mentioned_names().contains(&"table")
1163 {
1164 problems.push(
1165 "`table` block is only legal when `content` contains `table`".to_string(),
1166 );
1167 }
1168 if table.columns.is_empty() {
1169 problems.push("`table.columns` must name at least one column".to_string());
1170 }
1171 for (column, pattern) in &table.column_patterns {
1172 if !table.columns.contains(column) {
1173 problems.push(format!(
1174 "`column_patterns` names '{column}', which is not in `columns`"
1175 ));
1176 }
1177 if let Err(e) = regex::Regex::new(pattern) {
1178 problems.push(format!(
1179 "`column_patterns.{column}` is not a valid regex: {e}"
1180 ));
1181 }
1182 }
1183 }
1184
1185 if problems.is_empty() {
1186 section.compiled_content = compiled;
1187 } else {
1188 section.format_problems = problems;
1189 }
1190 }
1191}
1192
1193pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
1201 let mut first: Option<(String, String)> = None;
1206 let mut problems: Vec<String> = Vec::new();
1207 for td in schema.types.values() {
1208 for section in &td.sections {
1209 if section.format_problems.is_empty() {
1210 continue;
1211 }
1212 if first.is_none() {
1213 first = Some((td.name.clone(), section.key.clone()));
1214 problems.extend(section.format_problems.iter().cloned());
1215 } else {
1216 problems.extend(
1217 section
1218 .format_problems
1219 .iter()
1220 .map(|p| format!("[{}.{}] {p}", td.name, section.key)),
1221 );
1222 }
1223 }
1224 }
1225 match first {
1226 Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
1227 type_name,
1228 section,
1229 problems,
1230 }),
1231 None => Ok(()),
1232 }
1233}
1234
1235fn validate_type(
1236 td: &TypeDefinition,
1237 rel_names: &HashSet<String>,
1238 available_rels: &[String],
1239 errors: &mut Vec<SchemaLoadError>,
1240) {
1241 for section in &td.sections {
1249 if reserved_section_keys().contains(§ion.key.as_str()) {
1250 errors.push(SchemaLoadError::ReservedSchemaKey {
1251 type_name: td.name.clone(),
1252 kind: "section",
1253 offending_key: section.key.clone(),
1254 reserved_keys: reserved_section_keys()
1255 .iter()
1256 .map(|s| s.to_string())
1257 .collect(),
1258 });
1259 }
1260 }
1261
1262 if let Err(e) = check_rel(
1263 &td.name,
1264 "hierarchy_relationship",
1265 &td.hierarchy_relationship,
1266 rel_names,
1267 available_rels,
1268 ) {
1269 errors.push(e);
1270 }
1271 for r in &td.no_self_loop_relationships {
1272 if let Err(e) = check_rel(
1273 &td.name,
1274 "no_self_loop_relationships",
1275 r,
1276 rel_names,
1277 available_rels,
1278 ) {
1279 errors.push(e);
1280 }
1281 }
1282 for r in td.edge_weight_overrides.keys() {
1283 if let Err(e) = check_rel(
1284 &td.name,
1285 "edge_weight_overrides",
1286 r,
1287 rel_names,
1288 available_rels,
1289 ) {
1290 errors.push(e);
1291 }
1292 }
1293 for block in &td.required_outgoing {
1294 for r in &block.relationships {
1295 if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
1296 errors.push(e);
1297 }
1298 }
1299 }
1300
1301 let field_keys: std::collections::HashSet<&str> =
1305 td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
1306 let section_keys: std::collections::HashSet<&str> =
1307 td.sections.iter().map(|sec| sec.key.as_str()).collect();
1308 for c in &td.constraints {
1309 match c {
1310 crate::types::ConstraintDef::RequiresWhen {
1311 field,
1312 when_field,
1313 when_value,
1314 ..
1315 } => {
1316 if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
1317 errors.push(SchemaLoadError::InvalidConstraint {
1318 type_name: td.name.clone(),
1319 kind: "requires_when",
1320 offender: field.clone(),
1321 reason: "`field` names neither a metadata field nor a section of this type"
1322 .to_string(),
1323 });
1324 }
1325 let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
1326 else {
1327 errors.push(SchemaLoadError::InvalidConstraint {
1328 type_name: td.name.clone(),
1329 kind: "requires_when",
1330 offender: when_field.clone(),
1331 reason: "`when_field` names no metadata field of this type".to_string(),
1332 });
1333 continue;
1334 };
1335 if let Some(allowed) = &when_def.enum_values
1336 && !allowed.contains(when_value)
1337 {
1338 errors.push(SchemaLoadError::InvalidConstraint {
1339 type_name: td.name.clone(),
1340 kind: "requires_when",
1341 offender: when_value.clone(),
1342 reason: format!(
1343 "`when_value` is not in `{when_field}`'s enum_values [{}]",
1344 allowed.join(", ")
1345 ),
1346 });
1347 }
1348 }
1349 crate::types::ConstraintDef::Unique { fields, .. } => {
1350 if fields.is_empty() {
1351 errors.push(SchemaLoadError::InvalidConstraint {
1352 type_name: td.name.clone(),
1353 kind: "unique",
1354 offender: "(empty)".to_string(),
1355 reason: "`fields` must name at least one metadata field".to_string(),
1356 });
1357 }
1358 for f in fields {
1359 if !field_keys.contains(f.as_str()) {
1360 errors.push(SchemaLoadError::InvalidConstraint {
1361 type_name: td.name.clone(),
1362 kind: "unique",
1363 offender: f.clone(),
1364 reason: "`fields` entry names no metadata field of this type"
1365 .to_string(),
1366 });
1367 }
1368 }
1369 }
1370 crate::types::ConstraintDef::EnumFromNeighbour {
1371 field, rel_type, ..
1372 } => {
1373 if !field_keys.contains(field.as_str()) {
1374 errors.push(SchemaLoadError::InvalidConstraint {
1375 type_name: td.name.clone(),
1376 kind: "enum_from_neighbour",
1377 offender: field.clone(),
1378 reason: "`field` names no metadata field of this type".to_string(),
1379 });
1380 }
1381 if !rel_names.contains(rel_type) {
1382 errors.push(SchemaLoadError::InvalidConstraint {
1383 type_name: td.name.clone(),
1384 kind: "enum_from_neighbour",
1385 offender: rel_type.clone(),
1386 reason: "`rel_type` is not in the schema's relationship vocabulary"
1387 .to_string(),
1388 });
1389 }
1390 }
1394 crate::types::ConstraintDef::StatusPropagation {
1395 field,
1396 value,
1397 rel_type,
1398 severity,
1399 ..
1400 } => {
1401 match td.metadata_fields.iter().find(|f| f.key == *field) {
1402 None => {
1403 errors.push(SchemaLoadError::InvalidConstraint {
1404 type_name: td.name.clone(),
1405 kind: "status_propagation",
1406 offender: field.clone(),
1407 reason: "`field` names no metadata field of this type".to_string(),
1408 });
1409 }
1410 Some(field_def) => {
1411 if let Some(allowed) = &field_def.enum_values
1412 && !allowed.contains(value)
1413 {
1414 errors.push(SchemaLoadError::InvalidConstraint {
1415 type_name: td.name.clone(),
1416 kind: "status_propagation",
1417 offender: value.clone(),
1418 reason: format!(
1419 "`value` is not in `{field}`'s enum_values [{}]",
1420 allowed.join(", ")
1421 ),
1422 });
1423 }
1424 }
1425 }
1426 if !rel_names.contains(rel_type) {
1427 errors.push(SchemaLoadError::InvalidConstraint {
1428 type_name: td.name.clone(),
1429 kind: "status_propagation",
1430 offender: rel_type.clone(),
1431 reason: "`rel_type` is not in the schema's relationship vocabulary"
1432 .to_string(),
1433 });
1434 }
1435 if *severity == crate::types::ConstraintSeverity::Block {
1436 errors.push(SchemaLoadError::InvalidConstraint {
1442 type_name: td.name.clone(),
1443 kind: "status_propagation",
1444 offender: "block".to_string(),
1445 reason: "status_propagation is always warn-tier — a parent falling after \
1446 the child was written cannot retroactively make the child's \
1447 write illegal"
1448 .to_string(),
1449 });
1450 }
1451 }
1452 }
1453 }
1454
1455 if let Some(due) = &td.due {
1458 match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
1459 None => errors.push(SchemaLoadError::InvalidDueAxis {
1460 type_name: td.name.clone(),
1461 offender: due.date_field.clone(),
1462 reason: "`date_field` names no metadata field of this type".to_string(),
1463 }),
1464 Some(f) if f.field_type != crate::types::FieldType::Date => {
1465 errors.push(SchemaLoadError::InvalidDueAxis {
1466 type_name: td.name.clone(),
1467 offender: due.date_field.clone(),
1468 reason: "`date_field` must name a date-typed metadata field".to_string(),
1469 })
1470 }
1471 Some(_) => {}
1472 }
1473 match td
1474 .metadata_fields
1475 .iter()
1476 .find(|f| f.key == due.status_field)
1477 {
1478 None => errors.push(SchemaLoadError::InvalidDueAxis {
1479 type_name: td.name.clone(),
1480 offender: due.status_field.clone(),
1481 reason: "`status_field` names no metadata field of this type".to_string(),
1482 }),
1483 Some(f) => match &f.enum_values {
1484 None => errors.push(SchemaLoadError::InvalidDueAxis {
1485 type_name: td.name.clone(),
1486 offender: due.status_field.clone(),
1487 reason: "`status_field` must name an enum-typed metadata field \
1488 (declare enum_values)"
1489 .to_string(),
1490 }),
1491 Some(allowed) => {
1492 for v in &due.open_values {
1493 if !allowed.contains(v) {
1494 errors.push(SchemaLoadError::InvalidDueAxis {
1495 type_name: td.name.clone(),
1496 offender: v.clone(),
1497 reason: format!(
1498 "`open_values` entry is not in `{}`'s enum_values [{}]",
1499 due.status_field,
1500 allowed.join(", ")
1501 ),
1502 });
1503 }
1504 }
1505 }
1506 },
1507 }
1508 if due.open_values.is_empty() {
1509 errors.push(SchemaLoadError::InvalidDueAxis {
1510 type_name: td.name.clone(),
1511 offender: "(empty)".to_string(),
1512 reason: "`open_values` must name at least one open status value".to_string(),
1513 });
1514 }
1515 if let Some(lead) = &due.lead_section
1516 && !td.sections.iter().any(|s| s.key == *lead)
1517 {
1518 errors.push(SchemaLoadError::InvalidDueAxis {
1519 type_name: td.name.clone(),
1520 offender: lead.clone(),
1521 reason: "`lead_section` names no section of this type".to_string(),
1522 });
1523 }
1524 }
1525
1526 let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
1528 if catch_all_count != 1 {
1529 errors.push(SchemaLoadError::CatchAllViolation {
1530 type_name: td.name.clone(),
1531 count: catch_all_count,
1532 });
1533 }
1534
1535 let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
1537 let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
1538
1539 for f in &td.text_fields {
1540 if !section_keys.contains(f.as_str()) {
1542 errors.push(SchemaLoadError::UnknownFieldReference {
1543 type_name: td.name.clone(),
1544 field: "text_fields",
1545 reference: f.clone(),
1546 });
1547 }
1548 }
1549 for f in &td.health_required_fields {
1550 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1551 errors.push(SchemaLoadError::UnknownFieldReference {
1552 type_name: td.name.clone(),
1553 field: "health_required_fields",
1554 reference: f.clone(),
1555 });
1556 }
1557 }
1558 for f in &td.updatable_fields {
1559 if f == "title" {
1561 continue;
1562 }
1563 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
1564 errors.push(SchemaLoadError::UnknownFieldReference {
1565 type_name: td.name.clone(),
1566 field: "updatable_fields",
1567 reference: f.clone(),
1568 });
1569 }
1570 }
1571
1572 for m in &td.metadata_fields {
1574 if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
1575 && !allowed.contains(default)
1576 {
1577 errors.push(SchemaLoadError::DefaultValueNotInEnum {
1578 type_name: td.name.clone(),
1579 field: m.key.clone(),
1580 default: default.clone(),
1581 allowed: allowed.clone(),
1582 });
1583 }
1584 }
1585}
1586
1587fn check_rel(
1588 type_name: &str,
1589 field: &'static str,
1590 relationship: &str,
1591 rel_names: &HashSet<String>,
1592 available: &[String],
1593) -> Result<(), SchemaLoadError> {
1594 if rel_names.contains(relationship) {
1595 return Ok(());
1596 }
1597 Err(SchemaLoadError::UndeclaredRelationship {
1598 type_name: type_name.into(),
1599 field,
1600 relationship: relationship.into(),
1601 available: available.to_vec(),
1602 })
1603}