use std::collections::{HashMap, HashSet};
use std::fmt;
use armour_core::{CollectionKind, OnMissing};
use super::registry::{BuiltRegistry, RefIn};
use crate::armour::db::Db;
#[derive(Debug, Clone, PartialEq)]
pub enum Finding {
DanglingFk {
collection: String,
key: String,
target: String,
brand: String,
id: u64,
on_missing: OnMissing,
},
IndexMissing {
source: String,
index: String,
key: String,
},
IndexOrphan {
index: String,
key: String,
},
IndexValueMismatch {
index: String,
key: String,
},
CounterMismatch {
collection: String,
key: String,
field: String,
stored: u64,
expected: u64,
},
DenormMissing {
source: String,
denorm: String,
key: String,
},
DenormMismatch {
source: String,
denorm: String,
key: String,
},
UnclassifiedCollection {
name: String,
},
UnvalidatedEdge {
from: String,
brand: String,
},
RegistryError {
message: String,
},
}
impl Finding {
pub fn is_error(&self) -> bool {
match self {
Finding::DanglingFk { on_missing, .. } => *on_missing == OnMissing::Error,
Finding::UnclassifiedCollection { .. } | Finding::UnvalidatedEdge { .. } => false,
_ => true,
}
}
}
#[derive(Debug, Default)]
pub struct ValidationReport {
pub findings: Vec<Finding>,
}
impl ValidationReport {
pub fn errors(&self) -> impl Iterator<Item = &Finding> {
self.findings.iter().filter(|f| f.is_error())
}
pub fn is_clean(&self) -> bool {
self.findings.is_empty()
}
}
impl fmt::Display for ValidationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.findings.is_empty() {
return writeln!(f, "schema validation: clean");
}
for finding in &self.findings {
writeln!(
f,
"{}: {finding:?}",
if finding.is_error() { "ERROR" } else { "WARN" }
)?;
}
Ok(())
}
}
pub(super) fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn validate(db: &Db, reg: &BuiltRegistry) -> ValidationReport {
let mut report = ValidationReport::default();
let known: HashSet<&str> = reg
.reg
.entries
.iter()
.map(|e| e.name)
.chain(reg.reg.external.iter().map(|(n, _)| *n))
.collect();
for name in db.collection_names() {
if !known.contains(name.as_str()) {
report
.findings
.push(Finding::UnclassifiedCollection { name });
}
}
let entry_map: HashMap<&str, &_> = reg.reg.entries.iter().map(|e| (e.name, e)).collect();
for entry in ®.reg.entries {
let mut unvalidated: HashSet<&'static str> = HashSet::new();
for (loc, r) in &entry.auto_refs {
if entry.kind == CollectionKind::Entity
&& *loc == RefIn::Key
&& entry.self_brand == Some(r.brand)
{
continue;
}
if !reg.brand_targets.contains_key(r.brand) {
unvalidated.insert(r.brand);
}
}
for brand in unvalidated {
report.findings.push(Finding::UnvalidatedEdge {
from: entry.name.to_string(),
brand: brand.to_string(),
});
}
let self_brand = (entry.kind == CollectionKind::Entity)
.then_some(entry.self_brand)
.flatten();
(entry.visit_ids)(&mut |key_bytes, loc, brand, id| {
if entry.kind == CollectionKind::Entity
&& loc == RefIn::Key
&& Some(brand) == self_brand
{
return; }
let Some(target_name) = reg.brand_targets.get(brand) else {
return; };
let target = entry_map.get(target_name).expect("brand target registered");
let Some(contains) = &target.contains_branded else {
return;
};
if !contains(id) {
report.findings.push(Finding::DanglingFk {
collection: entry.name.to_string(),
key: hex(key_bytes),
target: target_name.to_string(),
brand: brand.to_string(),
id,
on_missing: OnMissing::Error,
});
}
});
}
super::registry::run_decls(reg, &mut report);
report
}