ontoref-code/crates/ontoref-ontology-content/src/registry.rs
2026-07-10 01:44:59 +01:00

197 lines
6.8 KiB
Rust

//! 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<String>,
}
/// 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<RegistryEntry>,
}
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<Self> {
match std::fs::read(path) {
Ok(bytes) => {
let reg = serde_json::from_slice::<Self>(&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<RegistryEntry> {
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());
}
}