ursula-config 0.3.0

Ursula configuration types and TOML loading.
Documentation
use std::path::Path;

use thiserror::Error;

use crate::config::UrsulaConfig;
use crate::preset::Preset;
use crate::validate::ValidationError;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("config file not found: {0}")]
    NotFound(String),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("TOML parse error: {0}")]
    TomlParse(#[from] toml::de::Error),
    #[error("validation error: {0}")]
    Validation(#[from] ValidationError),
    #[error("{0}")]
    Other(String),
}

/// Search for a default config file when `--config` is not given.
///
/// Searches TOML candidates in order of specificity.
pub fn find_default_config() -> Option<std::path::PathBuf> {
    let mut candidates = vec![
        std::path::PathBuf::from("./ursula.toml"),
        std::path::PathBuf::from("/etc/ursula/ursula.toml"),
    ];
    if let Some(config_dir) = dirs::config_dir() {
        candidates.push(config_dir.join("ursula").join("config.toml"));
    }
    for path in &candidates {
        if path.exists() {
            return Some(path.clone());
        }
    }
    None
}

/// Load config from an optional file path with optional preset and node_id override.
///
/// `node_id` may be set in the config file as `raft.node_id`; the CLI
/// `--node-id` flag intentionally overrides it for per-node identity or
/// deployment-derived identities such as StatefulSet ordinals.
///
/// When `path` is `None`, the config is built entirely from the preset (if any)
/// plus `UrsulaConfig` defaults.  This allows `--preset tiny` to work without
/// a config file.
///
/// Only TOML config files (`.toml`) are supported; any other extension is an
/// error.
pub fn load_config(
    path: Option<&Path>,
    preset: Option<Preset>,
    node_id: Option<u64>,
) -> Result<UrsulaConfig, ConfigError> {
    let user_table = match path {
        Some(path) => {
            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
                return Err(ConfigError::Other(format!(
                    "unsupported config file extension for '{}': only TOML is supported",
                    path.display()
                )));
            }
            let raw = std::fs::read_to_string(path)?;
            raw.parse::<toml::Table>()?
        }
        None => toml::Table::new(),
    };

    let mut base_table = match preset {
        Some(p) => {
            let preset_config = UrsulaConfig::from(p);
            toml::Value::try_from(preset_config)
                .map_err(|e| ConfigError::Other(format!("serialize preset: {e}")))?
                .as_table()
                .cloned()
                .ok_or_else(|| ConfigError::Other("preset is not a table".into()))?
        }
        None => toml::Table::new(),
    };

    merge_tables(&mut base_table, user_table);

    let mut config: UrsulaConfig = base_table.try_into()?;
    if let Some(id) = node_id {
        config.raft.node_id = id;
    }
    if config.raft.init_membership_per_group {
        config.raft.init_membership = true;
    }
    config.validate()?;
    Ok(config)
}

/// Deep-merge two TOML tables.
///
/// * Tables are merged recursively (user keys override base keys).
/// * Arrays are replaced wholesale (user array wins).
/// * Scalar values are overwritten.
///
/// This is the standard "preset + user override" semantics: the preset
/// supplies the base configuration and the user's TOML file patches it.
/// We operate at the `toml::Table` AST level because serde does not
/// provide a way to partially-deserialise into an existing struct.
fn merge_tables(base: &mut toml::Table, user: toml::Table) {
    for (key, user_value) in user {
        match base.get_mut(&key) {
            Some(toml::Value::Table(base_sub)) => {
                if let toml::Value::Table(user_sub) = user_value {
                    merge_tables(base_sub, user_sub);
                    continue;
                }
            }
            Some(toml::Value::Array(base_arr)) => {
                if let toml::Value::Array(user_arr) = user_value {
                    // Arrays: user array replaces base array (TOML semantics)
                    *base_arr = user_arr;
                    continue;
                }
            }
            _ => {}
        }
        base.insert(key, user_value);
    }
}

#[cfg(test)]
pub fn merge_tables_for_test(base: &mut toml::Table, user: toml::Table) {
    merge_tables(base, user);
}