raz-config 0.2.0

Configuration management for RAZ - handles loading, saving, validation, and inheritance
Documentation
use crate::{
    CommandConfig, FilterConfig, ProviderConfig, RazConfig, UiConfig,
    error::{ConfigError, Result},
    override_config::OverrideCollection,
    schema::ConfigVersion,
};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

static WORKSPACE_CONFIG_CACHE: Lazy<Mutex<HashMap<PathBuf, WorkspaceConfig>>> =
    Lazy::new(|| Mutex::new(HashMap::new()));

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceConfig {
    pub raz: Option<RazConfig>,
    pub providers_config: Option<ProviderConfig>,
    pub filters: Option<FilterConfig>,
    pub ui: Option<UiConfig>,
    pub commands: Option<Vec<CommandConfig>>,
    pub overrides: Option<OverrideCollection>,
    pub extends: Option<PathBuf>,
    #[serde(skip)]
    pub path: PathBuf,
}

impl WorkspaceConfig {
    pub fn new(workspace_path: PathBuf) -> Self {
        Self {
            raz: None,
            providers_config: None,
            filters: None,
            ui: None,
            commands: None,
            overrides: None,
            extends: None,
            path: workspace_path,
        }
    }

    pub fn load(workspace_path: impl AsRef<Path>) -> Result<Option<Self>> {
        let workspace_path = workspace_path.as_ref();
        let config_path = Self::find_config_path(workspace_path)?;

        let Some(config_path) = config_path else {
            return Ok(None);
        };

        {
            let cache = WORKSPACE_CONFIG_CACHE.lock().unwrap();
            if let Some(cached) = cache.get(&config_path) {
                return Ok(Some(cached.clone()));
            }
        }

        let contents = std::fs::read_to_string(&config_path)?;
        let mut config: Self = toml::from_str(&contents)?;
        config.path = config_path.parent().unwrap().to_path_buf();

        if let Some(extends_path) = &config.extends {
            let base_path = if extends_path.is_relative() {
                config.path.join(extends_path)
            } else {
                extends_path.clone()
            };

            if let Some(base_config) = Self::load(&base_path)? {
                config = config.merge_with_base(base_config);
            }
        }

        config.validate()?;

        {
            let mut cache = WORKSPACE_CONFIG_CACHE.lock().unwrap();
            cache.insert(config_path, config.clone());
        }

        Ok(Some(config))
    }

    pub fn save(&self) -> Result<()> {
        let config_path = self.path.join(crate::WORKSPACE_CONFIG_FILENAME);

        if let Some(parent) = config_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let contents = toml::to_string_pretty(self)?;
        std::fs::write(&config_path, contents)?;

        {
            let mut cache = WORKSPACE_CONFIG_CACHE.lock().unwrap();
            cache.insert(config_path, self.clone());
        }

        Ok(())
    }

    fn find_config_path(start_path: &Path) -> Result<Option<PathBuf>> {
        let mut current = start_path;

        loop {
            let config_path = current.join(crate::WORKSPACE_CONFIG_FILENAME);
            if config_path.exists() {
                return Ok(Some(config_path));
            }

            if let Some(parent) = current.parent() {
                current = parent;
            } else {
                break;
            }
        }

        Ok(None)
    }

    pub fn validate(&self) -> Result<()> {
        if let Some(raz_config) = &self.raz {
            if raz_config.version.needs_migration(&ConfigVersion::CURRENT) {
                return Err(ConfigError::VersionMismatch {
                    expected: ConfigVersion::CURRENT.0,
                    found: raz_config.version.0,
                });
            }
        }

        if let Some(extends_path) = &self.extends {
            if extends_path.canonicalize()? == self.path.canonicalize()? {
                return Err(ConfigError::CyclicInheritance);
            }
        }

        Ok(())
    }

    fn merge_with_base(mut self, base: WorkspaceConfig) -> Self {
        if self.raz.is_none() && base.raz.is_some() {
            self.raz = base.raz;
        }

        if self.providers_config.is_none() && base.providers_config.is_some() {
            self.providers_config = base.providers_config;
        }

        if self.filters.is_none() && base.filters.is_some() {
            self.filters = base.filters;
        }

        if self.ui.is_none() && base.ui.is_some() {
            self.ui = base.ui;
        }

        if self.commands.is_none() && base.commands.is_some() {
            self.commands = base.commands;
        } else if let (Some(mut commands), Some(base_commands)) =
            (self.commands.take(), base.commands)
        {
            let mut merged = base_commands;
            merged.append(&mut commands);
            self.commands = Some(merged);
        }

        if self.overrides.is_none() && base.overrides.is_some() {
            self.overrides = base.overrides;
        } else if let (Some(overrides), Some(base_overrides)) =
            (&mut self.overrides, base.overrides)
        {
            for (key, override_config) in base_overrides.overrides {
                overrides.overrides.entry(key).or_insert(override_config);
            }
        }

        self
    }

    pub fn clear_cache() {
        let mut cache = WORKSPACE_CONFIG_CACHE.lock().unwrap();
        cache.clear();
    }

    pub fn invalidate_cache_for(path: &Path) {
        let mut cache = WORKSPACE_CONFIG_CACHE.lock().unwrap();
        cache.retain(|k, _| !k.starts_with(path));
    }
}