Skip to main content

dol/
config.rs

1//! Configuration management.
2//!
3//! Loads/saves DOL configuration from YAML or TOML files. Supports
4//! Docker host, output format, timeouts, and theme settings. Config
5//! is searched in standard paths (`$XDG_CONFIG_HOME/dol/`, `~/.dolrc`, etc.).
6//!
7//! # Example
8//!
9//! ```ignore
10//! let config = DolConfig::load();
11//! let api_cfg = DockerApiConfig::from(&config);
12//! ```
13
14use std::path::PathBuf;
15
16use clap::Subcommand;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct DolConfig {
21    pub store: Option<String>,
22    pub output: Option<String>,
23    pub metrics_interval: Option<u64>,
24    pub snapshot_interval: Option<u64>,
25    pub host: Option<String>,
26    /// Default colour theme: "dark" or "light".
27    pub theme: Option<String>,
28
29    // ── Docker API timeout settings (seconds) ─────────────────
30    /// Timeout for standard Docker API calls (list, inspect, etc.). Default: 30s.
31    pub api_timeout: Option<u64>,
32    /// Timeout for lightweight Docker API calls (ping). Default: 10s.
33    pub api_quick_timeout: Option<u64>,
34    /// Timeout for per-container stats calls. Default: 10s.
35    pub stats_timeout: Option<u64>,
36    /// Max seconds to wait for a single event from the events stream. Default: 30s.
37    pub events_timeout: Option<u64>,
38    /// Timeout for alert webhook HTTP POST. Default: 10s.
39    pub webhook_timeout: Option<u64>,
40    /// Timeout for alert container restart action. Default: 30s.
41    pub restart_timeout: Option<u64>,
42
43    // ── SMTP / Email notification settings ───────────────────
44    /// SMTP server hostname for email alerts. Default: localhost.
45    pub smtp_host: Option<String>,
46    /// SMTP server port. Default: 25.
47    pub smtp_port: Option<u16>,
48    /// SMTP username (optional).
49    pub smtp_user: Option<String>,
50    /// SMTP password (optional).
51    pub smtp_pass: Option<String>,
52
53    // ── Anomaly detection thresholds ────────────────────────
54    /// CPU % above which a warning is issued. Default: 80.
55    pub analysis_high_cpu_percent: Option<f64>,
56    /// CPU % above which a critical alert is issued. Default: 95.
57    pub analysis_critical_cpu_percent: Option<f64>,
58    /// Memory usage/limit ratio above which a warning is issued. Default: 0.85.
59    pub analysis_memory_pressure_ratio: Option<f64>,
60    /// Memory usage/limit ratio above which a critical alert is issued. Default: 0.95.
61    pub analysis_critical_memory_ratio: Option<f64>,
62    /// Restart count threshold for restart loop detection. Default: 3.
63    pub analysis_restart_loop_count: Option<u64>,
64    /// Number of die events indicating deployment failure. Default: 3.
65    pub analysis_deployment_error_threshold: Option<u64>,
66    /// Memory increase % indicating a resource leak. Default: 20.
67    pub analysis_leak_memory_increase_pct: Option<f64>,
68    /// Min metric samples needed for leak detection. Default: 3.
69    pub analysis_leak_min_samples: Option<u64>,
70    /// Network I/O bytes warning threshold. Default: 1048576 (1 MB).
71    pub analysis_high_network_bytes: Option<u64>,
72    /// Network I/O bytes critical threshold. Default: 10485760 (10 MB).
73    pub analysis_critical_network_bytes: Option<u64>,
74    /// Disk I/O bytes warning threshold. Default: 10485760 (10 MB).
75    pub analysis_high_disk_bytes: Option<u64>,
76    /// Disk I/O bytes critical threshold. Default: 104857600 (100 MB).
77    pub analysis_critical_disk_bytes: Option<u64>,
78}
79
80impl DolConfig {
81    #[must_use]
82    pub fn load() -> Self {
83        let paths = config_paths();
84        for path in &paths {
85            if path.exists() {
86                let Ok(content) = std::fs::read_to_string(path) else {
87                    continue;
88                };
89                if let Ok(config) = serde_yaml::from_str::<Self>(&content) {
90                    return config;
91                }
92                if let Ok(config) = toml::from_str::<Self>(&content) {
93                    return config;
94                }
95            }
96        }
97        Self::default()
98    }
99
100    pub fn save(&self) -> anyhow::Result<()> {
101        let path = config_path();
102        if let Some(parent) = path.parent() {
103            std::fs::create_dir_all(parent)?;
104        }
105        let content = serde_yaml::to_string(self)?;
106        std::fs::write(&path, content)?;
107        Ok(())
108    }
109}
110
111#[derive(Debug, Clone, Subcommand)]
112pub enum ConfigAction {
113    /// Create a default config file at the standard config path.
114    Init,
115    /// Set a config key to a value (e.g. `theme light`, `api-timeout 60`).
116    Set { key: String, value: String },
117    /// Display the current configuration.
118    View,
119}
120
121pub fn execute_config(action: ConfigAction) -> anyhow::Result<()> {
122    match action {
123        ConfigAction::Init => {
124            let path = config_path();
125            if path.exists() {
126                anyhow::bail!("config file already exists at {}", path.display());
127            }
128            let config = DolConfig::default();
129            if let Some(parent) = path.parent() {
130                std::fs::create_dir_all(parent)?;
131            }
132            let content = serde_yaml::to_string(&config)?;
133            std::fs::write(&path, content)?;
134            println!("Created default config at {}", path.display());
135            Ok(())
136        }
137        ConfigAction::Set { key, value } => {
138            let mut config = DolConfig::load();
139            match key.as_str() {
140                "store" => config.store = Some(value.clone()),
141                "output" => config.output = Some(value.clone()),
142                "metrics-interval" | "metrics_interval" => {
143                    config.metrics_interval = Some(value.parse().map_err(|_| {
144                        anyhow::anyhow!("invalid value for {key}: expected integer")
145                    })?);
146                }
147                "snapshot-interval" | "snapshot_interval" => {
148                    config.snapshot_interval = Some(value.parse().map_err(|_| {
149                        anyhow::anyhow!("invalid value for {key}: expected integer")
150                    })?);
151                }
152                "host" => config.host = Some(value.clone()),
153                "theme" => config.theme = Some(value.clone()),
154                "api-timeout" | "api_timeout" => {
155                    config.api_timeout = Some(value.parse().map_err(|_| {
156                        anyhow::anyhow!("invalid value for {key}: expected integer (seconds)")
157                    })?);
158                }
159                "api-quick-timeout" | "api_quick_timeout" => {
160                    config.api_quick_timeout = Some(value.parse().map_err(|_| {
161                        anyhow::anyhow!("invalid value for {key}: expected integer (seconds)")
162                    })?);
163                }
164                "stats-timeout" | "stats_timeout" => {
165                    config.stats_timeout = Some(value.parse().map_err(|_| {
166                        anyhow::anyhow!("invalid value for {key}: expected integer (seconds)")
167                    })?);
168                }
169                "events-timeout" | "events_timeout" => {
170                    config.events_timeout = Some(value.parse().map_err(|_| {
171                        anyhow::anyhow!("invalid value for {key}: expected integer (seconds)")
172                    })?);
173                }
174                "webhook-timeout" | "webhook_timeout" => {
175                    config.webhook_timeout = Some(value.parse().map_err(|_| {
176                        anyhow::anyhow!("invalid value for {key}: expected integer (seconds)")
177                    })?);
178                }
179                "restart-timeout" | "restart_timeout" => {
180                    config.restart_timeout = Some(value.parse().map_err(|_| {
181                        anyhow::anyhow!("invalid value for {key}: expected integer (seconds)")
182                    })?);
183                }
184                "smtp-host" | "smtp_host" => {
185                    config.smtp_host = Some(value.clone());
186                }
187                "smtp-port" | "smtp_port" => {
188                    config.smtp_port = Some(value.parse().map_err(|_| {
189                        anyhow::anyhow!("invalid value for {key}: expected port number")
190                    })?);
191                }
192                "smtp-user" | "smtp_user" => {
193                    config.smtp_user = Some(value.clone());
194                }
195                "smtp-pass" | "smtp_pass" => {
196                    config.smtp_pass = Some(value.clone());
197                }
198                // ── Anomaly detection threshold keys ────────────────────
199                "analysis-high-cpu-percent" | "analysis_high_cpu_percent" => {
200                    config.analysis_high_cpu_percent = Some(value.parse().map_err(|_| {
201                        anyhow::anyhow!("invalid value for {key}: expected number")
202                    })?);
203                }
204                "analysis-critical-cpu-percent" | "analysis_critical_cpu_percent" => {
205                    config.analysis_critical_cpu_percent = Some(value.parse().map_err(|_| {
206                        anyhow::anyhow!("invalid value for {key}: expected number")
207                    })?);
208                }
209                "analysis-memory-pressure-ratio" | "analysis_memory_pressure_ratio" => {
210                    config.analysis_memory_pressure_ratio = Some(value.parse().map_err(|_| {
211                        anyhow::anyhow!("invalid value for {key}: expected number")
212                    })?);
213                }
214                "analysis-critical-memory-ratio" | "analysis_critical_memory_ratio" => {
215                    config.analysis_critical_memory_ratio = Some(value.parse().map_err(|_| {
216                        anyhow::anyhow!("invalid value for {key}: expected number")
217                    })?);
218                }
219                "analysis-restart-loop-count" | "analysis_restart_loop_count" => {
220                    config.analysis_restart_loop_count = Some(value.parse().map_err(|_| {
221                        anyhow::anyhow!("invalid value for {key}: expected integer")
222                    })?);
223                }
224                "analysis-deployment-error-threshold" | "analysis_deployment_error_threshold" => {
225                    config.analysis_deployment_error_threshold =
226                        Some(value.parse().map_err(|_| {
227                            anyhow::anyhow!("invalid value for {key}: expected integer")
228                        })?);
229                }
230                "analysis-leak-memory-increase-pct" | "analysis_leak_memory_increase_pct" => {
231                    config.analysis_leak_memory_increase_pct =
232                        Some(value.parse().map_err(|_| {
233                            anyhow::anyhow!("invalid value for {key}: expected number")
234                        })?);
235                }
236                "analysis-leak-min-samples" | "analysis_leak_min_samples" => {
237                    config.analysis_leak_min_samples = Some(value.parse().map_err(|_| {
238                        anyhow::anyhow!("invalid value for {key}: expected integer")
239                    })?);
240                }
241                "analysis-high-network-bytes" | "analysis_high_network_bytes" => {
242                    config.analysis_high_network_bytes = Some(value.parse().map_err(|_| {
243                        anyhow::anyhow!("invalid value for {key}: expected integer")
244                    })?);
245                }
246                "analysis-critical-network-bytes" | "analysis_critical_network_bytes" => {
247                    config.analysis_critical_network_bytes = Some(value.parse().map_err(|_| {
248                        anyhow::anyhow!("invalid value for {key}: expected integer")
249                    })?);
250                }
251                "analysis-high-disk-bytes" | "analysis_high_disk_bytes" => {
252                    config.analysis_high_disk_bytes = Some(value.parse().map_err(|_| {
253                        anyhow::anyhow!("invalid value for {key}: expected integer")
254                    })?);
255                }
256                "analysis-critical-disk-bytes" | "analysis_critical_disk_bytes" => {
257                    config.analysis_critical_disk_bytes = Some(value.parse().map_err(|_| {
258                        anyhow::anyhow!("invalid value for {key}: expected integer")
259                    })?);
260                }
261                _ => anyhow::bail!("unknown config key: {key}"),
262            }
263            config.save()?;
264            println!("Set {key} = {value}");
265            Ok(())
266        }
267        ConfigAction::View => {
268            let config = DolConfig::load();
269            let content = serde_yaml::to_string(&config)?;
270            print!("{content}");
271            Ok(())
272        }
273    }
274}
275
276fn config_paths() -> Vec<PathBuf> {
277    let mut paths = Vec::new();
278
279    if let Some(config_dir) = dirs::config_dir() {
280        paths.push(config_dir.join("dol").join("config.yaml"));
281        paths.push(config_dir.join("dol").join("config.toml"));
282        paths.push(config_dir.join("dolrc"));
283    }
284
285    if let Some(home) = dirs::home_dir() {
286        paths.push(home.join(".dolrc"));
287        paths.push(home.join(".dolrc.yaml"));
288        paths.push(home.join(".dolrc.toml"));
289    }
290
291    paths.push(PathBuf::from(".dolrc"));
292    paths.push(PathBuf::from("dol.yaml"));
293    paths.push(PathBuf::from("dol.toml"));
294
295    paths
296}
297
298fn config_path() -> PathBuf {
299    dirs::config_dir().map_or_else(
300        || PathBuf::from(".dolrc"),
301        |config_dir| config_dir.join("dol").join("config.yaml"),
302    )
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::docker::DockerApiConfig;
309
310    // ── Default values ───────────────────────────────────────────────────
311
312    #[test]
313    fn default_all_timeout_fields_are_none() {
314        let config = DolConfig::default();
315        assert!(config.api_timeout.is_none());
316        assert!(config.api_quick_timeout.is_none());
317        assert!(config.stats_timeout.is_none());
318        assert!(config.events_timeout.is_none());
319        assert!(config.webhook_timeout.is_none());
320        assert!(config.restart_timeout.is_none());
321    }
322
323    #[test]
324    fn default_non_timeout_fields() {
325        let config = DolConfig::default();
326        assert!(config.store.is_none());
327        assert!(config.output.is_none());
328        assert!(config.metrics_interval.is_none());
329        assert!(config.snapshot_interval.is_none());
330        assert!(config.host.is_none());
331        assert!(config.theme.is_none());
332    }
333
334    // ── YAML serialization / deserialization ────────────────────────────
335
336    #[test]
337    fn yaml_roundtrip_empty() {
338        let config = DolConfig::default();
339        let yaml = serde_yaml::to_string(&config).expect("serialize");
340        let deserialized: DolConfig = serde_yaml::from_str(&yaml).expect("deserialize");
341        assert_eq!(config.store, deserialized.store);
342        assert_eq!(config.api_timeout, deserialized.api_timeout);
343        assert_eq!(config.stats_timeout, deserialized.stats_timeout);
344    }
345
346    #[test]
347    fn yaml_roundtrip_with_timeouts() {
348        let config = DolConfig {
349            api_timeout: Some(60),
350            api_quick_timeout: Some(15),
351            stats_timeout: Some(20),
352            events_timeout: Some(120),
353            webhook_timeout: Some(5),
354            restart_timeout: Some(45),
355            ..DolConfig::default()
356        };
357        let yaml = serde_yaml::to_string(&config).expect("serialize");
358        let deserialized: DolConfig = serde_yaml::from_str(&yaml).expect("deserialize");
359        assert_eq!(deserialized.api_timeout, Some(60));
360        assert_eq!(deserialized.api_quick_timeout, Some(15));
361        assert_eq!(deserialized.stats_timeout, Some(20));
362        assert_eq!(deserialized.events_timeout, Some(120));
363        assert_eq!(deserialized.webhook_timeout, Some(5));
364        assert_eq!(deserialized.restart_timeout, Some(45));
365    }
366
367    #[test]
368    fn yaml_deserialize_from_string() {
369        let yaml = r#"
370api_timeout: 60
371stats_timeout: 15
372events_timeout: 30
373webhook_timeout: 10
374restart_timeout: 45
375"#;
376        let config: DolConfig = serde_yaml::from_str(yaml).expect("deserialize");
377        assert_eq!(config.api_timeout, Some(60));
378        assert_eq!(config.stats_timeout, Some(15));
379        assert_eq!(config.events_timeout, Some(30));
380        assert_eq!(config.webhook_timeout, Some(10));
381        assert_eq!(config.restart_timeout, Some(45));
382        // Not set in YAML — should be None
383        assert!(config.api_quick_timeout.is_none());
384        // Non-timeout fields should not be affected
385        assert!(config.store.is_none());
386    }
387
388    #[test]
389    fn yaml_deserialize_mixed_fields() {
390        let yaml = r#"
391store: /tmp/dol.db
392output: json
393theme: light
394api_timeout: 90
395"#;
396        let config: DolConfig = serde_yaml::from_str(yaml).expect("deserialize");
397        assert_eq!(config.store.as_deref(), Some("/tmp/dol.db"));
398        assert_eq!(config.output.as_deref(), Some("json"));
399        assert_eq!(config.theme.as_deref(), Some("light"));
400        assert_eq!(config.api_timeout, Some(90));
401        // Unset fields remain None
402        assert!(config.stats_timeout.is_none());
403        assert!(config.events_timeout.is_none());
404    }
405
406    // ── DockerApiConfig conversion ───────────────────────────────────────
407
408    #[test]
409    fn docker_api_config_from_default_config() {
410        let config = DolConfig::default();
411        let api_cfg = DockerApiConfig::from(&config);
412        assert_eq!(api_cfg.call_timeout.as_secs(), 30);
413        assert_eq!(api_cfg.quick_timeout.as_secs(), 10);
414        assert_eq!(api_cfg.max_retries, 2);
415        assert_eq!(api_cfg.retry_base_ms, 200);
416    }
417
418    #[test]
419    fn docker_api_config_from_custom_config() {
420        let config = DolConfig {
421            api_timeout: Some(120),
422            api_quick_timeout: Some(30),
423            ..DolConfig::default()
424        };
425        let api_cfg = DockerApiConfig::from(&config);
426        assert_eq!(api_cfg.call_timeout.as_secs(), 120);
427        assert_eq!(api_cfg.quick_timeout.as_secs(), 30);
428        // max_retries and retry_base_ms are not configurable from DolConfig
429        assert_eq!(api_cfg.max_retries, 2);
430        assert_eq!(api_cfg.retry_base_ms, 200);
431    }
432
433    #[test]
434    fn docker_api_config_mixed_defaults() {
435        // Only set api_timeout — others should use defaults
436        let config = DolConfig {
437            api_timeout: Some(45),
438            ..DolConfig::default()
439        };
440        let api_cfg = DockerApiConfig::from(&config);
441        assert_eq!(api_cfg.call_timeout.as_secs(), 45);
442        // Not set in config, should fall back to default
443        assert_eq!(api_cfg.quick_timeout.as_secs(), 10);
444    }
445
446    // ── TOML serialization ───────────────────────────────────────────────
447
448    #[test]
449    fn toml_roundtrip_with_timeouts() {
450        let config = DolConfig {
451            api_timeout: Some(60),
452            stats_timeout: Some(15),
453            ..DolConfig::default()
454        };
455        let toml_str = toml::to_string(&config).expect("serialize");
456        let deserialized: DolConfig = toml::from_str(&toml_str).expect("deserialize");
457        assert_eq!(deserialized.api_timeout, Some(60));
458        assert_eq!(deserialized.stats_timeout, Some(15));
459        assert!(deserialized.events_timeout.is_none());
460    }
461
462    // ── ConfigAction::Set match arm verification ─────────────────────────
463    // execute_config writes to disk, so we can't easily test it in unit tests.
464    // Instead, we test the YAML -> DolConfig deserialization which is the
465    // underlying mechanism — if a key round-trips through YAML, the config set
466    // logic will work correctly when writing/reading from disk.
467
468    #[test]
469    fn config_set_all_timeout_keys_roundtrip_through_yaml() {
470        // Simulate: for each timeout key, create a config with that key set,
471        // serialize to YAML, deserialize back, and verify the value is preserved.
472        // This validates that all 6 timeout keys are properly handled by serde.
473        let cases = [
474            ("api_timeout", 60u64),
475            ("api_quick_timeout", 15u64),
476            ("stats_timeout", 20u64),
477            ("events_timeout", 120u64),
478            ("webhook_timeout", 5u64),
479            ("restart_timeout", 45u64),
480        ];
481
482        for (key, value) in &cases {
483            let yaml = format!("{key}: {value}\n");
484            let config: DolConfig = serde_yaml::from_str(&yaml)
485                .unwrap_or_else(|e| panic!("failed to deserialize {key}: {e}"));
486
487            match *key {
488                "api_timeout" => assert_eq!(config.api_timeout, Some(*value)),
489                "api_quick_timeout" => assert_eq!(config.api_quick_timeout, Some(*value)),
490                "stats_timeout" => assert_eq!(config.stats_timeout, Some(*value)),
491                "events_timeout" => assert_eq!(config.events_timeout, Some(*value)),
492                "webhook_timeout" => assert_eq!(config.webhook_timeout, Some(*value)),
493                "restart_timeout" => assert_eq!(config.restart_timeout, Some(*value)),
494                _ => panic!("unexpected key: {key}"),
495            }
496        }
497    }
498
499    #[test]
500    fn config_set_invalid_value_rejected_by_serde() {
501        // Non-integer values for timeout fields should fail to deserialize.
502        let yaml = "api_timeout: not_a_number\n";
503        let result: Result<DolConfig, _> = serde_yaml::from_str(yaml);
504        assert!(
505            result.is_err(),
506            "expected deserialization error for invalid value"
507        );
508    }
509
510    #[test]
511    fn config_set_negative_value_rejected() {
512        // Negative values should fail to deserialize as u64.
513        let yaml = "stats_timeout: -5\n";
514        let result: Result<DolConfig, _> = serde_yaml::from_str(yaml);
515        assert!(
516            result.is_err(),
517            "expected deserialization error for negative value"
518        );
519    }
520
521    // ── SMTP config fields ────────────────────────────────────────────────
522
523    #[test]
524    fn default_smtp_fields_are_none() {
525        let config = DolConfig::default();
526        assert!(config.smtp_host.is_none());
527        assert!(config.smtp_port.is_none());
528        assert!(config.smtp_user.is_none());
529        assert!(config.smtp_pass.is_none());
530    }
531
532    #[test]
533    fn yaml_roundtrip_with_smtp() {
534        let config = DolConfig {
535            smtp_host: Some("smtp.gmail.com".to_owned()),
536            smtp_port: Some(587),
537            smtp_user: Some("user@gmail.com".to_owned()),
538            smtp_pass: Some("app-password".to_owned()),
539            ..DolConfig::default()
540        };
541        let yaml = serde_yaml::to_string(&config).expect("serialize");
542        let deserialized: DolConfig = serde_yaml::from_str(&yaml).expect("deserialize");
543        assert_eq!(deserialized.smtp_host.as_deref(), Some("smtp.gmail.com"));
544        assert_eq!(deserialized.smtp_port, Some(587));
545        assert_eq!(deserialized.smtp_user.as_deref(), Some("user@gmail.com"));
546        assert_eq!(deserialized.smtp_pass.as_deref(), Some("app-password"));
547    }
548
549    #[test]
550    fn yaml_deserialize_smtp_fields() {
551        let yaml = r#"
552smtp_host: smtp.gmail.com
553smtp_port: 587
554smtp_user: user@gmail.com
555smtp_pass: app-password
556"#;
557        let config: DolConfig = serde_yaml::from_str(yaml).expect("deserialize");
558        assert_eq!(config.smtp_host.as_deref(), Some("smtp.gmail.com"));
559        assert_eq!(config.smtp_port, Some(587));
560        assert_eq!(config.smtp_user.as_deref(), Some("user@gmail.com"));
561        assert_eq!(config.smtp_pass.as_deref(), Some("app-password"));
562    }
563
564    #[test]
565    fn yaml_deserialize_smtp_defaults_when_omitted() {
566        let yaml = "api_timeout: 30\n";
567        let config: DolConfig = serde_yaml::from_str(yaml).expect("deserialize");
568        assert!(config.smtp_host.is_none());
569        assert!(config.smtp_port.is_none());
570        assert!(config.smtp_user.is_none());
571        assert!(config.smtp_pass.is_none());
572    }
573
574    #[test]
575    fn config_set_all_smtp_keys_roundtrip_through_yaml() {
576        // Simulate: for each SMTP key, create a config with that key set,
577        // serialize to YAML, deserialize back, and verify the value is preserved.
578        // This validates that all 4 SMTP keys are properly handled by serde.
579        let cases = [
580            ("smtp_host", "smtp.gmail.com"),
581            ("smtp_port", "587"),
582            ("smtp_user", "user@gmail.com"),
583            ("smtp_pass", "app-password"),
584        ];
585
586        for &(key, value) in &cases {
587            let yaml = format!("{key}: {value}\n");
588            let config: DolConfig = serde_yaml::from_str(&yaml)
589                .unwrap_or_else(|e| panic!("failed to deserialize {key}: {e}"));
590
591            match key {
592                "smtp_host" => assert_eq!(config.smtp_host.as_deref(), Some(value)),
593                "smtp_port" => {
594                    let expected: u16 = value.parse().unwrap();
595                    assert_eq!(config.smtp_port, Some(expected));
596                }
597                "smtp_user" => assert_eq!(config.smtp_user.as_deref(), Some(value)),
598                "smtp_pass" => assert_eq!(config.smtp_pass.as_deref(), Some(value)),
599                _ => panic!("unexpected key: {key}"),
600            }
601        }
602    }
603
604    #[test]
605    fn toml_roundtrip_with_smtp() {
606        let config = DolConfig {
607            smtp_host: Some("mail.example.com".to_owned()),
608            smtp_port: Some(465),
609            ..DolConfig::default()
610        };
611        let toml_str = toml::to_string(&config).expect("serialize");
612        let deserialized: DolConfig = toml::from_str(&toml_str).expect("deserialize");
613        assert_eq!(deserialized.smtp_host.as_deref(), Some("mail.example.com"));
614        assert_eq!(deserialized.smtp_port, Some(465));
615        assert!(deserialized.smtp_user.is_none());
616        assert!(deserialized.smtp_pass.is_none());
617    }
618
619    // ── Analysis threshold config fields ──────────────────────────────
620
621    #[test]
622    fn default_analysis_threshold_fields_are_none() {
623        let config = DolConfig::default();
624        assert!(config.analysis_high_cpu_percent.is_none());
625        assert!(config.analysis_critical_cpu_percent.is_none());
626        assert!(config.analysis_memory_pressure_ratio.is_none());
627        assert!(config.analysis_critical_memory_ratio.is_none());
628        assert!(config.analysis_restart_loop_count.is_none());
629        assert!(config.analysis_deployment_error_threshold.is_none());
630        assert!(config.analysis_leak_memory_increase_pct.is_none());
631        assert!(config.analysis_leak_min_samples.is_none());
632        assert!(config.analysis_high_network_bytes.is_none());
633        assert!(config.analysis_critical_network_bytes.is_none());
634        assert!(config.analysis_high_disk_bytes.is_none());
635        assert!(config.analysis_critical_disk_bytes.is_none());
636    }
637
638    #[test]
639    fn yaml_roundtrip_with_analysis_thresholds() {
640        let config = DolConfig {
641            analysis_high_cpu_percent: Some(85.0),
642            analysis_critical_cpu_percent: Some(98.0),
643            analysis_memory_pressure_ratio: Some(0.90),
644            analysis_critical_memory_ratio: Some(0.99),
645            analysis_restart_loop_count: Some(5),
646            analysis_deployment_error_threshold: Some(10),
647            analysis_leak_memory_increase_pct: Some(30.0),
648            analysis_leak_min_samples: Some(5),
649            analysis_high_network_bytes: Some(2_097_152),
650            analysis_critical_network_bytes: Some(20_971_520),
651            analysis_high_disk_bytes: Some(20_971_520),
652            analysis_critical_disk_bytes: Some(209_715_200),
653            ..DolConfig::default()
654        };
655        let yaml = serde_yaml::to_string(&config).expect("serialize");
656        let deserialized: DolConfig = serde_yaml::from_str(&yaml).expect("deserialize");
657        assert_eq!(deserialized.analysis_high_cpu_percent, Some(85.0));
658        assert_eq!(deserialized.analysis_critical_cpu_percent, Some(98.0));
659        assert_eq!(deserialized.analysis_memory_pressure_ratio, Some(0.90));
660        assert_eq!(deserialized.analysis_critical_memory_ratio, Some(0.99));
661        assert_eq!(deserialized.analysis_restart_loop_count, Some(5));
662        assert_eq!(deserialized.analysis_deployment_error_threshold, Some(10));
663        assert_eq!(deserialized.analysis_leak_memory_increase_pct, Some(30.0));
664        assert_eq!(deserialized.analysis_leak_min_samples, Some(5));
665        assert_eq!(deserialized.analysis_high_network_bytes, Some(2_097_152));
666        assert_eq!(
667            deserialized.analysis_critical_network_bytes,
668            Some(20_971_520)
669        );
670        assert_eq!(deserialized.analysis_high_disk_bytes, Some(20_971_520));
671        assert_eq!(deserialized.analysis_critical_disk_bytes, Some(209_715_200));
672    }
673
674    #[test]
675    fn yaml_deserialize_analysis_defaults_when_omitted() {
676        let yaml = "api_timeout: 30\n";
677        let config: DolConfig = serde_yaml::from_str(yaml).expect("deserialize");
678        assert!(config.analysis_high_cpu_percent.is_none());
679        assert!(config.analysis_critical_cpu_percent.is_none());
680        assert!(config.analysis_restart_loop_count.is_none());
681    }
682
683    #[test]
684    fn config_set_all_analysis_keys_roundtrip_through_yaml() {
685        let cases = [
686            ("analysis_high_cpu_percent", "85.0"),
687            ("analysis_critical_cpu_percent", "98.0"),
688            ("analysis_memory_pressure_ratio", "0.90"),
689            ("analysis_critical_memory_ratio", "0.99"),
690            ("analysis_restart_loop_count", "5"),
691            ("analysis_deployment_error_threshold", "10"),
692            ("analysis_leak_memory_increase_pct", "30.0"),
693            ("analysis_leak_min_samples", "5"),
694            ("analysis_high_network_bytes", "2097152"),
695            ("analysis_critical_network_bytes", "20971520"),
696            ("analysis_high_disk_bytes", "20971520"),
697            ("analysis_critical_disk_bytes", "209715200"),
698        ];
699
700        for &(key, value) in &cases {
701            let yaml = format!("{key}: {value}\n");
702            let config: DolConfig = serde_yaml::from_str(&yaml)
703                .unwrap_or_else(|e| panic!("failed to deserialize {key}: {e}"));
704
705            match key {
706                "analysis_high_cpu_percent" => {
707                    assert_eq!(config.analysis_high_cpu_percent, Some(85.0));
708                }
709                "analysis_critical_cpu_percent" => {
710                    assert_eq!(config.analysis_critical_cpu_percent, Some(98.0));
711                }
712                "analysis_memory_pressure_ratio" => {
713                    assert_eq!(config.analysis_memory_pressure_ratio, Some(0.90));
714                }
715                "analysis_critical_memory_ratio" => {
716                    assert_eq!(config.analysis_critical_memory_ratio, Some(0.99));
717                }
718                "analysis_restart_loop_count" => {
719                    assert_eq!(config.analysis_restart_loop_count, Some(5));
720                }
721                "analysis_deployment_error_threshold" => {
722                    assert_eq!(config.analysis_deployment_error_threshold, Some(10));
723                }
724                "analysis_leak_memory_increase_pct" => {
725                    assert_eq!(config.analysis_leak_memory_increase_pct, Some(30.0));
726                }
727                "analysis_leak_min_samples" => {
728                    assert_eq!(config.analysis_leak_min_samples, Some(5));
729                }
730                "analysis_high_network_bytes" => {
731                    assert_eq!(config.analysis_high_network_bytes, Some(2_097_152));
732                }
733                "analysis_critical_network_bytes" => {
734                    assert_eq!(config.analysis_critical_network_bytes, Some(20_971_520));
735                }
736                "analysis_high_disk_bytes" => {
737                    assert_eq!(config.analysis_high_disk_bytes, Some(20_971_520));
738                }
739                "analysis_critical_disk_bytes" => {
740                    assert_eq!(config.analysis_critical_disk_bytes, Some(209_715_200));
741                }
742                _ => panic!("unexpected key: {key}"),
743            }
744        }
745    }
746
747    #[test]
748    fn toml_roundtrip_with_analysis_thresholds() {
749        let config = DolConfig {
750            analysis_high_cpu_percent: Some(90.0),
751            analysis_restart_loop_count: Some(7),
752            ..DolConfig::default()
753        };
754        let toml_str = toml::to_string(&config).expect("serialize");
755        let deserialized: DolConfig = toml::from_str(&toml_str).expect("deserialize");
756        assert_eq!(deserialized.analysis_high_cpu_percent, Some(90.0));
757        assert_eq!(deserialized.analysis_restart_loop_count, Some(7));
758        assert!(deserialized.analysis_critical_cpu_percent.is_none());
759    }
760
761    #[test]
762    fn config_set_smtp_invalid_port_rejected() {
763        // Non-integer port value should fail to deserialize for smtp_port field.
764        let yaml = "smtp_port: not_a_number\n";
765        let result: Result<DolConfig, _> = serde_yaml::from_str(yaml);
766        assert!(
767            result.is_err(),
768            "expected deserialization error for invalid port"
769        );
770    }
771}