use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::RwLock; use super::api::{Context, ContextConfig}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Config { pub context: String, pub contexts: HashMap, } impl Default for Config { fn default() -> Self { let mut contexts = HashMap::new(); contexts.insert("local".to_string(), ContextConfig::Local); Self { context: "local".to_string(), contexts, } } } /// Helper managing contexts pub struct ContextManager { context: RwLock>, config_path: PathBuf, config: RwLock, } pub static MANAGER: std::sync::LazyLock = std::sync::LazyLock::new(|| ContextManager::new().expect("Cannot setup context manager")); impl ContextManager { fn new() -> io::Result { let proj_dirs = ProjectDirs::from("com", "pkh", "pkh").ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, "Could not determine config directory", ) })?; let config_dir = proj_dirs.config_dir(); fs::create_dir_all(config_dir)?; let config_path = config_dir.join("contexts.json"); let mut config = Self::load_config(&config_path); // Build the initial Context against the freshly loaded map, before // the manager itself exists: resolution must not go through the // global MANAGER here, since a parented current context would // re-enter its own LazyLock initialization. let initial = match Self::make_context(&config.context, &config.contexts) { Ok(context) => context, Err(e) => { log::error!( "Cannot build current context '{}' from {}: {e}; falling back to 'local'", config.context, config_path.display() ); config.context = "local".to_string(); Self::make_context("local", &config.contexts).unwrap_or_else(|e| { // Only possible in a hand-edited configuration without // any 'local' entry; a plain Local context has no parent // and cannot fail to build. log::error!( "'local' context missing from {}: {e}", config_path.display() ); Context::new(ContextConfig::Local).expect("Local context cannot fail") }) } }; Ok(Self { context: RwLock::new(Arc::new(initial)), config_path, config: RwLock::new(config), }) } /// Load the configuration stored at `path`. /// /// A missing file yields [`Config::default`]. A file that cannot be read /// or parsed must not take the whole program down: this falls back to /// the default (local-only) configuration and logs an error. Because a /// later [`ContextManager::save`] would otherwise silently overwrite the /// corrupt file and destroy its content, the corrupt file is first /// backed up to `.bak` (best effort). pub(crate) fn load_config(path: &Path) -> Config { if !path.exists() { return Config::default(); } let loaded = fs::read_to_string(path).and_then(|content| { serde_json::from_str(&content) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) }); match loaded { Ok(config) => config, Err(e) => { log::error!( "Context configuration {} is corrupt ({e}); using the default (local-only) configuration", path.display() ); Self::backup_corrupt_file(path); Config::default() } } } /// Back up a corrupt configuration file (best effort) so a later save /// cannot silently destroy its content. fn backup_corrupt_file(path: &Path) { let mut os = path.as_os_str().to_os_string(); os.push(".bak"); let backup_path = PathBuf::from(os); match fs::copy(path, &backup_path) { Ok(_) => log::warn!( "Corrupt context configuration backed up to {}", backup_path.display() ), Err(e) => log::warn!( "Could not back up corrupt context configuration to {}: {e}", backup_path.display() ), } } /// Obtain current ContextManager configuration pub fn get_config(&self) -> std::sync::RwLockReadGuard<'_, Config> { self.config.read().unwrap() } /// Make a ContextManager using a specific configuration path pub fn with_path(path: PathBuf) -> Self { let config = Config::default(); Self { // 'local' is always present in Config::default and has no // parent, so this cannot fail. context: RwLock::new(Arc::new( Self::make_context("local", &config.contexts) .expect("default 'local' context cannot fail"), )), config_path: path, config: RwLock::new(config), } } /// Save current context configuration to disk pub fn save(&self) -> io::Result<()> { let config = self.config.read().unwrap(); let content = serde_json::to_string_pretty(&*config) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; fs::write(&self.config_path, content)?; Ok(()) } /// Build a [`Context`] for `name` from `contexts`. /// /// Lock-free by construction: parent references are resolved against /// `contexts` itself (see [`Context::with_lookup`]), never against the /// manager's configuration lock. This is what keeps [`ContextManager::new`] /// working before the global [`MANAGER`] exists, and what allows callers /// to build contexts without risking a re-entrant read on a lock they /// already hold for writing. fn make_context(name: &str, contexts: &HashMap) -> io::Result { let context_config = contexts.get(name).cloned().ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, format!("Context '{name}' not found in configuration"), ) })?; Context::with_lookup(context_config, &|parent| contexts.get(parent).cloned()) } /// List contexts from configuration pub fn list_contexts(&self) -> Vec { self.config .read() .unwrap() .contexts .keys() .cloned() .collect() } /// Add a context to configuration pub fn add_context(&self, name: &str, config: ContextConfig) -> io::Result<()> { self.config .write() .unwrap() .contexts .insert(name.to_string(), config); self.save() } /// Remove context from configuration pub fn remove_context(&self, name: &str) -> io::Result<()> { if name == "local" { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Cannot remove local context", )); } // Mutate under the write lock, snapshotting the remaining map when // the removed context was current; the fallback Context is built // after the lock is released (same discipline as `set_current`). let fallback_contexts = { let mut config = self.config.write().unwrap(); if config.contexts.remove(name).is_none() { return Ok(()); } if name == config.context { // If we are removing the current context, fallback to local config.context = "local".to_string(); Some(config.contexts.clone()) } else { None } }; if let Some(contexts) = fallback_contexts { self.set_current_ephemeral(Self::make_context("local", &contexts)?); } self.save()?; Ok(()) } /// Set current context from name (modifying configuration) pub fn set_current(&self, name: &str) -> io::Result<()> { // Snapshot what `make_context` needs and release the lock before // building the Context. Building resolves parent contexts, and this // code path used to hold the config write guard while re-entering // the same lock for a read — a guaranteed deadlock on a // std::sync::RwLock (same-thread write-then-read). let contexts = self.config.read().unwrap().contexts.clone(); if !contexts.contains_key(name) { return Err(io::Error::new( io::ErrorKind::NotFound, format!("Context '{name}' not found"), )); } let context = Self::make_context(name, &contexts)?; // Re-take the write lock briefly to commit. The name may have been // removed between snapshot and commit; report the same NotFound // error instead of persisting a dangling current-context reference. let mut config = self.config.write().unwrap(); if !config.contexts.contains_key(name) { return Err(io::Error::new( io::ErrorKind::NotFound, format!("Context '{name}' not found"), )); } config.context = name.to_string(); drop(config); // Drop write lock before saving self.set_current_ephemeral(context); self.save()?; Ok(()) } /// Set current context, without modifying configuration /// /// Accepts either an owned [`Context`] or an already-shared /// `Arc`: callers that keep their own handle to the context /// they install (e.g. [`crate::deb::ephemeral::EphemeralContextGuard`]) /// pass the Arc so they can restore exactly this context afterwards /// instead of relying on whatever happens to be current at that time. pub fn set_current_ephemeral(&self, context: impl Into>) { *self.context.write().unwrap() = context.into(); } /// Obtain current context handle pub fn current(&self) -> Arc { self.context.read().unwrap().clone() } /// Obtain current context name /// Will not work for ephemeral context (obtained from config) pub fn current_name(&self) -> String { self.config.read().unwrap().context.clone() } }