//! Global naming index for human-readable names //! //! Names are purely a UI concern - all data in structures is identified by UUID. //! This index maps UUIDs to human-readable names for display and provides //! reverse lookup for parsing. //! //! Following chit's design: "namings are purely a user interface (input/output //! for humans and large language models)" //! //! ## Suffix-based lookup via ReversedPath //! //! To efficiently look up names by suffix (e.g., find all `*/A` when given just `A`), //! we store paths reversed in a BTreeMap. For example: //! - `["PetriNet", "P"]` is stored as `ReversedPath(["P", "PetriNet"])` //! - A prefix scan for `["A"]` finds all paths ending in `A` //! //! This enables O(log n + k) suffix lookups where k is the number of matches. use crate::id::Uuid; use indexmap::IndexMap; use memmap2::Mmap; use rkyv::ser::Serializer; use rkyv::ser::serializers::AllocSerializer; use rkyv::{Archive, Deserialize, Serialize, check_archived_root}; use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::Write; use std::path::PathBuf; /// A qualified name path (e.g., ["PetriNet", "P"] for sort P in theory PetriNet) pub type QualifiedName = Vec; /// A path stored with segments reversed for efficient suffix-based lookup. /// /// `["PetriNet", "P"]` becomes `ReversedPath(["P", "PetriNet"])`. /// This allows BTreeMap range queries to find all paths with a given suffix. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ReversedPath(Vec); impl ReversedPath { /// Create a reversed path from a qualified name. pub fn from_qualified(segments: &[String]) -> Self { Self(segments.iter().rev().cloned().collect()) } /// Convert back to a qualified name (forward order). pub fn to_qualified(&self) -> QualifiedName { self.0.iter().rev().cloned().collect() } /// Create a prefix for range queries (just the suffix segments, reversed). /// For looking up all paths ending in `["A"]`, create `ReversedPath(["A"])`. pub fn from_suffix(suffix: &[String]) -> Self { // Suffix is already in forward order, just reverse it Self(suffix.iter().rev().cloned().collect()) } /// Check if this path starts with the given prefix (for range iteration). pub fn starts_with(&self, prefix: &ReversedPath) -> bool { self.0.len() >= prefix.0.len() && self.0[..prefix.0.len()] == prefix.0[..] } /// Get the inner segments (reversed order). pub fn segments(&self) -> &[String] { &self.0 } } /// Serializable form of the naming index #[derive(Archive, Deserialize, Serialize, Default)] #[archive(check_bytes)] struct NamingData { /// UUID → qualified name mapping entries: Vec<(Uuid, QualifiedName)>, } /// Global naming index /// /// Provides bidirectional mapping between UUIDs and human-readable names. /// Names are qualified paths like ["PetriNet", "P"] for sort P in theory PetriNet. /// /// ## Lookup modes /// - **By UUID**: O(1) via `uuid_to_name` /// - **By exact path**: O(log n) via `path_to_uuid` /// - **By suffix**: O(log n + k) via BTreeMap range query on reversed paths #[derive(Debug, Default)] pub struct NamingIndex { /// UUID → qualified name (for display) uuid_to_name: IndexMap, /// Reversed path → UUIDs (for suffix-based lookup) /// Paths are stored reversed so that suffix queries become prefix scans. /// Multiple UUIDs can share the same path (ambiguous names). path_to_uuid: BTreeMap>, /// Persistence path path: Option, /// Dirty flag dirty: bool, } impl NamingIndex { /// Create a new empty naming index pub fn new() -> Self { Self::default() } /// Create a naming index with a persistence path pub fn with_path(path: impl Into) -> Self { Self { uuid_to_name: IndexMap::new(), path_to_uuid: BTreeMap::new(), path: Some(path.into()), dirty: false, } } /// Load a naming index from disk pub fn load(path: impl Into) -> Result { let path = path.into(); if !path.exists() { return Ok(Self::with_path(path)); } let file = File::open(&path).map_err(|e| format!("Failed to open naming index: {}", e))?; let mmap = unsafe { Mmap::map(&file) } .map_err(|e| format!("Failed to mmap naming index: {}", e))?; if mmap.is_empty() { return Ok(Self::with_path(path)); } let archived = check_archived_root::(&mmap) .map_err(|e| format!("Failed to validate naming index: {}", e))?; let data: NamingData = archived .deserialize(&mut rkyv::Infallible) .map_err(|_| "Failed to deserialize naming index")?; let mut index = Self::with_path(path); for (uuid, name) in data.entries { index.insert_internal(uuid, name); } Ok(index) } /// Save the naming index to disk pub fn save(&mut self) -> Result<(), String> { let path = self .path .as_ref() .ok_or("Naming index has no persistence path")?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create naming directory: {}", e))?; } let data = NamingData { entries: self .uuid_to_name .iter() .map(|(k, v)| (*k, v.clone())) .collect(), }; let mut serializer = AllocSerializer::<4096>::default(); serializer .serialize_value(&data) .map_err(|e| format!("Failed to serialize naming index: {}", e))?; let bytes = serializer.into_serializer().into_inner(); let temp_path = path.with_extension("tmp"); { let mut file = File::create(&temp_path) .map_err(|e| format!("Failed to create temp file: {}", e))?; file.write_all(&bytes) .map_err(|e| format!("Failed to write naming index: {}", e))?; file.sync_all() .map_err(|e| format!("Failed to sync naming index: {}", e))?; } fs::rename(&temp_path, path) .map_err(|e| format!("Failed to rename naming index: {}", e))?; self.dirty = false; Ok(()) } /// Internal insert without setting dirty flag fn insert_internal(&mut self, uuid: Uuid, name: QualifiedName) { // Add to reverse index (reversed path → UUIDs) let reversed = ReversedPath::from_qualified(&name); self.path_to_uuid .entry(reversed) .or_default() .push(uuid); self.uuid_to_name.insert(uuid, name); } /// Register a name for a UUID pub fn insert(&mut self, uuid: Uuid, name: QualifiedName) { self.insert_internal(uuid, name); self.dirty = true; } /// Register a simple (unqualified) name for a UUID pub fn insert_simple(&mut self, uuid: Uuid, name: String) { self.insert(uuid, vec![name]); } /// Get the qualified name for a UUID pub fn get(&self, uuid: &Uuid) -> Option<&QualifiedName> { self.uuid_to_name.get(uuid) } /// Get the simple (last component) name for a UUID pub fn get_simple(&self, uuid: &Uuid) -> Option<&str> { self.uuid_to_name .get(uuid) .and_then(|name| name.last()) .map(|s| s.as_str()) } /// Get the display name for a UUID (simple name, or UUID if unnamed) pub fn display_name(&self, uuid: &Uuid) -> String { self.get_simple(uuid) .map(|s| s.to_string()) .unwrap_or_else(|| format!("{}", uuid)) } /// Look up all UUIDs whose qualified name ends with the given suffix. /// /// Examples: /// - `lookup_suffix(&["A"])` returns UUIDs for "ExampleNet/A", "OtherNet/A", etc. /// - `lookup_suffix(&["ExampleNet", "A"])` returns just "ExampleNet/A" /// /// Returns an iterator over matching UUIDs. pub fn lookup_suffix<'a>(&'a self, suffix: &[String]) -> impl Iterator + 'a { let prefix = ReversedPath::from_suffix(suffix); self.path_to_uuid .range(prefix.clone()..) .take_while(move |(k, _)| k.starts_with(&prefix)) .flat_map(|(_, uuids)| uuids.iter().copied()) } /// Look up UUID by exact qualified path. /// Returns None if ambiguous (multiple UUIDs share the exact path). pub fn lookup_exact(&self, path: &[String]) -> Option { let reversed = ReversedPath::from_qualified(path); match self.path_to_uuid.get(&reversed) { Some(uuids) if uuids.len() == 1 => Some(uuids[0]), _ => None, } } /// Resolve a path to a UUID. /// - If exact match exists, return it. /// - If suffix matches exactly one UUID, return it. /// - Otherwise return Err with all candidates (empty if not found, multiple if ambiguous). pub fn resolve(&self, path: &[String]) -> Result> { // First try exact match if let Some(uuid) = self.lookup_exact(path) { return Ok(uuid); } // Fall back to suffix match let candidates: Vec = self.lookup_suffix(path).collect(); match candidates.len() { 1 => Ok(candidates[0]), _ => Err(candidates), } } /// Look up UUIDs by simple (single-segment) name. /// This is a convenience wrapper around `lookup_suffix` for single names. pub fn lookup(&self, name: &str) -> Vec { self.lookup_suffix(&[name.to_string()]).collect() } /// Look up a unique UUID by simple name (returns None if ambiguous or not found) pub fn lookup_unique(&self, name: &str) -> Option { let results: Vec = self.lookup_suffix(&[name.to_string()]).collect(); if results.len() == 1 { Some(results[0]) } else { None } } /// Check if dirty pub fn is_dirty(&self) -> bool { self.dirty } /// Number of entries pub fn len(&self) -> usize { self.uuid_to_name.len() } /// Check if empty pub fn is_empty(&self) -> bool { self.uuid_to_name.is_empty() } /// Iterate over all (UUID, name) pairs pub fn iter(&self) -> impl Iterator { self.uuid_to_name.iter() } } impl Drop for NamingIndex { fn drop(&mut self) { if self.dirty && self.path.is_some() { let _ = self.save(); } } } /// Get the global naming index path pub fn global_naming_path() -> Option { #[cfg(unix)] { std::env::var("HOME").ok().map(|h| { let mut p = PathBuf::from(h); p.push(".config"); p.push("geolog"); p.push("names.bin"); p }) } #[cfg(windows)] { std::env::var("APPDATA").ok().map(|mut p| { p.push("geolog"); p.push("names.bin"); p }) } #[cfg(not(any(unix, windows)))] { None } } /// Load or create the global naming index pub fn global_naming_index() -> NamingIndex { match global_naming_path() { Some(path) => NamingIndex::load(&path).unwrap_or_else(|_| NamingIndex::with_path(path)), None => NamingIndex::new(), } } // Unit tests moved to tests/proptest_naming.rs