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(
"type '{type_name}': `propagating_relationships` was renamed — its only effect is \
refusing self-loops on the listed rel-types, so the key is now \
`no_self_loop_relationships` (optional; empty lists can simply be deleted). \
Rename the key and retry."
)]
PropagatingRelationshipsRenamed { type_name: String },
#[error(
"type '{type_name}' declares the retired `examples:` list — it was never \
validated nor served and is replaced by the engine-validated `exemplar:` \
(one canonical entity: title, metadata, sections, relations with \
placeholder targets). Move the material into `exemplar:` and retry."
)]
ExamplesRetired { type_name: String },
#[error(
"type '{type_name}' metadata field '{field}' declares the retired `optional:` key — \
fields are optional unless they declare `required: true`. Fix: delete `optional: true`; \
replace `optional: false` with `required: true`. Then retry."
)]
OptionalRetired { type_name: String, field: String },
#[error("type '{type_name}' due axis is invalid: {reason} — offending name: '{offender}'")]
InvalidDueAxis {
type_name: String,
offender: String,
reason: 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}' constraint ({kind}) is invalid: {reason} — offending name: '{offender}'"
)]
InvalidConstraint {
type_name: String,
kind: &'static str,
offender: String,
reason: String,
},
#[error(
"type '{type_name}' section '{section}' format declaration is invalid: {}",
problems.join("; ")
)]
InvalidSectionFormat {
type_name: String,
section: String,
problems: Vec<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 declares to_schema '*' but the schema declares no \
alias_target_rel_type — the wildcard is bound to the alias-synthesised rel-type; \
declare alias_target_rel_type, or name each destination schema explicitly"
)]
CrossMemWildcardWithoutAliasTarget,
#[error(
"cross_mem_relationships[to_schema='*'] declares rel-type '{rel_type}', but the \
wildcard is bound to the schema's alias_target_rel_type '{alias_target}' — \
hand-authored structural edges need a per-destination-schema declaration"
)]
CrossMemWildcardNonAliasRelType {
rel_type: String,
alias_target: 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>,
},
#[error(
"schema declares section heading(s) that cannot round-trip to their key(s): {}. \
Fix: make each heading derive to its key — lowercasing the heading and replacing \
spaces with underscores must yield the key exactly (key `current_state` ⇒ heading \
`Current State`)",
format_heading_violations(violations)
)]
SectionHeadingMismatch {
violations: Vec<HeadingKeyViolation>,
},
#[error(
"schema has {} violations:\n{}",
errors.len(),
format_multiple(errors)
)]
Multiple { errors: Vec<SchemaLoadError> },
}
fn format_multiple(errors: &[SchemaLoadError]) -> String {
errors
.iter()
.enumerate()
.map(|(i, e)| format!(" {}. {e}", i + 1))
.collect::<Vec<_>>()
.join("\n")
}
fn collapse(mut errors: Vec<SchemaLoadError>) -> SchemaLoadError {
debug_assert!(!errors.is_empty());
if errors.len() == 1 {
errors.remove(0)
} else {
SchemaLoadError::Multiple { errors }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadingKeyViolation {
pub type_name: String,
pub key: String,
pub heading: String,
pub derived_key: String,
}
fn format_heading_violations(violations: &[HeadingKeyViolation]) -> String {
violations
.iter()
.map(|v| {
format!(
"type '{}' section key '{}' has heading '{}' (derives to '{}')",
v.type_name, v.key, v.heading, v.derived_key
)
})
.collect::<Vec<_>>()
.join("; ")
}
pub fn check_section_heading_roundtrip(schema: &Schema) -> Result<(), SchemaLoadError> {
let mut violations = Vec::new();
let mut type_names: Vec<&String> = schema.types.keys().collect();
type_names.sort();
for type_name in type_names {
let t = &schema.types[type_name];
for s in &t.sections {
let derived_key = crate::types::derive_section_key(&s.heading);
if derived_key != s.key {
violations.push(HeadingKeyViolation {
type_name: type_name.clone(),
key: s.key.clone(),
heading: s.heading.clone(),
derived_key,
});
}
}
}
if violations.is_empty() {
Ok(())
} else {
Err(SchemaLoadError::SectionHeadingMismatch { violations })
}
}
pub fn reserved_section_keys() -> &'static [&'static str] {
&["relationships"]
}
pub fn reserved_metadata_field_keys() -> &'static [&'static str] {
&["type", "mem", "id"]
}
pub fn check_reserved_metadata_keys(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
for td in schema.types.values() {
for key in &td.declared_metadata_keys {
if reserved_metadata_field_keys().contains(&key.as_str()) {
return Err(SchemaLoadError::ReservedSchemaKey {
type_name: td.name.clone(),
kind: "metadata_field",
offending_key: key.clone(),
reserved_keys: reserved_metadata_field_keys()
.iter()
.map(|s| s.to_string())
.collect(),
});
}
}
}
Ok(())
}
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));
}
}
type_files.sort_by(|a, b| a.0.cmp(&b.0));
load_with_context(
&manifest_text,
&type_files,
Some(&manifest_path),
Some(&types_dir),
MetadataPolarityFormat::RequiredOptIn,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataPolarityFormat {
Legacy,
RequiredOptIn,
}
pub const SCHEMA_FORMAT_MARKER_FILE: &str = "schema-format.json";
pub const SCHEMA_FORMAT_MARKER_CONTENT: &str = "{\"metadata_polarity\":\"required-opt-in\"}\n";
pub fn with_format_marker(mut files: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
if !files
.iter()
.any(|(rel, _)| rel == SCHEMA_FORMAT_MARKER_FILE)
{
files.push((
SCHEMA_FORMAT_MARKER_FILE.to_string(),
SCHEMA_FORMAT_MARKER_CONTENT.as_bytes().to_vec(),
));
}
files
}
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,
MetadataPolarityFormat::Legacy,
)
}
pub fn load_schema_from_memory_with_format(
manifest_yaml: &str,
types_yamls: &[(String, String)],
format: MetadataPolarityFormat,
) -> Result<Schema, SchemaLoadError> {
load_with_context(manifest_yaml, types_yamls, None, None, format)
}
fn load_with_context(
manifest_yaml: &str,
types_yamls: &[(String, String)],
manifest_path: Option<&Path>,
types_dir: Option<&Path>,
format: MetadataPolarityFormat,
) -> Result<Schema, SchemaLoadError> {
let mut errors: Vec<SchemaLoadError> = Vec::new();
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,
})?;
if let Err(e) = validate_name(&manifest.name) {
errors.push(e);
}
let version = match semver::Version::parse(&manifest.version) {
Ok(v) => Some(v),
Err(_) => {
errors.push(SchemaLoadError::InvalidVersion {
value: manifest.version.clone(),
});
None
}
};
let mut rel_names: HashSet<String> = HashSet::new();
for def in &manifest.relationships.definitions {
if !rel_names.insert(def.name.clone()) {
errors.push(SchemaLoadError::DuplicateRelationship {
name: def.name.clone(),
});
}
}
if !rel_names.contains("_default") {
errors.push(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();
errors.push(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) {
errors.push(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) {
errors.push(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 == "*" {
match manifest.alias_target_rel_type.as_deref() {
None => errors.push(SchemaLoadError::CrossMemWildcardWithoutAliasTarget),
Some(alias) => {
for def in &entry.definitions {
if def.name != alias {
errors.push(SchemaLoadError::CrossMemWildcardNonAliasRelType {
rel_type: def.name.clone(),
alias_target: alias.to_string(),
});
}
}
}
}
} else if entry.to_schema.contains('@') {
errors.push(SchemaLoadError::InvalidCrossMemToSchema {
value: entry.to_schema.clone(),
reason: "must not carry a version or range".into(),
});
} else if let Err(reason) = name_shape(&entry.to_schema) {
errors.push(SchemaLoadError::InvalidCrossMemToSchema {
value: entry.to_schema.clone(),
reason: reason.into(),
});
}
if !seen_to_schemas.insert(entry.to_schema.clone()) {
errors.push(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) {
errors.push(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 {
errors.push(SchemaLoadError::TypeFileMismatch {
declared,
found: found_stems,
});
return Err(collapse(errors));
}
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();
let mut had_type_parse_failure = false;
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 = match serde_yaml_ng::from_str(text) {
Ok(td) => td,
Err(e) => {
errors.push(SchemaLoadError::ParseType {
path: type_path.clone(),
source: e,
});
had_type_parse_failure = true;
continue;
}
};
if td.name != *stem {
errors.push(SchemaLoadError::TypeNameMismatch {
file: stem.clone(),
declared: td.name.clone(),
});
}
if let Some(legacy) = td.legacy_propagating_relationships.take() {
if types_dir.is_some() {
errors.push(SchemaLoadError::PropagatingRelationshipsRenamed {
type_name: td.name.clone(),
});
} else if td.no_self_loop_relationships.is_empty() {
td.no_self_loop_relationships = legacy;
}
}
if td.legacy_examples.take().is_some() && types_dir.is_some() {
errors.push(SchemaLoadError::ExamplesRetired {
type_name: td.name.clone(),
});
}
for field in &mut td.metadata_fields {
if matches!(format, MetadataPolarityFormat::RequiredOptIn)
&& field.legacy_optional.is_some()
{
errors.push(SchemaLoadError::OptionalRetired {
type_name: td.name.clone(),
field: field.key.clone(),
});
}
field.required_resolved = match (field.required, field.legacy_optional.take()) {
(Some(required), _) => required,
(None, Some(optional)) => !optional,
(None, None) => matches!(format, MetadataPolarityFormat::Legacy),
};
}
td.declared_metadata_keys = td.metadata_fields.iter().map(|f| f.key.clone()).collect();
for field in &td.metadata_fields {
if base_metadata::is_base_key(&field.key)
&& !reserved_metadata_field_keys().contains(&field.key.as_str())
{
errors.push(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;
compile_section_formats(&mut td);
validate_type(&td, &rel_names, &available_rels, &mut errors);
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));
}
if !had_type_parse_failure {
let all_section_keys: HashSet<&str> = types_map
.values()
.flat_map(|t| t.sections.iter().map(|s| s.key.as_str()))
.collect();
let mut type_names: Vec<&String> = types_map.keys().collect();
type_names.sort();
for type_name in type_names {
let td = &types_map[type_name];
for c in &td.constraints {
if let crate::types::ConstraintDef::EnumFromNeighbour { section, .. } = c
&& !all_section_keys.contains(section.as_str())
{
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "enum_from_neighbour",
offender: section.clone(),
reason: "`section` names a section key no type of this schema declares"
.to_string(),
});
}
}
}
}
if !errors.is_empty() {
return Err(collapse(errors));
}
Ok(Schema {
manifest,
version: version.expect("version parse failure would have accumulated an error"),
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 compile_section_formats(td: &mut TypeDefinition) {
use crate::content_expr::ContentExpr;
for section in &mut td.sections {
let declares_any = section.content.is_some()
|| section.item_pattern.is_some()
|| section.table.is_some()
|| section.example.is_some()
|| section.format_severity != crate::types::ConstraintSeverity::Block;
if !declares_any {
continue;
}
let mut problems: Vec<String> = Vec::new();
let compiled = match §ion.content {
None => {
problems.push(
"`item_pattern` / `table` / `example` require a `content` declaration"
.to_string(),
);
None
}
Some(expr_src) => match ContentExpr::parse(expr_src) {
Ok(expr) => Some(expr),
Err(e) => {
problems.push(format!("`content` is invalid: {e}"));
None
}
},
};
if let Some(pattern) = §ion.item_pattern {
if let Err(e) = regex::Regex::new(pattern) {
problems.push(format!("`item_pattern` is not a valid regex: {e}"));
}
if let Some(expr) = &compiled {
let names = expr.mentioned_names();
let has_list = names.contains(&"list");
let has_paragraph = names.contains(&"paragraph");
if has_list == has_paragraph {
problems.push(
"`item_pattern` requires a `content` expression containing exactly one of `list` / `paragraph` (tables use `column_patterns`)"
.to_string(),
);
}
}
}
if let Some(table) = §ion.table {
if let Some(expr) = &compiled
&& !expr.mentioned_names().contains(&"table")
{
problems.push(
"`table` block is only legal when `content` contains `table`".to_string(),
);
}
if table.columns.is_empty() {
problems.push("`table.columns` must name at least one column".to_string());
}
for (column, pattern) in &table.column_patterns {
if !table.columns.contains(column) {
problems.push(format!(
"`column_patterns` names '{column}', which is not in `columns`"
));
}
if let Err(e) = regex::Regex::new(pattern) {
problems.push(format!(
"`column_patterns.{column}` is not a valid regex: {e}"
));
}
}
}
if problems.is_empty() {
section.compiled_content = compiled;
} else {
section.format_problems = problems;
}
}
}
pub fn check_section_formats(schema: &crate::Schema) -> Result<(), SchemaLoadError> {
let mut first: Option<(String, String)> = None;
let mut problems: Vec<String> = Vec::new();
for td in schema.types.values() {
for section in &td.sections {
if section.format_problems.is_empty() {
continue;
}
if first.is_none() {
first = Some((td.name.clone(), section.key.clone()));
problems.extend(section.format_problems.iter().cloned());
} else {
problems.extend(
section
.format_problems
.iter()
.map(|p| format!("[{}.{}] {p}", td.name, section.key)),
);
}
}
}
match first {
Some((type_name, section)) => Err(SchemaLoadError::InvalidSectionFormat {
type_name,
section,
problems,
}),
None => Ok(()),
}
}
fn validate_type(
td: &TypeDefinition,
rel_names: &HashSet<String>,
available_rels: &[String],
errors: &mut Vec<SchemaLoadError>,
) {
for section in &td.sections {
if reserved_section_keys().contains(§ion.key.as_str()) {
errors.push(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(),
});
}
}
if let Err(e) = check_rel(
&td.name,
"hierarchy_relationship",
&td.hierarchy_relationship,
rel_names,
available_rels,
) {
errors.push(e);
}
for r in &td.no_self_loop_relationships {
if let Err(e) = check_rel(
&td.name,
"no_self_loop_relationships",
r,
rel_names,
available_rels,
) {
errors.push(e);
}
}
for r in td.edge_weight_overrides.keys() {
if let Err(e) = check_rel(
&td.name,
"edge_weight_overrides",
r,
rel_names,
available_rels,
) {
errors.push(e);
}
}
for block in &td.required_outgoing {
for r in &block.relationships {
if let Err(e) = check_rel(&td.name, "required_outgoing", r, rel_names, available_rels) {
errors.push(e);
}
}
}
let field_keys: std::collections::HashSet<&str> =
td.metadata_fields.iter().map(|f| f.key.as_str()).collect();
let section_keys: std::collections::HashSet<&str> =
td.sections.iter().map(|sec| sec.key.as_str()).collect();
for c in &td.constraints {
match c {
crate::types::ConstraintDef::RequiresWhen {
field,
when_field,
when_value,
..
} => {
if !field_keys.contains(field.as_str()) && !section_keys.contains(field.as_str()) {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "requires_when",
offender: field.clone(),
reason: "`field` names neither a metadata field nor a section of this type"
.to_string(),
});
}
let Some(when_def) = td.metadata_fields.iter().find(|f| f.key == *when_field)
else {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "requires_when",
offender: when_field.clone(),
reason: "`when_field` names no metadata field of this type".to_string(),
});
continue;
};
if let Some(allowed) = &when_def.enum_values
&& !allowed.contains(when_value)
{
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "requires_when",
offender: when_value.clone(),
reason: format!(
"`when_value` is not in `{when_field}`'s enum_values [{}]",
allowed.join(", ")
),
});
}
}
crate::types::ConstraintDef::Unique { fields, .. } => {
if fields.is_empty() {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "unique",
offender: "(empty)".to_string(),
reason: "`fields` must name at least one metadata field".to_string(),
});
}
for f in fields {
if !field_keys.contains(f.as_str()) {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "unique",
offender: f.clone(),
reason: "`fields` entry names no metadata field of this type"
.to_string(),
});
}
}
}
crate::types::ConstraintDef::EnumFromNeighbour {
field, rel_type, ..
} => {
if !field_keys.contains(field.as_str()) {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "enum_from_neighbour",
offender: field.clone(),
reason: "`field` names no metadata field of this type".to_string(),
});
}
if !rel_names.contains(rel_type) {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "enum_from_neighbour",
offender: rel_type.clone(),
reason: "`rel_type` is not in the schema's relationship vocabulary"
.to_string(),
});
}
}
crate::types::ConstraintDef::StatusPropagation {
field,
value,
rel_type,
severity,
..
} => {
match td.metadata_fields.iter().find(|f| f.key == *field) {
None => {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "status_propagation",
offender: field.clone(),
reason: "`field` names no metadata field of this type".to_string(),
});
}
Some(field_def) => {
if let Some(allowed) = &field_def.enum_values
&& !allowed.contains(value)
{
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "status_propagation",
offender: value.clone(),
reason: format!(
"`value` is not in `{field}`'s enum_values [{}]",
allowed.join(", ")
),
});
}
}
}
if !rel_names.contains(rel_type) {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "status_propagation",
offender: rel_type.clone(),
reason: "`rel_type` is not in the schema's relationship vocabulary"
.to_string(),
});
}
if *severity == crate::types::ConstraintSeverity::Block {
errors.push(SchemaLoadError::InvalidConstraint {
type_name: td.name.clone(),
kind: "status_propagation",
offender: "block".to_string(),
reason: "status_propagation is always warn-tier — a parent falling after \
the child was written cannot retroactively make the child's \
write illegal"
.to_string(),
});
}
}
}
}
if let Some(due) = &td.due {
match td.metadata_fields.iter().find(|f| f.key == due.date_field) {
None => errors.push(SchemaLoadError::InvalidDueAxis {
type_name: td.name.clone(),
offender: due.date_field.clone(),
reason: "`date_field` names no metadata field of this type".to_string(),
}),
Some(f) if f.field_type != crate::types::FieldType::Date => {
errors.push(SchemaLoadError::InvalidDueAxis {
type_name: td.name.clone(),
offender: due.date_field.clone(),
reason: "`date_field` must name a date-typed metadata field".to_string(),
})
}
Some(_) => {}
}
match td
.metadata_fields
.iter()
.find(|f| f.key == due.status_field)
{
None => errors.push(SchemaLoadError::InvalidDueAxis {
type_name: td.name.clone(),
offender: due.status_field.clone(),
reason: "`status_field` names no metadata field of this type".to_string(),
}),
Some(f) => match &f.enum_values {
None => errors.push(SchemaLoadError::InvalidDueAxis {
type_name: td.name.clone(),
offender: due.status_field.clone(),
reason: "`status_field` must name an enum-typed metadata field \
(declare enum_values)"
.to_string(),
}),
Some(allowed) => {
for v in &due.open_values {
if !allowed.contains(v) {
errors.push(SchemaLoadError::InvalidDueAxis {
type_name: td.name.clone(),
offender: v.clone(),
reason: format!(
"`open_values` entry is not in `{}`'s enum_values [{}]",
due.status_field,
allowed.join(", ")
),
});
}
}
}
},
}
if due.open_values.is_empty() {
errors.push(SchemaLoadError::InvalidDueAxis {
type_name: td.name.clone(),
offender: "(empty)".to_string(),
reason: "`open_values` must name at least one open status value".to_string(),
});
}
if let Some(lead) = &due.lead_section
&& !td.sections.iter().any(|s| s.key == *lead)
{
errors.push(SchemaLoadError::InvalidDueAxis {
type_name: td.name.clone(),
offender: lead.clone(),
reason: "`lead_section` names no section of this type".to_string(),
});
}
}
let catch_all_count = td.sections.iter().filter(|s| s.catch_all).count();
if catch_all_count != 1 {
errors.push(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()) {
errors.push(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()) {
errors.push(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()) {
errors.push(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)
{
errors.push(SchemaLoadError::DefaultValueNotInEnum {
type_name: td.name.clone(),
field: m.key.clone(),
default: default.clone(),
allowed: allowed.clone(),
});
}
}
}
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(),
})
}