//! Local registry mapping `OntologyId → local_path + classification`. //! //! Phase 1 (D28) persists this as a JSON document inside //! `.ontoref/ontology_registry.ncl`. Real NCL contract enforcement //! arrives once the catalog/ schema for the registry is wired in; //! parsing the registry by hand keeps O1.8 self-contained. use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::classification::OntologyType; use crate::ontology_id::OntologyId; use crate::{Error, Result}; /// One registry entry — an ontology this actor knows about locally. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryEntry { /// Content address of the ontology. pub ontology_id: OntologyId, /// Path to the oplog database file for this ontology on the local /// filesystem. pub oplog_path: PathBuf, /// Classification of the ontology (governs sharing rules). pub classification: OntologyType, /// Most recent state root verified by this actor for this ontology. /// `None` when the ontology has been registered but its current /// state has not yet been confirmed. pub verified_state_root_hex: Option, } /// In-memory snapshot of the local ontology registry. #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct OntologyRegistry { /// All known ontologies, in insertion order. pub entries: Vec, } impl OntologyRegistry { /// Open an empty registry — useful for tests and for actors that /// have never registered an ontology. pub fn empty() -> Self { Self::default() } /// Load the registry from the JSON document at `path`. A missing /// file is treated as an empty registry — this matches the /// expected on-disk state of a fresh actor. /// /// # Errors /// /// - [`Error::Io`] when the file exists but cannot be read. /// - [`Error::RegistryParse`] when the file exists but does not /// parse into the registry schema. pub fn load(path: &Path) -> Result { match std::fs::read(path) { Ok(bytes) => { let reg = serde_json::from_slice::(&bytes).map_err(|e| { Error::RegistryParse { path: path.display().to_string(), message: e.to_string(), } })?; Ok(reg) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(e) => Err(Error::Io { context: format!("reading {}", path.display()), source: e, }), } } /// Persist the registry to `path` as pretty-printed JSON. The parent /// directory is created on demand. /// /// # Errors /// /// IO error on directory creation or write. pub fn save(&self, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| Error::Io { context: format!("creating parent of {}", path.display()), source: e, })?; } let json = serde_json::to_string_pretty(self).map_err(|e| Error::RegistryParse { path: path.display().to_string(), message: e.to_string(), })?; std::fs::write(path, json).map_err(|e| Error::Io { context: format!("writing {}", path.display()), source: e, }) } /// Look up an entry by ontology id. pub fn lookup(&self, id: &OntologyId) -> Option<&RegistryEntry> { self.entries.iter().find(|e| &e.ontology_id == id) } /// Register a new entry, replacing any existing one with the same /// ontology id. pub fn register(&mut self, entry: RegistryEntry) { if let Some(existing) = self.entries.iter_mut().find(|e| e.ontology_id == entry.ontology_id) { *existing = entry; } else { self.entries.push(entry); } } /// Remove an entry by ontology id, returning the removed entry if /// it existed. pub fn unregister(&mut self, id: &OntologyId) -> Option { let idx = self.entries.iter().position(|e| &e.ontology_id == id)?; Some(self.entries.remove(idx)) } } #[cfg(test)] mod tests { use super::*; fn sample_id(seed: u8) -> OntologyId { OntologyId([seed; 32]) } fn sample_entry(seed: u8, classification: OntologyType) -> RegistryEntry { RegistryEntry { ontology_id: sample_id(seed), oplog_path: PathBuf::from(format!("/tmp/ontology-{seed}.oplog")), classification, verified_state_root_hex: None, } } #[test] fn register_and_lookup() { let mut reg = OntologyRegistry::empty(); let entry = sample_entry(1, OntologyType::Type1Protocol); let id = entry.ontology_id; reg.register(entry); assert!(reg.lookup(&id).is_some()); assert!(reg.lookup(&sample_id(2)).is_none()); } #[test] fn register_replaces_existing() { let mut reg = OntologyRegistry::empty(); reg.register(sample_entry(1, OntologyType::Type1Protocol)); reg.register(sample_entry(1, OntologyType::Type3ProjectSpecific)); assert_eq!(reg.entries.len(), 1); assert_eq!( reg.lookup(&sample_id(1)).unwrap().classification, OntologyType::Type3ProjectSpecific ); } #[test] fn unregister_returns_entry() { let mut reg = OntologyRegistry::empty(); reg.register(sample_entry(1, OntologyType::Type1Protocol)); let removed = reg.unregister(&sample_id(1)).expect("entry was registered"); assert_eq!(removed.ontology_id, sample_id(1)); assert!(reg.entries.is_empty()); } #[test] fn save_and_load_roundtrip() { let tmp = tempfile::tempdir().expect("tempdir"); let path = tmp.path().join("registry.json"); let mut reg = OntologyRegistry::empty(); reg.register(sample_entry(1, OntologyType::Type1Protocol)); reg.register(sample_entry(2, OntologyType::Type3ProjectSpecific)); reg.save(&path).expect("save ok"); let loaded = OntologyRegistry::load(&path).expect("load ok"); assert_eq!(loaded.entries.len(), 2); assert_eq!( loaded.entries[0].classification, OntologyType::Type1Protocol ); assert_eq!( loaded.entries[1].classification, OntologyType::Type3ProjectSpecific ); } #[test] fn load_missing_file_returns_empty() { let tmp = tempfile::tempdir().expect("tempdir"); let path = tmp.path().join("never_created.json"); let reg = OntologyRegistry::load(&path).expect("missing is empty"); assert!(reg.entries.is_empty()); } }