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    /// Round 37 (Srikanth on 0.3.181) — extra admin URLs the user can
29    /// swap between with `Ctrl-]` / `Ctrl-[`. `admin_url` is always the
30    /// first server (index 0); `extra_servers` add to the rotation in
31    /// order. Empty by default — the TUI behaves exactly as before when
32    /// this is empty.
33    #[serde(default)]
34    pub extra_servers: Vec<String>,
35}
36
37impl Default for TuiConfig {
38    fn default() -> Self {
39        Self {
40            admin_url: "http://localhost:9080".into(),
41            refresh_interval: 2,
42            theme: "dark".into(),
43            last_tab: None,
44            log_file: None,
45            extra_servers: Vec::new(),
46        }
47    }
48}
49
50impl TuiConfig {
51    /// Round 37 — flatten `admin_url` + `extra_servers` into the
52    /// ordered list of admin URLs the TUI rotates through. The
53    /// primary `admin_url` is always index 0 so existing single-server
54    /// behaviour is unchanged when `extra_servers` is empty.
55    pub fn all_admin_urls(&self) -> Vec<String> {
56        let mut urls = Vec::with_capacity(1 + self.extra_servers.len());
57        urls.push(self.admin_url.clone());
58        for u in &self.extra_servers {
59            if !u.is_empty() && !urls.iter().any(|existing| existing == u) {
60                urls.push(u.clone());
61            }
62        }
63        urls
64    }
65}
66
67impl TuiConfig {
68    /// Resolve the config file path: `~/.config/mockforge/tui.toml`.
69    pub fn path() -> Option<PathBuf> {
70        home_dir().map(|h| h.join(".config").join("mockforge").join("tui.toml"))
71    }
72
73    /// Load config from the default path. Returns `Default` if the file
74    /// doesn't exist or can't be parsed.
75    pub fn load() -> Self {
76        Self::path()
77            .and_then(|p| std::fs::read_to_string(&p).ok())
78            .and_then(|s| toml::from_str(&s).ok())
79            .unwrap_or_default()
80    }
81
82    /// Save config to the default path, creating parent directories as needed.
83    pub fn save(&self) -> anyhow::Result<()> {
84        let path =
85            Self::path().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
86        if let Some(parent) = path.parent() {
87            std::fs::create_dir_all(parent)?;
88        }
89        let contents = toml::to_string_pretty(self)?;
90        std::fs::write(&path, contents)?;
91        Ok(())
92    }
93
94    /// Returns `true` if the theme is "light".
95    pub fn is_light_theme(&self) -> bool {
96        self.theme.eq_ignore_ascii_case("light")
97    }
98}
99
100/// Simple home directory lookup via `$HOME`.
101fn home_dir() -> Option<PathBuf> {
102    std::env::var_os("HOME").map(PathBuf::from)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn default_config_values() {
111        let cfg = TuiConfig::default();
112        assert_eq!(cfg.admin_url, "http://localhost:9080");
113        assert_eq!(cfg.refresh_interval, 2);
114        assert_eq!(cfg.theme, "dark");
115        assert!(cfg.last_tab.is_none());
116        assert!(cfg.log_file.is_none());
117    }
118
119    #[test]
120    fn deserialize_minimal_toml() {
121        let toml_str = r#"admin_url = "http://remote:9080""#;
122        let cfg: TuiConfig = toml::from_str(toml_str).unwrap();
123        assert_eq!(cfg.admin_url, "http://remote:9080");
124        // Defaults fill in the rest.
125        assert_eq!(cfg.refresh_interval, 2);
126        assert_eq!(cfg.theme, "dark");
127    }
128
129    #[test]
130    fn deserialize_full_toml() {
131        let toml_str = r#"
132admin_url = "http://prod:9090"
133refresh_interval = 5
134theme = "light"
135last_tab = 3
136log_file = "/tmp/tui.log"
137"#;
138        let cfg: TuiConfig = toml::from_str(toml_str).unwrap();
139        assert_eq!(cfg.admin_url, "http://prod:9090");
140        assert_eq!(cfg.refresh_interval, 5);
141        assert_eq!(cfg.theme, "light");
142        assert_eq!(cfg.last_tab, Some(3));
143        assert_eq!(cfg.log_file.as_deref(), Some("/tmp/tui.log"));
144    }
145
146    #[test]
147    fn roundtrip_serialize_deserialize() {
148        let cfg = TuiConfig {
149            admin_url: "http://test:8080".into(),
150            refresh_interval: 10,
151            theme: "light".into(),
152            last_tab: Some(5),
153            log_file: Some("/var/log/tui.log".into()),
154            extra_servers: vec!["http://test2:8080".into()],
155        };
156        let serialized = toml::to_string_pretty(&cfg).unwrap();
157        let deserialized: TuiConfig = toml::from_str(&serialized).unwrap();
158        assert_eq!(cfg, deserialized);
159    }
160
161    #[test]
162    fn is_light_theme_case_insensitive() {
163        let mut cfg = TuiConfig::default();
164        assert!(!cfg.is_light_theme());
165
166        cfg.theme = "light".into();
167        assert!(cfg.is_light_theme());
168
169        cfg.theme = "Light".into();
170        assert!(cfg.is_light_theme());
171
172        cfg.theme = "LIGHT".into();
173        assert!(cfg.is_light_theme());
174
175        cfg.theme = "dark".into();
176        assert!(!cfg.is_light_theme());
177    }
178
179    #[test]
180    fn unknown_fields_ignored() {
181        let toml_str = r#"
182admin_url = "http://localhost:9080"
183unknown_field = "should be ignored"
184"#;
185        let cfg: TuiConfig = toml::from_str(toml_str).unwrap();
186        assert_eq!(cfg.admin_url, "http://localhost:9080");
187    }
188
189    #[test]
190    fn config_path_is_under_home() {
191        // Only testable if $HOME is set.
192        if let Some(path) = TuiConfig::path() {
193            assert!(path.ends_with(".config/mockforge/tui.toml"));
194        }
195    }
196
197    #[test]
198    fn load_returns_default_when_no_file() {
199        // In test env, the config file almost certainly doesn't exist.
200        let cfg = TuiConfig::load();
201        assert_eq!(cfg, TuiConfig::default());
202    }
203}