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