armdb 0.4.1

sharded bitcask key-value storage optimized for NVMe
Documentation
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,
        /// hex of the source record key
        key: String,
        target: String,
        /// Brand for auto-FK; explicit `fk()` declarations store the label here instead.
        brand: String,
        /// Native id for auto-FK; explicit `fk()` declarations use 0.
        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,
    },
    /// auto-FK edge that cannot be validated (brand without entity target or
    /// target with composite key without explicit fk).
    UnvalidatedEdge {
        from: String,
        brand: String,
    },
    /// Registry usage error (type mismatch in declaration).
    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()
}

/// `db` is only needed for completeness (names of open collections);
/// data is read through handles captured at registration.
pub fn validate(db: &Db, reg: &BuiltRegistry) -> ValidationReport {
    let mut report = ValidationReport::default();

    // 5. Completeness: every open collection is classified.
    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();

    // 1. Auto-FK: dangling + unvalidated.
    for entry in &reg.reg.entries {
        // brands without entity target — one UnvalidatedEdge per (from, brand)
        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; // entity's own branded key, not a value-field FK
            }
            let Some(target_name) = reg.brand_targets.get(brand) else {
                return; // already recorded as UnvalidatedEdge
            };
            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,
                });
            }
        });
    }

    // 2-4. Declarations (Index/Fk/Denorm/Counter) — Task 9/10/11.
    super::registry::run_decls(reg, &mut report);

    report
}