mi6_core/config/
app.rs

1//! Application configuration loading and management.
2//!
3//! This module handles the main mi6 configuration file (`<mi6_dir>/config.toml`).
4//! Since issue #271, both core and TUI settings are stored in a single config.toml file.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::time::Duration;
9
10use super::dir;
11use super::hooks::HooksConfig;
12use super::otel::OtelConfig;
13use super::retention::{DEFAULT_RETENTION, default_retention_string, parse_retention};
14use super::tui::TuiConfig;
15use crate::model::error::ConfigError;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Config {
19    /// Explicit database path. If None, uses default (<mi6_dir>/events.db).
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub db_path: Option<String>,
22
23    /// Explicit machine identifier. If None, uses hostname.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub machine_id: Option<String>,
26
27    #[serde(default)]
28    pub hooks: HooksConfig,
29
30    /// Data retention period for all events. Default: 365 days.
31    /// Supports suffixes: 'd' (days), 'h' (hours), 'm' (minutes), 's' (seconds), or raw seconds.
32    #[serde(default = "default_retention_string")]
33    pub retention: String,
34
35    /// OpenTelemetry server configuration.
36    #[serde(default)]
37    pub otel: OtelConfig,
38
39    /// TUI configuration (columns, theme, animations, etc.).
40    #[serde(default, skip_serializing_if = "TuiConfig::is_empty")]
41    pub tui: TuiConfig,
42
43    /// Where the config was loaded from (not serialized)
44    #[serde(skip)]
45    source: ConfigSource,
46}
47
48impl Default for Config {
49    fn default() -> Self {
50        Self {
51            db_path: None,
52            machine_id: None,
53            hooks: HooksConfig::default(),
54            retention: default_retention_string(),
55            otel: OtelConfig::default(),
56            tui: TuiConfig::default(),
57            source: ConfigSource::Default,
58        }
59    }
60}
61
62/// Where the config was loaded from
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum ConfigSource {
65    /// User config file (<mi6_dir>/config.toml)
66    UserConfig,
67    /// No config file found, using defaults
68    #[default]
69    Default,
70}
71
72impl Config {
73    /// Load config with source information.
74    ///
75    /// Config resolution order:
76    /// 1. `<mi6_dir>/config.toml` (if exists)
77    /// 2. Built-in defaults
78    ///
79    /// The mi6 directory is determined by `MI6_DIR_PATH` env var or defaults to `~/.mi6`.
80    pub fn load_with_source() -> Result<(Self, ConfigSource), ConfigError> {
81        // Check user config at <mi6_dir>/config.toml
82        let config_path = dir::core_config_path()?;
83        if config_path.exists() {
84            let contents = std::fs::read_to_string(&config_path)?;
85            let mut config: Config = toml::from_str(&contents)?;
86            config.source = ConfigSource::UserConfig;
87            return Ok((config, ConfigSource::UserConfig));
88        }
89
90        // Use defaults
91        Ok((Config::default(), ConfigSource::Default))
92    }
93
94    /// Load config (convenience method that discards source)
95    pub fn load() -> Result<Self, ConfigError> {
96        Ok(Self::load_with_source()?.0)
97    }
98
99    /// Get the database path.
100    ///
101    /// Resolution order:
102    /// 1. `db_path` from config file (if set)
103    /// 2. Default: `<mi6_dir>/events.db`
104    pub fn db_path(&self) -> Result<PathBuf, ConfigError> {
105        // If db_path is explicitly set in config, use it
106        if let Some(ref db_path) = self.db_path {
107            return Self::expand_path(db_path);
108        }
109
110        // Use the centralized directory module
111        dir::db_path()
112    }
113
114    /// Get the source from which this config was loaded.
115    pub fn source(&self) -> ConfigSource {
116        self.source
117    }
118
119    /// Expand a path, handling ~ for home directory
120    fn expand_path(path: &str) -> Result<PathBuf, ConfigError> {
121        if let Some(stripped) = path.strip_prefix("~/") {
122            let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir)?;
123            Ok(home.join(stripped))
124        } else {
125            Ok(PathBuf::from(path))
126        }
127    }
128
129    /// Get the mi6 directory path.
130    ///
131    /// Returns `MI6_DIR_PATH` if set, otherwise `~/.mi6`.
132    pub fn mi6_dir() -> Result<PathBuf, ConfigError> {
133        dir::mi6_dir()
134    }
135
136    /// Get the machine ID.
137    ///
138    /// Resolution order:
139    /// 1. `machine_id` from config file (if set)
140    /// 2. Hostname
141    /// 3. "unknown" fallback
142    pub fn machine_id(&self) -> String {
143        // 1. Config file setting
144        if let Some(ref id) = self.machine_id
145            && !id.is_empty()
146        {
147            return id.clone();
148        }
149
150        // 2. Hostname
151        if let Some(hostname) = hostname::get().ok().and_then(|h| h.into_string().ok())
152            && !hostname.is_empty()
153        {
154            return hostname;
155        }
156
157        // 3. Fallback
158        "unknown".to_string()
159    }
160
161    /// Get the retention period as a Duration.
162    ///
163    /// Falls back to default if the retention string is invalid.
164    pub fn retention_duration(&self) -> Duration {
165        match parse_retention(&self.retention) {
166            Ok(duration) => duration,
167            Err(e) => {
168                eprintln!(
169                    "mi6: warning: failed to parse retention '{}': {}. Using default.",
170                    self.retention, e
171                );
172                DEFAULT_RETENTION
173            }
174        }
175    }
176
177    /// Save the configuration to disk.
178    ///
179    /// Writes to `<mi6_dir>/config.toml`, ensuring the mi6 directory exists.
180    pub fn save(&self) -> Result<(), ConfigError> {
181        // Ensure mi6 directory exists
182        let _ = dir::ensure_initialized();
183
184        let config_path = dir::core_config_path()?;
185        let contents =
186            toml::to_string_pretty(self).map_err(|e| ConfigError::TomlSerialize(e.to_string()))?;
187        std::fs::write(&config_path, contents)?;
188        Ok(())
189    }
190
191    /// Get a mutable reference to the TUI config section.
192    pub fn tui_mut(&mut self) -> &mut TuiConfig {
193        &mut self.tui
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_config_default_retention() {
203        let config = Config::default();
204        assert_eq!(config.retention, "365d");
205        assert_eq!(config.retention_duration(), DEFAULT_RETENTION);
206    }
207
208    #[test]
209    fn test_config_custom_retention() -> Result<(), String> {
210        let toml_str = r#"
211            retention = "30d"
212        "#;
213
214        let config: Config = toml::from_str(toml_str).map_err(|e| e.to_string())?;
215        assert_eq!(config.retention, "30d");
216        assert_eq!(
217            config.retention_duration(),
218            std::time::Duration::from_secs(30 * 24 * 60 * 60)
219        );
220        Ok(())
221    }
222
223    #[test]
224    fn test_config_retention_fallback_on_invalid() {
225        let config = Config {
226            retention: "invalid".to_string(),
227            ..Default::default()
228        };
229        // Should fall back to DEFAULT_RETENTION
230        assert_eq!(config.retention_duration(), DEFAULT_RETENTION);
231    }
232
233    #[test]
234    fn test_config_default_source() {
235        let config = Config::default();
236        assert_eq!(config.source(), ConfigSource::Default);
237    }
238
239    #[test]
240    fn test_config_source_cached_from_load() -> Result<(), String> {
241        // When loading config, the source should be cached in the struct
242        let (config, source) = Config::load_with_source().map_err(|e| e.to_string())?;
243        assert_eq!(config.source(), source);
244        Ok(())
245    }
246
247    #[test]
248    fn test_db_path_consistent() -> Result<(), String> {
249        // Calling db_path() multiple times should return consistent results
250        let config = Config::default();
251        let path1 = config.db_path().map_err(|e| e.to_string())?;
252        let path2 = config.db_path().map_err(|e| e.to_string())?;
253        assert_eq!(path1, path2);
254        Ok(())
255    }
256
257    #[test]
258    fn test_db_path_ends_with_events_db() -> Result<(), String> {
259        // db_path() should return a path ending in events.db
260        let config = Config::default();
261        let path = config.db_path().map_err(|e| e.to_string())?;
262        assert!(path.ends_with("events.db"));
263        Ok(())
264    }
265
266    #[test]
267    fn test_config_source_default_is_default() {
268        // Verify ConfigSource::Default is the default variant
269        assert_eq!(ConfigSource::default(), ConfigSource::Default);
270    }
271
272    #[test]
273    fn test_machine_id_returns_nonempty() {
274        // machine_id() should always return a non-empty string
275        let config = Config::default();
276        let machine_id = config.machine_id();
277        // Should be non-empty (config, hostname, or "unknown")
278        assert!(!machine_id.is_empty());
279    }
280
281    #[test]
282    fn test_config_machine_id_from_toml() -> Result<(), String> {
283        let toml_str = r#"
284            machine_id = "test-machine"
285        "#;
286
287        let config: Config = toml::from_str(toml_str).map_err(|e| e.to_string())?;
288        assert_eq!(config.machine_id, Some("test-machine".to_string()));
289        Ok(())
290    }
291
292    #[test]
293    fn test_config_machine_id_optional_in_toml() -> Result<(), String> {
294        // machine_id should be optional in TOML
295        let toml_str = r#"
296            retention = "30d"
297        "#;
298
299        let config: Config = toml::from_str(toml_str).map_err(|e| e.to_string())?;
300        assert_eq!(config.machine_id, None);
301        Ok(())
302    }
303
304    #[test]
305    fn test_machine_id_default_is_none() {
306        // Default config should have machine_id as None
307        let config = Config::default();
308        assert!(config.machine_id.is_none());
309    }
310
311    #[test]
312    fn test_mi6_dir_returns_path() -> Result<(), String> {
313        // mi6_dir() should return a valid path
314        let path = Config::mi6_dir().map_err(|e| e.to_string())?;
315        assert!(path.ends_with(".mi6") || std::env::var("MI6_DIR_PATH").is_ok());
316        Ok(())
317    }
318}