use std::path::{Path, PathBuf};
use crate::ontology_pack::{OntologyPack, parse_pack};
#[derive(Debug, Clone)]
pub struct RegistryValidation {
pub relationship_endpoint_orphans: Vec<String>,
pub subtype_of_orphans: Vec<String>,
pub registry_type_count: usize,
}
impl RegistryValidation {
#[must_use]
pub const fn ok(&self) -> bool {
self.relationship_endpoint_orphans.is_empty() && self.subtype_of_orphans.is_empty()
}
}
fn glob_one_level(dir: &Path, suffix: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|p| p.is_dir())
.filter_map(|subdir| {
std::fs::read_dir(&subdir).ok().map(|inner| {
inner
.flatten()
.map(|entry| entry.path())
.filter(|p| {
p.is_file()
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(suffix))
})
.collect::<Vec<_>>()
})
})
.flatten()
.collect();
paths.sort();
paths
}
fn load_packs(paths: &[PathBuf]) -> Vec<OntologyPack> {
paths
.iter()
.filter_map(|path| {
let contents = std::fs::read_to_string(path).ok()?;
parse_pack(&contents, &path.display().to_string()).ok()
})
.collect()
}
#[must_use]
pub fn validate_ontology_registry(root: &Path) -> RegistryValidation {
let core_layers = load_packs(&glob_one_level(&root.join("schemas/ontologies"), ".yaml"));
let domain_packs = load_packs(&glob_one_level(
&root.join("packs/ontologies"),
".ontology.yaml",
));
let mut registry_types: Vec<String> = core_layers
.iter()
.chain(&domain_packs)
.flat_map(|pack| pack.entity_types.iter().map(|et| et.name.clone()))
.collect();
registry_types.sort();
registry_types.dedup();
let mut subtype_of_orphans: Vec<String> = core_layers
.iter()
.chain(&domain_packs)
.flat_map(|pack| {
pack.entity_types
.iter()
.flat_map(|et| et.subtype_of.iter().cloned())
})
.filter(|parent| !registry_types.contains(parent))
.collect();
subtype_of_orphans.sort();
subtype_of_orphans.dedup();
let mut relationship_endpoint_orphans: Vec<String> = domain_packs
.iter()
.flat_map(|pack| pack.relationships.values())
.flat_map(|rel| rel.from.iter().chain(rel.to.iter()))
.filter(|endpoint| endpoint.as_str() != "*")
.filter(|endpoint| !registry_types.contains(*endpoint))
.cloned()
.collect();
relationship_endpoint_orphans.sort();
relationship_endpoint_orphans.dedup();
RegistryValidation {
relationship_endpoint_orphans,
subtype_of_orphans,
registry_type_count: registry_types.len(),
}
}
#[cfg(test)]
mod tests {
use super::validate_ontology_registry;
use std::fs;
fn write(dir: &std::path::Path, rel: &str, contents: &str) {
let path = dir.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
#[test]
fn passes_a_fully_resolved_registry() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"schemas/ontologies/engineering-base/0.1.0.yaml",
"
ontology:
id: engineering-base
version: \"0.1.0\"
entity_types:
- name: control
- name: component
relationships:
governs:
from: [control]
to: [component]
",
);
write(
dir.path(),
"packs/ontologies/software-security/software-security.ontology.yaml",
"
ontology:
id: software-security
version: \"0.1.0\"
entity_types:
- name: security-control
subtype_of: [control]
",
);
let result = validate_ontology_registry(dir.path());
assert!(result.ok(), "{result:?}");
assert_eq!(result.registry_type_count, 3);
}
#[test]
fn flags_a_relationship_endpoint_naming_no_registry_type() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"packs/ontologies/x/x.ontology.yaml",
"
ontology:
id: x
version: \"0.1.0\"
entity_types:
- name: a
relationships:
rel:
from: [a]
to: [does-not-exist]
",
);
let result = validate_ontology_registry(dir.path());
assert!(!result.ok());
assert_eq!(result.relationship_endpoint_orphans, vec!["does-not-exist"]);
assert!(result.subtype_of_orphans.is_empty());
}
#[test]
fn a_wildcard_relationship_endpoint_is_never_an_orphan() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"packs/ontologies/x/x.ontology.yaml",
"
ontology:
id: x
version: \"0.1.0\"
entity_types:
- name: a
relationships:
relates-to:
from: [a]
to: [\"*\"]
",
);
let result = validate_ontology_registry(dir.path());
assert!(result.ok(), "{result:?}");
}
#[test]
fn flags_a_subtype_of_parent_naming_no_registry_type() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"packs/ontologies/x/x.ontology.yaml",
"
ontology:
id: x
version: \"0.1.0\"
entity_types:
- name: a
subtype_of: [ghost-parent]
",
);
let result = validate_ontology_registry(dir.path());
assert!(!result.ok());
assert_eq!(result.subtype_of_orphans, vec!["ghost-parent"]);
assert!(result.relationship_endpoint_orphans.is_empty());
}
#[test]
fn a_relationship_endpoint_may_resolve_against_a_different_pack_cross_pack() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"schemas/ontologies/engineering-base/0.1.0.yaml",
"
ontology:
id: engineering-base
version: \"0.1.0\"
entity_types:
- name: component
",
);
write(
dir.path(),
"packs/ontologies/software-security/software-security.ontology.yaml",
"
ontology:
id: software-security
version: \"0.1.0\"
entity_types:
- name: security-control
relationships:
governs:
from: [security-control]
to: [component]
",
);
let result = validate_ontology_registry(dir.path());
assert!(result.ok(), "{result:?}");
}
}