use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use indexmap::IndexMap;
use thiserror::Error;
use crate::base_metadata;
use crate::manifest::SchemaManifest;
use crate::schema::Schema;
use crate::types::TypeDefinition;
#[derive(Debug, Error)]
pub enum SchemaLoadError {
#[error("i/o error reading {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse manifest {}: {source}", .path.display())]
ParseManifest {
path: PathBuf,
#[source]
source: serde_yaml_ng::Error,
},
#[error("failed to parse type file {}: {source}", .path.display())]
ParseType {
path: PathBuf,
#[source]
source: serde_yaml_ng::Error,
},
#[error("invalid version '{value}': must be semver (e.g. 1.0.0)")]
InvalidVersion { value: String },
#[error("invalid schema name '{value}': {reason}")]
InvalidName { value: String, reason: &'static str },
#[error(
"schema type file mismatch — declared in manifest: [{}], found in types/: [{}]",
declared.join(", "),
found.join(", ")
)]
TypeFileMismatch {
declared: Vec<String>,
found: Vec<String>,
},
#[error(
"type file '{file}.yaml' has `name: {declared}` — filename and `name` field must match"
)]
TypeNameMismatch { file: String, declared: String },
#[error("schema relationship vocabulary must include a '_default' definition")]
MissingDefaultWeight,
#[error("duplicate relationship definition: '{name}'")]
DuplicateRelationship { name: String },
#[error(
"type '{type_name}' references relationship '{relationship}' in field '{field}' — not declared in schema. Available: [{}]. {}",
available.join(", "),
format_suggestion(relationship, available)
)]
UndeclaredRelationship {
type_name: String,
field: &'static str,
relationship: String,
available: Vec<String>,
},
#[error(
"type '{type_name}' must have exactly one section with `catch_all: true` (found {count})"
)]
CatchAllViolation { type_name: String, count: usize },
#[error(
"type '{type_name}' field '{field}' references unknown key '{reference}' — not a section or metadata field"
)]
UnknownFieldReference {
type_name: String,
field: &'static str,
reference: String,
},
#[error(
"type '{type_name}' metadata field '{field}' default '{default}' is not listed in enum_values: [{}]",
allowed.join(", ")
)]
DefaultValueNotInEnum {
type_name: String,
field: String,
default: String,
allowed: Vec<String>,
},
#[error(
"type '{type_name}' redeclares engine-implicit metadata key '{field}' — remove it from the YAML; the loader injects it automatically"
)]
RedeclaredBaseField { type_name: String, field: String },
#[error(
"relationship '{relationship}' field '{field}' references unknown type '{reference}'. Declared types: [{}]. {}",
declared.join(", "),
format_suggestion(reference, declared)
)]
UndeclaredRelationshipType {
relationship: String,
field: &'static str,
reference: String,
declared: Vec<String>,
},
#[error(
"type '{type_name}' declares {kind} with reserved key '{offending_key}' — reserved keys: [{}]",
reserved_keys.join(", ")
)]
ReservedSchemaKey {
type_name: String,
kind: &'static str,
offending_key: String,
reserved_keys: Vec<String>,
},
#[error(
"cross_mem_relationships[].to_schema '{value}' {reason} — expected a bare schema name (e.g. 'software', not 'software@1.0.0')"
)]
InvalidCrossMemToSchema { value: String, reason: String },
#[error("cross_mem_relationships declares duplicate to_schema '{to_schema}'")]
DuplicateCrossMemToSchema { to_schema: String },
#[error(
"cross_mem_relationships[to_schema='{to_schema}'].definitions[name='{relationship}'].source_types references unknown type '{reference}'. Declared types: [{}]. {}",
declared.join(", "),
format_suggestion(reference, declared)
)]
UndeclaredCrossMemSourceType {
to_schema: String,
relationship: String,
reference: String,
declared: Vec<String>,
},
#[error(
"schema '{schema}' alias_target_rel_type '{target}' is not declared in relationships. Declared: [{}]. {}",
declared.join(", "),
format_suggestion(target, declared)
)]
AliasTargetRelTypeNotDeclared {
schema: String,
target: String,
declared: Vec<String>,
},
}
pub fn reserved_section_keys() -> &'static [&'static str] {
&["relationships"]
}
pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
&["type"]
}
fn format_suggestion(needle: &str, candidates: &[String]) -> String {
let mut best: Option<(usize, &String)> = None;
for cand in candidates {
let d = strsim::levenshtein(needle, cand);
match best {
Some((bd, _)) if bd <= d => {}
_ => best = Some((d, cand)),
}
}
match best {
Some((d, cand)) if d > 0 && d <= needle.len().saturating_add(3) => {
format!("Did you mean '{cand}'?")
}
_ => String::new(),
}
}
pub fn load_schema_from_dir(path: &Path) -> Result<Schema, SchemaLoadError> {
let manifest_path = path.join("schema.yaml");
let manifest_text =
std::fs::read_to_string(&manifest_path).map_err(|e| SchemaLoadError::Io {
path: manifest_path.clone(),
source: e,
})?;
let types_dir = path.join("types");
let mut type_files: Vec<(String, String)> = Vec::new();
if types_dir.is_dir() {
let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaLoadError::Io {
path: types_dir.clone(),
source: e,
})?;
for entry in entries {
let entry = entry.map_err(|e| SchemaLoadError::Io {
path: types_dir.clone(),
source: e,
})?;
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("yaml") {
continue;
}
let Some(stem) = p.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
continue;
};
let contents = std::fs::read_to_string(&p).map_err(|e| SchemaLoadError::Io {
path: p.clone(),
source: e,
})?;
type_files.push((stem, contents));
}
}
load_with_context(
&manifest_text,
&type_files,
Some(&manifest_path),
Some(&types_dir),
)
}
pub fn load_schema_from_memory(
manifest_yaml: &str,
types_yamls: &[(String, String)],
) -> Result<Schema, SchemaLoadError> {
load_with_context(manifest_yaml, types_yamls, None, None)
}
fn load_with_context(
manifest_yaml: &str,
types_yamls: &[(String, String)],
manifest_path: Option<&Path>,
types_dir: Option<&Path>,
) -> Result<Schema, SchemaLoadError> {
let mut manifest: SchemaManifest =
serde_yaml_ng::from_str(manifest_yaml).map_err(|e| SchemaLoadError::ParseManifest {
path: manifest_path
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("<memory>")),
source: e,
})?;
validate_name(&manifest.name)?;
let version =
semver::Version::parse(&manifest.version).map_err(|_| SchemaLoadError::InvalidVersion {
value: manifest.version.clone(),
})?;
let mut rel_names: HashSet<String> = HashSet::new();
for def in &manifest.relationships.definitions {
if !rel_names.insert(def.name.clone()) {
return Err(SchemaLoadError::DuplicateRelationship {
name: def.name.clone(),
});
}
}
if !rel_names.contains("_default") {
return Err(SchemaLoadError::MissingDefaultWeight);
}
let available_rels: Vec<String> = manifest
.relationships
.definitions
.iter()
.map(|d| d.name.clone())
.collect();
if let Some(target) = &manifest.alias_target_rel_type
&& !rel_names.contains(target)
{
let mut declared = available_rels.clone();
declared.sort();
return Err(SchemaLoadError::AliasTargetRelTypeNotDeclared {
schema: manifest.name.clone(),
target: target.clone(),
declared,
});
}
if let Some(pointer) = manifest.alias_target_rel_type.clone() {
for def in &mut manifest.relationships.definitions {
if def.name == pointer {
def.manual_authoring = crate::manifest::ManualAuthoring::Forbidden;
}
}
}
for def in &manifest.relationships.definitions {
for t in &def.source_types {
if !manifest.types.iter().any(|d| d == t) {
return Err(SchemaLoadError::UndeclaredRelationshipType {
relationship: def.name.clone(),
field: "source_types",
reference: t.clone(),
declared: manifest.types.clone(),
});
}
}
for t in &def.target_types {
if !manifest.types.iter().any(|d| d == t) {
return Err(SchemaLoadError::UndeclaredRelationshipType {
relationship: def.name.clone(),
field: "target_types",
reference: t.clone(),
declared: manifest.types.clone(),
});
}
}
}
let mut seen_to_schemas: HashSet<String> = HashSet::new();
for entry in &manifest.cross_mem_relationships {
if entry.to_schema.contains('@') {
return Err(SchemaLoadError::InvalidCrossMemToSchema {
value: entry.to_schema.clone(),
reason: "must not carry a version or range".into(),
});
}
if let Err(reason) = name_shape(&entry.to_schema) {
return Err(SchemaLoadError::InvalidCrossMemToSchema {
value: entry.to_schema.clone(),
reason: reason.into(),
});
}
if !seen_to_schemas.insert(entry.to_schema.clone()) {
return Err(SchemaLoadError::DuplicateCrossMemToSchema {
to_schema: entry.to_schema.clone(),
});
}
for def in &entry.definitions {
for t in &def.source_types {
if !manifest.types.iter().any(|d| d == t) {
return Err(SchemaLoadError::UndeclaredCrossMemSourceType {
to_schema: entry.to_schema.clone(),
relationship: def.name.clone(),
reference: t.clone(),
declared: manifest.types.clone(),
});
}
}
}
}
let mut found_stems: Vec<String> = types_yamls.iter().map(|(s, _)| s.clone()).collect();
found_stems.sort();
let mut declared = manifest.types.clone();
declared.sort();
if found_stems != declared {
return Err(SchemaLoadError::TypeFileMismatch {
declared,
found: found_stems,
});
}
let defaults: IndexMap<String, f32> = manifest
.relationships
.definitions
.iter()
.map(|d| (d.name.clone(), d.default_weight))
.collect();
let mut types_map: HashMap<String, Arc<TypeDefinition>> = HashMap::new();
for (stem, text) in types_yamls {
let type_path = types_dir
.map(|d| d.join(format!("{stem}.yaml")))
.unwrap_or_else(|| PathBuf::from(format!("<memory>/{stem}.yaml")));
let mut td: TypeDefinition =
serde_yaml_ng::from_str(text).map_err(|e| SchemaLoadError::ParseType {
path: type_path.clone(),
source: e,
})?;
if td.name != *stem {
return Err(SchemaLoadError::TypeNameMismatch {
file: stem.clone(),
declared: td.name.clone(),
});
}
for field in &td.metadata_fields {
if reserved_metadata_field_keys().contains(&field.key.as_str()) {
return Err(SchemaLoadError::ReservedSchemaKey {
type_name: td.name.clone(),
kind: "metadata_field",
offending_key: field.key.clone(),
reserved_keys: reserved_metadata_field_keys()
.iter()
.map(|s| s.to_string())
.collect(),
});
}
}
for field in &td.metadata_fields {
if base_metadata::is_base_key(&field.key)
&& !reserved_metadata_field_keys().contains(&field.key.as_str())
{
return Err(SchemaLoadError::RedeclaredBaseField {
type_name: td.name.clone(),
field: field.key.clone(),
});
}
}
let mut merged = base_metadata::prefix_fields();
merged.append(&mut td.metadata_fields);
merged.extend(base_metadata::suffix_fields());
td.metadata_fields = merged;
validate_type(&td, &rel_names, &available_rels)?;
let mut weights = defaults.clone();
for (k, v) in &td.edge_weight_overrides {
weights.insert(k.clone(), *v);
}
td.edge_weights = weights;
types_map.insert(stem.clone(), Arc::new(td));
}
Ok(Schema {
manifest,
version,
types: types_map,
})
}
fn validate_name(name: &str) -> Result<(), SchemaLoadError> {
name_shape(name).map_err(|reason| SchemaLoadError::InvalidName {
value: name.into(),
reason,
})
}
pub fn validate_schema_name(name: &str) -> Result<(), &'static str> {
name_shape(name)
}
fn name_shape(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("must not be empty");
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_lowercase() {
return Err("must start with a lowercase letter");
}
for c in chars {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return Err("must contain only lowercase letters, digits, and hyphens");
}
}
Ok(())
}
fn validate_type(
td: &TypeDefinition,
rel_names: &HashSet<String>,
available_rels: &[String],
) -> Result<(), SchemaLoadError> {
for section in &td.sections {
if reserved_section_keys().contains(§ion.key.as_str()) {
return Err(SchemaLoadError::ReservedSchemaKey {
type_name: td.name.clone(),
kind: "section",
offending_key: section.key.clone(),
reserved_keys: reserved_section_keys()
.iter()
.map(|s| s.to_string())
.collect(),
});
}
}
check_rel(
&td.name,
"hierarchy_relationship",
&td.hierarchy_relationship,
rel_names,
available_rels,
)?;
for r in &td.propagating_relationships {
check_rel(
&td.name,
"propagating_relationships",
r,
rel_names,
available_rels,
)?;
}
for r in td.edge_weight_overrides.keys() {
check_rel(
&td.name,
"edge_weight_overrides",
r,
rel_names,
available_rels,
)?;
}
for block in &td.required_outgoing {
for r in &block.relationships {
check_rel(&td.name, "required_outgoing", r, rel_names, available_rels)?;
}
}
let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
if catch_all_count != 1 {
return Err(SchemaLoadError::CatchAllViolation {
type_name: td.name.clone(),
count: catch_all_count,
});
}
let section_keys: HashSet<&str> = td.sections.iter().map(|s| s.key.as_str()).collect();
let meta_keys: HashSet<&str> = td.metadata_fields.iter().map(|m| m.key.as_str()).collect();
for f in &td.text_fields {
if !section_keys.contains(f.as_str()) {
return Err(SchemaLoadError::UnknownFieldReference {
type_name: td.name.clone(),
field: "text_fields",
reference: f.clone(),
});
}
}
for f in &td.health_required_fields {
if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
return Err(SchemaLoadError::UnknownFieldReference {
type_name: td.name.clone(),
field: "health_required_fields",
reference: f.clone(),
});
}
}
for f in &td.updatable_fields {
if f == "title" {
continue;
}
if !section_keys.contains(f.as_str()) && !meta_keys.contains(f.as_str()) {
return Err(SchemaLoadError::UnknownFieldReference {
type_name: td.name.clone(),
field: "updatable_fields",
reference: f.clone(),
});
}
}
for m in &td.metadata_fields {
if let (Some(default), Some(allowed)) = (m.default_value.as_ref(), m.enum_values.as_ref())
&& !allowed.contains(default)
{
return Err(SchemaLoadError::DefaultValueNotInEnum {
type_name: td.name.clone(),
field: m.key.clone(),
default: default.clone(),
allowed: allowed.clone(),
});
}
}
Ok(())
}
fn check_rel(
type_name: &str,
field: &'static str,
relationship: &str,
rel_names: &HashSet<String>,
available: &[String],
) -> Result<(), SchemaLoadError> {
if rel_names.contains(relationship) {
return Ok(());
}
Err(SchemaLoadError::UndeclaredRelationship {
type_name: type_name.into(),
field,
relationship: relationship.into(),
available: available.to_vec(),
})
}