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
66 #[error("schema relationship vocabulary must include a '_default' definition")]
67 MissingDefaultWeight,
68
69 #[error("duplicate relationship definition: '{name}'")]
70 DuplicateRelationship { name: String },
71
72 #[error(
73 "type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
74 available.join(", "),
75 format_suggestion(relationship, available)
76 )]
77 UndeclaredRelationship {
78 type_name: String,
79 field: &'static str,
80 relationship: String,
81 available: Vec<String>,
82 },
83
84 #[error(
85 "type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
86 )]
87 CatchAllViolation { type_name: String, count: usize },
88
89 #[error(
90 "type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
91 )]
92 UnknownFieldReference {
93 type_name: String,
94 field: &'static str,
95 reference: String,
96 },
97
98 #[error(
99 "type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
100 allowed.join(", ")
101 )]
102 DefaultValueNotInEnum {
103 type_name: String,
104 field: String,
105 default: String,
106 allowed: Vec<String>,
107 },
108
109 #[error(
110 "type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
111 )]
112 RedeclaredBaseField { type_name: String, field: String },
113
114 #[error(
115 "relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
116 declared.join(", "),
117 format_suggestion(reference, declared)
118 )]
119 UndeclaredRelationshipType {
120 relationship: String,
121 field: &'static str,
122 reference: String,
123 declared: Vec<String>,
124 },
125
126 #[error(
132 "type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
133 reserved_keys.join(", ")
134 )]
135 ReservedSchemaKey {
136 type_name: String,
137 kind: &'static str,
138 offending_key: String,
139 reserved_keys: Vec<String>,
140 },
141
142 #[error(
148 "cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
149 )]
150 InvalidCrossMemToSchema { value: String, reason: String },
151
152 #[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
157 DuplicateCrossMemToSchema { to_schema: String },
158
159 #[error(
166 "cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
167 declared.join(", "),
168 format_suggestion(reference, declared)
169 )]
170 UndeclaredCrossMemSourceType {
171 to_schema: String,
172 relationship: String,
173 reference: String,
174 declared: Vec<String>,
175 },
176
177 #[error(
182 "schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
183 declared.join(", "),
184 format_suggestion(target, declared)
185 )]
186 AliasTargetRelTypeNotDeclared {
187 schema: String,
188 target: String,
189 declared: Vec<String>,
190 },
191}
192
193pub fn reserved_section_keys() -> &'static [&'static str] {
197 &["relationships"]
198}
199
200pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
203 &["type"]
204}
205
206fn format_suggestion(needle: &str, candidates: &[String]) -> String {
207 let mut best: Option<(usize, &String)> = None;
208 for cand in candidates {
209 let d = strsim::levenshtein(needle, cand);
210 match best {
211 Some((bd, _)) if bd <= d => {}
212 _ => best = Some((d, cand)),
213 }
214 }
215 match best {
216 Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
217 format!("Did you mean '{cand}'?")
218 }
219 _ => String::new(),
220 }
221}
222
223pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
225 let manifest_path = path.join("schema.yaml");
226 let manifest_text =
227 std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
228 path: manifest_path.clone(),
229 source: e,
230 })?;
231
232 let types_dir = path.join("types");
233 let mut type_files: Vec<(String, String)> = Vec::new();
234 if types_dir.is_dir() {
235 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
236 path: types_dir.clone(),
237 source: e,
238 })?;
239 for entry in entries {
240 let entry = entry.map_err(|e| SchemaLoadError::Io {
241 path: types_dir.clone(),
242 source: e,
243 })?;
244 let p = entry.path();
245 if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
246 continue;
247 }
248 let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
249 continue;
250 };
251 let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
252 path: p.clone(),
253 source: e,
254 })?;
255 type_files.push((stem, contents));
256 }
257 }
258
259 load_with_context(
260 &manifest_text,
261 &type_files,
262 Some(&manifest_path),
263 Some(&types_dir),
264 )
265}
266
267pub fn load_schema_from_memory(
272 manifest_yaml: &str,
273 types_yamls: &[(String, String)],
274) -> Result<Schema, SchemaLoadError> {
275 load_with_context(manifest_yaml, types_yamls, None, None)
276}
277
278fn load_with_context(
279 manifest_yaml: &str,
280 types_yamls: &[(String, String)],
281 manifest_path: Option<&Path>,
282 types_dir: Option<&Path>,
283) -> Result<Schema, SchemaLoadError> {
284 let mut manifest: SchemaManifest =
285 serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
286 path: manifest_path
287 .map(Path::to_path_buf)
288 .unwrap_or_else(|| PathBuf::from("<memory>")),
289 source: e,
290 })?;
291
292 validate_name(&manifest.name)?;
293
294 let version =
295 semver::Version::parse(&manifest.version).map_err(|_| SchemaLoadError::InvalidVersion {
296 value: manifest.version.clone(),
297 })?;
298
299 let mut rel_names: HashSet<String> = HashSet::new();
301 for def in &manifest.relationships.definitions {
302 if !rel_names.insert(def.name.clone()) {
303 return Err(SchemaLoadError::DuplicateRelationship {
304 name: def.name.clone(),
305 });
306 }
307 }
308 if !rel_names.contains("_default") {
309 return Err(SchemaLoadError::MissingDefaultWeight);
310 }
311 let available_rels: Vec<String> = manifest
312 .relationships
313 .definitions
314 .iter()
315 .map(|d| d.name.clone())
316 .collect();
317
318 if let Some(target) = &manifest.alias_target_rel_type
323 && !rel_names.contains(target)
324 {
325 let mut declared = available_rels.clone();
326 declared.sort();
327 return Err(SchemaLoadError::AliasTargetRelTypeNotDeclared {
328 schema: manifest.name.clone(),
329 target: target.clone(),
330 declared,
331 });
332 }
333
334 if let Some(pointer) = manifest.alias_target_rel_type.clone() {
353 for def in &mut manifest.relationships.definitions {
354 if def.name == pointer {
355 def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
356 }
357 }
358 }
359
360 for def in &manifest.relationships.definitions {
365 for t in &def.source_types {
366 if !manifest.types.iter().any(|d| d == t) {
367 return Err(SchemaLoadError::UndeclaredRelationshipType {
368 relationship: def.name.clone(),
369 field: "source_types",
370 reference: t.clone(),
371 declared: manifest.types.clone(),
372 });
373 }
374 }
375 for t in &def.target_types {
376 if !manifest.types.iter().any(|d| d == t) {
377 return Err(SchemaLoadError::UndeclaredRelationshipType {
378 relationship: def.name.clone(),
379 field: "target_types",
380 reference: t.clone(),
381 declared: manifest.types.clone(),
382 });
383 }
384 }
385 }
386
387 let mut seen_to_schemas: HashSet<String> = HashSet::new();
398 for entry in &manifest.cross_mem_relationships {
399 if entry.to_schema.contains('@') {
400 return Err(SchemaLoadError::InvalidCrossMemToSchema {
401 value: entry.to_schema.clone(),
402 reason: "must not carry a version or range".into(),
403 });
404 }
405 if let Err(reason) = name_shape(&entry.to_schema) {
406 return Err(SchemaLoadError::InvalidCrossMemToSchema {
407 value: entry.to_schema.clone(),
408 reason: reason.into(),
409 });
410 }
411 if !seen_to_schemas.insert(entry.to_schema.clone()) {
412 return Err(SchemaLoadError::DuplicateCrossMemToSchema {
413 to_schema: entry.to_schema.clone(),
414 });
415 }
416 for def in &entry.definitions {
417 for t in &def.source_types {
418 if !manifest.types.iter().any(|d| d == t) {
419 return Err(SchemaLoadError::UndeclaredCrossMemSourceType {
420 to_schema: entry.to_schema.clone(),
421 relationship: def.name.clone(),
422 reference: t.clone(),
423 declared: manifest.types.clone(),
424 });
425 }
426 }
427 }
428 }
429
430 let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
432 found_stems.sort();
433 let mut declared = manifest.types.clone();
434 declared.sort();
435 if found_stems != declared {
436 return Err(SchemaLoadError::TypeFileMismatch {
437 declared,
438 found: found_stems,
439 });
440 }
441
442 let defaults: IndexMap<String, f32> = manifest
444 .relationships
445 .definitions
446 .iter()
447 .map(|d| (d.name.clone(), d.default_weight))
448 .collect();
449
450 let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
451
452 for (stem, text) in types_yamls {
453 let type_path = types_dir
454 .map(|d| d.join(format!("{stem}.yaml")))
455 .unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
456
457 let mut td: TypeDefinition =
458 serde_yaml_ng::from_str(text).map_err(|e| SchemaLoadError::ParseType {
459 path: type_path.clone(),
460 source: e,
461 })?;
462
463 if td.name != *stem {
464 return Err(SchemaLoadError::TypeNameMismatch {
465 file: stem.clone(),
466 declared: td.name.clone(),
467 });
468 }
469
470 for field in &td.metadata_fields {
475 if reserved_metadata_field_keys().contains(&field.key.as_str()) {
476 return Err(SchemaLoadError::ReservedSchemaKey {
477 type_name: td.name.clone(),
478 kind: "metadata_field",
479 offending_key: field.key.clone(),
480 reserved_keys: reserved_metadata_field_keys()
481 .iter()
482 .map(|s| s.to_string())
483 .collect(),
484 });
485 }
486 }
487
488 for field in &td.metadata_fields {
493 if base_metadata::is_base_key(&field.key)
494 && !reserved_metadata_field_keys().contains(&field.key.as_str())
495 {
496 return Err(SchemaLoadError::RedeclaredBaseField {
497 type_name: td.name.clone(),
498 field: field.key.clone(),
499 });
500 }
501 }
502
503 let mut merged = base_metadata::prefix_fields();
506 merged.append(&mut td.metadata_fields);
507 merged.extend(base_metadata::suffix_fields());
508 td.metadata_fields = merged;
509
510 validate_type(&td, &rel_names, &available_rels)?;
511
512 let mut weights = defaults.clone();
514 for (k, v) in &td.edge_weight_overrides {
515 weights.insert(k.clone(), *v);
516 }
517 td.edge_weights = weights;
518
519 types_map.insert(stem.clone(), Arc::new(td));
520 }
521
522 Ok(Schema {
523 manifest,
524 version,
525 types: types_map,
526 })
527}
528
529fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
530 name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
531 value: name.into(),
532 reason,
533 })
534}
535
536pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
542 name_shape(name)
543}
544
545fn name_shape(name: &str) -> Result<(), &'static str> {
550 if name.is_empty() {
551 return Err("must not be empty");
552 }
553 let mut chars = name.chars();
554 let first = chars.next().unwrap();
555 if !first.is_ascii_lowercase() {
556 return Err("must start with a lowercase letter");
557 }
558 for c in chars {
559 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
560 return Err("must contain only lowercase letters, digits, and hyphens");
561 }
562 }
563 Ok(())
564}
565
566fn validate_type(
567 td: &TypeDefinition,
568 rel_names: &HashSet<String>,
569 available_rels: &[String],
570) -> Result<(), SchemaLoadError> {
571 for section in &td.sections {
579 if reserved_section_keys().contains(§ion.key.as_str()) {
580 return Err(SchemaLoadError::ReservedSchemaKey {
581 type_name: td.name.clone(),
582 kind: "section",
583 offending_key: section.key.clone(),
584 reserved_keys: reserved_section_keys()
585 .iter()
586 .map(|s| s.to_string())
587 .collect(),
588 });
589 }
590 }
591
592 check_rel(
593 &td.name,
594 "hierarchy_relationship",
595 &td.hierarchy_relationship,
596 rel_names,
597 available_rels,
598 )?;
599 for r in &td.propagating_relationships {
600 check_rel(
601 &td.name,
602 "propagating_relationships",
603 r,
604 rel_names,
605 available_rels,
606 )?;
607 }
608 for r in td.edge_weight_overrides.keys() {
609 check_rel(
610 &td.name,
611 "edge_weight_overrides",
612 r,
613 rel_names,
614 available_rels,
615 )?;
616 }
617 for block in &td.required_outgoing {
618 for r in &block.relationships {
619 check_rel(&td.name, "required_outgoing", r, rel_names, available_rels)?;
620 }
621 }
622
623 let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
625 if catch_all_count != 1 {
626 return Err(SchemaLoadError::CatchAllViolation {
627 type_name: td.name.clone(),
628 count: catch_all_count,
629 });
630 }
631
632 let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
634 let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
635
636 for f in &td.text_fields {
637 if !section_keys.contains(f.as_str()) {
639 return Err(SchemaLoadError::UnknownFieldReference {
640 type_name: td.name.clone(),
641 field: "text_fields",
642 reference: f.clone(),
643 });
644 }
645 }
646 for f in &td.health_required_fields {
647 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
648 return Err(SchemaLoadError::UnknownFieldReference {
649 type_name: td.name.clone(),
650 field: "health_required_fields",
651 reference: f.clone(),
652 });
653 }
654 }
655 for f in &td.updatable_fields {
656 if f == "title" {
658 continue;
659 }
660 if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
661 return Err(SchemaLoadError::UnknownFieldReference {
662 type_name: td.name.clone(),
663 field: "updatable_fields",
664 reference: f.clone(),
665 });
666 }
667 }
668
669 for m in &td.metadata_fields {
671 if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
672 && !allowed.contains(default)
673 {
674 return Err(SchemaLoadError::DefaultValueNotInEnum {
675 type_name: td.name.clone(),
676 field: m.key.clone(),
677 default: default.clone(),
678 allowed: allowed.clone(),
679 });
680 }
681 }
682
683 Ok(())
684}
685
686fn check_rel(
687 type_name: &str,
688 field: &'static str,
689 relationship: &str,
690 rel_names: &HashSet<String>,
691 available: &[String],
692) -> Result<(), SchemaLoadError> {
693 if rel_names.contains(relationship) {
694 return Ok(());
695 }
696 Err(SchemaLoadError::UndeclaredRelationship {
697 type_name: type_name.into(),
698 field,
699 relationship: relationship.into(),
700 available: available.to_vec(),
701 })
702}