Skip to main content

mockforge_tui/
config.rs

1//! Persistent configuration loaded from `~/.config/mockforge/tui.toml`.
2//!
3//! CLI arguments always override config file values.
4
5use std::path::PathBuf;
6
7use serde::{Deserialize, Serialize};
8
9/// Top-level TUI configuration.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(default)]
12pub struct TuiConfig {
13    /// Admin server URL (e.g. `http://localhost:9080`).
14    pub admin_url: String,
15
16    /// Dashboard refresh interval in seconds.
17    pub refresh_interval: u64,
18
19    /// Color theme: `"dark"` or `"light"`.
20    pub theme: String,
21
22    /// Last-used tab index (restored on startup).
23    pub last_tab: Option<usize>,
24
25    /// Optional log file path.
26    pub log_file: Option<String>,
27}
28
29impl Default for TuiConfig {
30    fn default() -> Self {
31        Self {
32            admin_url: "http://localhost:9080".into(),
33            refresh_interval: 2,
34            theme: "dark".into(),
35            last_tab: None,
36            log_file: None,
37        }
38    }
39}
40
41impl TuiConfig {
42    /// Resolve the config file path: `~/.config/mockforge/tui.toml`.
43    pub fn path() -> Option<PathBuf> {
44        home_dir().map(|h| h.join(".config").join("mockforge").join("tui.toml"))
45    }
46
47    /// Load config from the default path. Returns `Default` if the file
48    /// doesn't exist or can't be parsed.
49    pub fn load() -> Self {
50        Self::path()
51            .and_then(|p| std::fs::read_to_string(&p).ok())
52            .and_then(|s| toml::from_str(&s).ok())
53            .unwrap_or_default()
54    }
55
56    /// Save config to the default path, creating parent directories as needed.
57    pub fn save(&self) -> anyhow::Result<()> {
58        let path =
59            Self::path().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
60        if let Some(parent) = path.parent() {
61            std::fs::create_dir_all(parent)?;
62        }
63        let contents = toml::to_string_pretty(self)?;
64        std::fs::write(&path, contents)?;
65        Ok(())
66    }
67
68    /// Returns `true` if the theme is "light".
69    pub fn is_light_theme(&self) -> bool {
70        self.theme.eq_ignore_ascii_case("light")
71    }
72}
73
74/// Simple home directory lookup via `$HOME`.
75fn home_dir() -> Option<PathBuf> {
76    std::env::var_os("HOME").map(PathBuf::from)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn default_config_values() {
85        let cfg = TuiConfig::default();
86        assert_eq!(cfg.admin_url, "http://localhost:9080");
87        assert_eq!(cfg.refresh_interval, 2);
88        assert_eq!(cfg.theme, "dark");
89        assert!(cfg.last_tab.is_none());
90        assert!(cfg.log_file.is_none());
91    }
92
93    #[test]
94    fn deserialize_minimal_toml() {
95        let toml_str = r#"admin_url = "http://remote:9080""#;
96        let cfg: TuiConfig = toml::from_str(toml_str).unwrap();
97        assert_eq!(cfg.admin_url, "http://remote:9080");
98        // Defaults fill in the rest.
99        assert_eq!(cfg.refresh_interval, 2);
100        assert_eq!(cfg.theme, "dark");
101    }
102
103    #[test]
104    fn deserialize_full_toml() {
105        let toml_str = r#"
106admin_url = "http://prod:9090"
107refresh_interval = 5
108theme = "light"
109last_tab = 3
110log_file = "/tmp/tui.log"
111"#;
112        let cfg: TuiConfig = toml::from_str(toml_str).unwrap();
113        assert_eq!(cfg.admin_url, "http://prod:9090");
114        assert_eq!(cfg.refresh_interval, 5);
115        assert_eq!(cfg.theme, "light");
116        assert_eq!(cfg.last_tab, Some(3));
117        assert_eq!(cfg.log_file.as_deref(), Some("/tmp/tui.log"));
118    }
119
120    #[test]
121    fn roundtrip_serialize_deserialize() {
122        let cfg = TuiConfig {
123            admin_url: "http://test:8080".into(),
124            refresh_interval: 10,
125            theme: "light".into(),
126            last_tab: Some(5),
127            log_file: Some("/var/log/tui.log".into()),
128        };
129        let serialized = toml::to_string_pretty(&cfg).unwrap();
130        let deserialized: TuiConfig = toml::from_str(&serialized).unwrap();
131        assert_eq!(cfg, deserialized);
132    }
133
134    #[test]
135    fn is_light_theme_case_insensitive() {
136        let mut cfg = TuiConfig::default();
137        assert!(!cfg.is_light_theme());
138
139        cfg.theme = "light".into();
140        assert!(cfg.is_light_theme());
141
142        cfg.theme = "Light".into();
143        assert!(cfg.is_light_theme());
144
145        cfg.theme = "LIGHT".into();
146        assert!(cfg.is_light_theme());
147
148        cfg.theme = "dark".into();
149        assert!(!cfg.is_light_theme());
150    }
151
152    #[test]
153    fn unknown_fields_ignored() {
154        let toml_str = r#"
155admin_url = "http://localhost:9080"
156unknown_field = "should be ignored"
157"#;
158        let cfg: TuiConfig = toml::from_str(toml_str).unwrap();
159        assert_eq!(cfg.admin_url, "http://localhost:9080");
160    }
161
162    #[test]
163    fn config_path_is_under_home() {
164        // Only testable if $HOME is set.
165        if let Some(path) = TuiConfig::path() {
166            assert!(path.ends_with(".config/mockforge/tui.toml"));
167        }
168    }
169
170    #[test]
171    fn load_returns_default_when_no_file() {
172        // In test env, the config file almost certainly doesn't exist.
173        let cfg = TuiConfig::load();
174        assert_eq!(cfg, TuiConfig::default());
175    }
176}