cargowatch-core 0.1.0

Shared domain types, configuration, and session state for CargoWatch.
Documentation
//! Layered configuration loading.

use std::path::PathBuf;

use anyhow::Result;
use config::{Environment, File, FileFormat};
use serde::{Deserialize, Serialize};

use crate::paths::AppPaths;

/// Runtime configuration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppConfig {
    /// Configured theme colors.
    pub theme: ThemeConfig,
    /// How many recent sessions to show by default.
    pub history_limit: usize,
    /// Number of days to keep old sessions.
    pub retention_days: u32,
    /// Maximum number of log lines held in memory per loaded session.
    pub max_log_lines_in_memory: usize,
    /// Maximum number of raw log lines stored in SQLite per session.
    pub max_persisted_log_lines: usize,
    /// Override path for the SQLite database.
    pub database_path: PathBuf,
    /// How frequently external process detection runs.
    pub detection_poll_interval_ms: u64,
    /// Whether the TUI should auto-follow active sessions.
    pub auto_follow_running_session: bool,
    /// Whether raw logs should be persisted.
    pub capture_raw_log_storage: bool,
    /// User-visible starter commands for the TUI.
    pub command_presets: Vec<CommandPreset>,
}

impl AppConfig {
    /// Build defaults that depend on the resolved app paths.
    pub fn default_for(paths: &AppPaths) -> Self {
        Self {
            theme: ThemeConfig::default(),
            history_limit: 24,
            retention_days: 30,
            max_log_lines_in_memory: 4_000,
            max_persisted_log_lines: 8_000,
            database_path: paths.database_path.clone(),
            detection_poll_interval_ms: 1_500,
            auto_follow_running_session: true,
            capture_raw_log_storage: true,
            command_presets: vec![
                CommandPreset::new("cargo check", "cargo check"),
                CommandPreset::new("cargo build", "cargo build"),
                CommandPreset::new("cargo test", "cargo test"),
                CommandPreset::new("cargo clippy", "cargo clippy --workspace --all-targets"),
            ],
        }
    }

    fn apply_partial(mut self, partial: PartialAppConfig) -> Self {
        if let Some(theme) = partial.theme {
            self.theme = self.theme.apply_partial(theme);
        }
        if let Some(history_limit) = partial.history_limit {
            self.history_limit = history_limit;
        }
        if let Some(retention_days) = partial.retention_days {
            self.retention_days = retention_days;
        }
        if let Some(max_log_lines_in_memory) = partial.max_log_lines_in_memory {
            self.max_log_lines_in_memory = max_log_lines_in_memory;
        }
        if let Some(max_persisted_log_lines) = partial.max_persisted_log_lines {
            self.max_persisted_log_lines = max_persisted_log_lines;
        }
        if let Some(database_path) = partial.database_path {
            self.database_path = database_path;
        }
        if let Some(detection_poll_interval_ms) = partial.detection_poll_interval_ms {
            self.detection_poll_interval_ms = detection_poll_interval_ms;
        }
        if let Some(auto_follow_running_session) = partial.auto_follow_running_session {
            self.auto_follow_running_session = auto_follow_running_session;
        }
        if let Some(capture_raw_log_storage) = partial.capture_raw_log_storage {
            self.capture_raw_log_storage = capture_raw_log_storage;
        }
        if let Some(command_presets) = partial.command_presets {
            self.command_presets = command_presets;
        }
        self
    }
}

/// Theme colors used by the TUI.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ThemeConfig {
    /// Accent color.
    pub accent: String,
    /// Success color.
    pub success: String,
    /// Warning color.
    pub warning: String,
    /// Error color.
    pub error: String,
    /// Informational color.
    pub info: String,
    /// Muted border and hint color.
    pub muted: String,
}

impl Default for ThemeConfig {
    fn default() -> Self {
        Self {
            accent: "#5BA3E8".to_string(),
            success: "#58B368".to_string(),
            warning: "#E5A33A".to_string(),
            error: "#D95D5D".to_string(),
            info: "#7BAFD4".to_string(),
            muted: "#6C757D".to_string(),
        }
    }
}

impl ThemeConfig {
    fn apply_partial(mut self, partial: PartialThemeConfig) -> Self {
        if let Some(accent) = partial.accent {
            self.accent = accent;
        }
        if let Some(success) = partial.success {
            self.success = success;
        }
        if let Some(warning) = partial.warning {
            self.warning = warning;
        }
        if let Some(error) = partial.error {
            self.error = error;
        }
        if let Some(info) = partial.info {
            self.info = info;
        }
        if let Some(muted) = partial.muted {
            self.muted = muted;
        }
        self
    }
}

/// A named starter command exposed in the TUI.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CommandPreset {
    /// Display label.
    pub name: String,
    /// Shell-style command line.
    pub command: String,
}

impl CommandPreset {
    /// Construct a new preset.
    pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            command: command.into(),
        }
    }
}

/// Partial top-level config used for layered merging.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct PartialAppConfig {
    /// Partial theme override.
    pub theme: Option<PartialThemeConfig>,
    /// Recent history list size.
    pub history_limit: Option<usize>,
    /// Session retention in days.
    pub retention_days: Option<u32>,
    /// Max logs held in memory.
    pub max_log_lines_in_memory: Option<usize>,
    /// Max logs persisted.
    pub max_persisted_log_lines: Option<usize>,
    /// Database path override.
    pub database_path: Option<PathBuf>,
    /// Detection poll interval in milliseconds.
    pub detection_poll_interval_ms: Option<u64>,
    /// Follow running sessions in the UI.
    pub auto_follow_running_session: Option<bool>,
    /// Persist raw output lines.
    pub capture_raw_log_storage: Option<bool>,
    /// Command presets.
    pub command_presets: Option<Vec<CommandPreset>>,
}

/// Partial theme override.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct PartialThemeConfig {
    /// Accent color override.
    pub accent: Option<String>,
    /// Success color override.
    pub success: Option<String>,
    /// Warning color override.
    pub warning: Option<String>,
    /// Error color override.
    pub error: Option<String>,
    /// Info color override.
    pub info: Option<String>,
    /// Muted color override.
    pub muted: Option<String>,
}

/// Load configuration with defaults, file overrides, and environment overrides.
pub fn load_config(paths: &AppPaths) -> Result<AppConfig> {
    let defaults = AppConfig::default_for(paths);
    let layered = config::Config::builder()
        .add_source(
            File::new(
                paths.config_file().to_string_lossy().as_ref(),
                FileFormat::Toml,
            )
            .required(false),
        )
        .add_source(
            Environment::with_prefix("CARGOWATCH")
                .separator("__")
                .try_parsing(true),
        )
        .build()?;
    let partial = layered.try_deserialize::<PartialAppConfig>()?;
    Ok(defaults.apply_partial(partial))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::*;

    #[test]
    fn load_config_layers_file() {
        let temp = tempdir().expect("tempdir");
        let root = temp.path().join("cw-home");
        let paths = AppPaths {
            root: Some(root.clone()),
            config_dir: root.join("config"),
            data_dir: root.join("data"),
            log_dir: root.join("logs"),
            config_file: root.join("config").join("config.toml"),
            database_path: root.join("data").join("cargowatch.db"),
        };
        paths.ensure_exists().expect("dirs");
        fs::write(
            paths.config_file(),
            r##"
retention_days = 7
[theme]
accent = "#112233"
"##,
        )
        .expect("config file");

        let config = load_config(&paths).expect("config");

        assert_eq!(config.retention_days, 7);
        assert_eq!(config.history_limit, 24);
        assert_eq!(config.theme.accent, "#112233");
    }
}