1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub machine_id: Option<String>,
22
23 #[serde(default)]
24 pub hooks: HooksConfig,
25
26 #[serde(default = "default_history_string")]
29 pub history: String,
30
31 #[serde(default)]
33 pub otel: OtelConfig,
34
35 #[serde(default, skip_serializing_if = "TuiConfig::is_empty")]
37 pub tui: TuiConfig,
38
39 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum ConfigSource {
60 UserConfig,
62 #[default]
64 Default,
65}
66
67impl Config {
68 pub fn load_with_source() -> Result<(Self, ConfigSource), ConfigError> {
76 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 Ok((Config::default(), ConfigSource::Default))
87 }
88
89 pub fn load() -> Result<Self, ConfigError> {
91 Ok(Self::load_with_source()?.0)
92 }
93
94 pub fn db_path() -> Result<PathBuf, ConfigError> {
98 dir::db_path()
99 }
100
101 pub fn source(&self) -> ConfigSource {
103 self.source
104 }
105
106 pub fn mi6_dir() -> Result<PathBuf, ConfigError> {
110 dir::mi6_dir()
111 }
112
113 pub fn machine_id(&self) -> String {
120 if let Some(ref id) = self.machine_id
122 && !id.is_empty()
123 {
124 return id.clone();
125 }
126
127 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 "unknown".to_string()
136 }
137
138 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 pub fn save(&self) -> Result<(), ConfigError> {
158 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 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 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 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 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 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 assert_eq!(ConfigSource::default(), ConfigSource::Default);
245 }
246
247 #[test]
248 fn test_machine_id_returns_nonempty() {
249 let config = Config::default();
251 let machine_id = config.machine_id();
252 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 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 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 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}