1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub db_path: Option<String>,
22
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub machine_id: Option<String>,
26
27 #[serde(default)]
28 pub hooks: HooksConfig,
29
30 #[serde(default = "default_retention_string")]
33 pub retention: String,
34
35 #[serde(default)]
37 pub otel: OtelConfig,
38
39 #[serde(default, skip_serializing_if = "TuiConfig::is_empty")]
41 pub tui: TuiConfig,
42
43 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum ConfigSource {
65 UserConfig,
67 #[default]
69 Default,
70}
71
72impl Config {
73 pub fn load_with_source() -> Result<(Self, ConfigSource), ConfigError> {
81 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 Ok((Config::default(), ConfigSource::Default))
92 }
93
94 pub fn load() -> Result<Self, ConfigError> {
96 Ok(Self::load_with_source()?.0)
97 }
98
99 pub fn db_path(&self) -> Result<PathBuf, ConfigError> {
105 if let Some(ref db_path) = self.db_path {
107 return Self::expand_path(db_path);
108 }
109
110 dir::db_path()
112 }
113
114 pub fn source(&self) -> ConfigSource {
116 self.source
117 }
118
119 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 pub fn mi6_dir() -> Result<PathBuf, ConfigError> {
133 dir::mi6_dir()
134 }
135
136 pub fn machine_id(&self) -> String {
143 if let Some(ref id) = self.machine_id
145 && !id.is_empty()
146 {
147 return id.clone();
148 }
149
150 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 "unknown".to_string()
159 }
160
161 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 pub fn save(&self) -> Result<(), ConfigError> {
181 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 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 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 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 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 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 assert_eq!(ConfigSource::default(), ConfigSource::Default);
270 }
271
272 #[test]
273 fn test_machine_id_returns_nonempty() {
274 let config = Config::default();
276 let machine_id = config.machine_id();
277 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 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 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 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}