Skip to main content

cargowatch_core/
config.rs

1//! Layered configuration loading.
2
3use std::path::PathBuf;
4
5use anyhow::Result;
6use config::{Environment, File, FileFormat};
7use serde::{Deserialize, Serialize};
8
9use crate::paths::AppPaths;
10
11/// Runtime configuration.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct AppConfig {
14    /// Configured theme colors.
15    pub theme: ThemeConfig,
16    /// How many recent sessions to show by default.
17    pub history_limit: usize,
18    /// Number of days to keep old sessions.
19    pub retention_days: u32,
20    /// Maximum number of log lines held in memory per loaded session.
21    pub max_log_lines_in_memory: usize,
22    /// Maximum number of raw log lines stored in SQLite per session.
23    pub max_persisted_log_lines: usize,
24    /// Override path for the SQLite database.
25    pub database_path: PathBuf,
26    /// How frequently external process detection runs.
27    pub detection_poll_interval_ms: u64,
28    /// Whether the TUI should auto-follow active sessions.
29    pub auto_follow_running_session: bool,
30    /// Whether raw logs should be persisted.
31    pub capture_raw_log_storage: bool,
32    /// User-visible starter commands for the TUI.
33    pub command_presets: Vec<CommandPreset>,
34}
35
36impl AppConfig {
37    /// Build defaults that depend on the resolved app paths.
38    pub fn default_for(paths: &AppPaths) -> Self {
39        Self {
40            theme: ThemeConfig::default(),
41            history_limit: 24,
42            retention_days: 30,
43            max_log_lines_in_memory: 4_000,
44            max_persisted_log_lines: 8_000,
45            database_path: paths.database_path.clone(),
46            detection_poll_interval_ms: 1_500,
47            auto_follow_running_session: true,
48            capture_raw_log_storage: true,
49            command_presets: vec![
50                CommandPreset::new("cargo check", "cargo check"),
51                CommandPreset::new("cargo build", "cargo build"),
52                CommandPreset::new("cargo test", "cargo test"),
53                CommandPreset::new("cargo clippy", "cargo clippy --workspace --all-targets"),
54            ],
55        }
56    }
57
58    fn apply_partial(mut self, partial: PartialAppConfig) -> Self {
59        if let Some(theme) = partial.theme {
60            self.theme = self.theme.apply_partial(theme);
61        }
62        if let Some(history_limit) = partial.history_limit {
63            self.history_limit = history_limit;
64        }
65        if let Some(retention_days) = partial.retention_days {
66            self.retention_days = retention_days;
67        }
68        if let Some(max_log_lines_in_memory) = partial.max_log_lines_in_memory {
69            self.max_log_lines_in_memory = max_log_lines_in_memory;
70        }
71        if let Some(max_persisted_log_lines) = partial.max_persisted_log_lines {
72            self.max_persisted_log_lines = max_persisted_log_lines;
73        }
74        if let Some(database_path) = partial.database_path {
75            self.database_path = database_path;
76        }
77        if let Some(detection_poll_interval_ms) = partial.detection_poll_interval_ms {
78            self.detection_poll_interval_ms = detection_poll_interval_ms;
79        }
80        if let Some(auto_follow_running_session) = partial.auto_follow_running_session {
81            self.auto_follow_running_session = auto_follow_running_session;
82        }
83        if let Some(capture_raw_log_storage) = partial.capture_raw_log_storage {
84            self.capture_raw_log_storage = capture_raw_log_storage;
85        }
86        if let Some(command_presets) = partial.command_presets {
87            self.command_presets = command_presets;
88        }
89        self
90    }
91}
92
93/// Theme colors used by the TUI.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct ThemeConfig {
96    /// Accent color.
97    pub accent: String,
98    /// Success color.
99    pub success: String,
100    /// Warning color.
101    pub warning: String,
102    /// Error color.
103    pub error: String,
104    /// Informational color.
105    pub info: String,
106    /// Muted border and hint color.
107    pub muted: String,
108}
109
110impl Default for ThemeConfig {
111    fn default() -> Self {
112        Self {
113            accent: "#5BA3E8".to_string(),
114            success: "#58B368".to_string(),
115            warning: "#E5A33A".to_string(),
116            error: "#D95D5D".to_string(),
117            info: "#7BAFD4".to_string(),
118            muted: "#6C757D".to_string(),
119        }
120    }
121}
122
123impl ThemeConfig {
124    fn apply_partial(mut self, partial: PartialThemeConfig) -> Self {
125        if let Some(accent) = partial.accent {
126            self.accent = accent;
127        }
128        if let Some(success) = partial.success {
129            self.success = success;
130        }
131        if let Some(warning) = partial.warning {
132            self.warning = warning;
133        }
134        if let Some(error) = partial.error {
135            self.error = error;
136        }
137        if let Some(info) = partial.info {
138            self.info = info;
139        }
140        if let Some(muted) = partial.muted {
141            self.muted = muted;
142        }
143        self
144    }
145}
146
147/// A named starter command exposed in the TUI.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
149pub struct CommandPreset {
150    /// Display label.
151    pub name: String,
152    /// Shell-style command line.
153    pub command: String,
154}
155
156impl CommandPreset {
157    /// Construct a new preset.
158    pub fn new(name: impl Into<String>, command: impl Into<String>) -> Self {
159        Self {
160            name: name.into(),
161            command: command.into(),
162        }
163    }
164}
165
166/// Partial top-level config used for layered merging.
167#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
168pub struct PartialAppConfig {
169    /// Partial theme override.
170    pub theme: Option<PartialThemeConfig>,
171    /// Recent history list size.
172    pub history_limit: Option<usize>,
173    /// Session retention in days.
174    pub retention_days: Option<u32>,
175    /// Max logs held in memory.
176    pub max_log_lines_in_memory: Option<usize>,
177    /// Max logs persisted.
178    pub max_persisted_log_lines: Option<usize>,
179    /// Database path override.
180    pub database_path: Option<PathBuf>,
181    /// Detection poll interval in milliseconds.
182    pub detection_poll_interval_ms: Option<u64>,
183    /// Follow running sessions in the UI.
184    pub auto_follow_running_session: Option<bool>,
185    /// Persist raw output lines.
186    pub capture_raw_log_storage: Option<bool>,
187    /// Command presets.
188    pub command_presets: Option<Vec<CommandPreset>>,
189}
190
191/// Partial theme override.
192#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
193pub struct PartialThemeConfig {
194    /// Accent color override.
195    pub accent: Option<String>,
196    /// Success color override.
197    pub success: Option<String>,
198    /// Warning color override.
199    pub warning: Option<String>,
200    /// Error color override.
201    pub error: Option<String>,
202    /// Info color override.
203    pub info: Option<String>,
204    /// Muted color override.
205    pub muted: Option<String>,
206}
207
208/// Load configuration with defaults, file overrides, and environment overrides.
209pub fn load_config(paths: &AppPaths) -> Result<AppConfig> {
210    let defaults = AppConfig::default_for(paths);
211    let layered = config::Config::builder()
212        .add_source(
213            File::new(
214                paths.config_file().to_string_lossy().as_ref(),
215                FileFormat::Toml,
216            )
217            .required(false),
218        )
219        .add_source(
220            Environment::with_prefix("CARGOWATCH")
221                .separator("__")
222                .try_parsing(true),
223        )
224        .build()?;
225    let partial = layered.try_deserialize::<PartialAppConfig>()?;
226    Ok(defaults.apply_partial(partial))
227}
228
229#[cfg(test)]
230mod tests {
231    use std::fs;
232
233    use tempfile::tempdir;
234
235    use super::*;
236
237    #[test]
238    fn load_config_layers_file() {
239        let temp = tempdir().expect("tempdir");
240        let root = temp.path().join("cw-home");
241        let paths = AppPaths {
242            root: Some(root.clone()),
243            config_dir: root.join("config"),
244            data_dir: root.join("data"),
245            log_dir: root.join("logs"),
246            config_file: root.join("config").join("config.toml"),
247            database_path: root.join("data").join("cargowatch.db"),
248        };
249        paths.ensure_exists().expect("dirs");
250        fs::write(
251            paths.config_file(),
252            r##"
253retention_days = 7
254[theme]
255accent = "#112233"
256"##,
257        )
258        .expect("config file");
259
260        let config = load_config(&paths).expect("config");
261
262        assert_eq!(config.retention_days, 7);
263        assert_eq!(config.history_limit, 24);
264        assert_eq!(config.theme.accent, "#112233");
265    }
266}