Skip to main content

gritty/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6/// Embedded default config template (from repo root config.toml).
7pub const DEFAULT_CONFIG: &str = include_str!("../config.toml");
8
9/// Resolved session settings after merging all config layers.
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct SessionSettings {
12    pub forward_agent: bool,
13    pub forward_open: bool,
14    pub no_escape: bool,
15    pub no_redraw: bool,
16}
17
18/// Resolved connect settings after merging all config layers.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct ConnectSettings {
21    pub session: SessionSettings,
22    pub ssh_options: Vec<String>,
23    pub no_server_start: bool,
24}
25
26/// Top-level config file structure.
27#[derive(Debug, Clone, Default, Deserialize)]
28#[serde(default)]
29pub struct ConfigFile {
30    pub defaults: Defaults,
31    pub host: HashMap<String, HostConfig>,
32}
33
34/// Global defaults section.
35#[derive(Debug, Clone, Default, Deserialize)]
36#[serde(default, rename_all = "kebab-case")]
37pub struct Defaults {
38    pub forward_agent: Option<bool>,
39    pub forward_open: Option<bool>,
40    pub no_escape: Option<bool>,
41    pub no_redraw: Option<bool>,
42    pub connect: Option<ConnectDefaults>,
43}
44
45/// Connect-specific defaults nested under [defaults.connect].
46#[derive(Debug, Clone, Default, Deserialize)]
47#[serde(default, rename_all = "kebab-case")]
48pub struct ConnectDefaults {
49    pub ssh_options: Option<Vec<String>>,
50    pub no_server_start: Option<bool>,
51}
52
53/// Per-host override section.
54#[derive(Debug, Clone, Default, Deserialize)]
55#[serde(default, rename_all = "kebab-case")]
56pub struct HostConfig {
57    pub forward_agent: Option<bool>,
58    pub forward_open: Option<bool>,
59    pub no_escape: Option<bool>,
60    pub no_redraw: Option<bool>,
61    pub connect: Option<ConnectDefaults>,
62}
63
64/// Return the config file path: $XDG_CONFIG_HOME/gritty/config.toml
65pub fn config_path() -> PathBuf {
66    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
67        return PathBuf::from(xdg).join("gritty").join("config.toml");
68    }
69    if let Ok(home) = std::env::var("HOME") {
70        return PathBuf::from(home).join(".config").join("gritty").join("config.toml");
71    }
72    PathBuf::from(".config").join("gritty").join("config.toml")
73}
74
75impl ConfigFile {
76    /// Load config from the default path. Returns default on missing or malformed file.
77    pub fn load() -> Self {
78        Self::load_from(&config_path())
79    }
80
81    /// Load config from a specific path. Returns default on missing or malformed file.
82    pub fn load_from(path: &std::path::Path) -> Self {
83        let content = match std::fs::read_to_string(path) {
84            Ok(c) => c,
85            Err(_) => return Self::default(),
86        };
87        match toml::from_str(&content) {
88            Ok(c) => c,
89            Err(e) => {
90                tracing::warn!("malformed config at {}: {e}", path.display());
91                Self::default()
92            }
93        }
94    }
95
96    /// Resolve session settings for a given host (or None for local).
97    pub fn resolve_session(&self, host: Option<&str>) -> SessionSettings {
98        let d = &self.defaults;
99        let h = host.and_then(|name| self.host.get(name));
100
101        SessionSettings {
102            forward_agent: pick(h.and_then(|h| h.forward_agent), d.forward_agent),
103            forward_open: pick(h.and_then(|h| h.forward_open), d.forward_open),
104            no_escape: pick(h.and_then(|h| h.no_escape), d.no_escape),
105            no_redraw: pick(h.and_then(|h| h.no_redraw), d.no_redraw),
106        }
107    }
108
109    /// Resolve connect settings for a given host.
110    pub fn resolve_connect(&self, host: &str) -> ConnectSettings {
111        let d = &self.defaults;
112        let dc = d.connect.as_ref();
113        let h = self.host.get(host);
114        let hc = h.and_then(|h| h.connect.as_ref());
115
116        // ssh-options: host-specific first, then defaults (SSH uses first-match)
117        let mut ssh_options = Vec::new();
118        if let Some(opts) = hc.and_then(|c| c.ssh_options.as_ref()) {
119            ssh_options.extend(opts.iter().cloned());
120        }
121        if let Some(opts) = dc.and_then(|c| c.ssh_options.as_ref()) {
122            ssh_options.extend(opts.iter().cloned());
123        }
124
125        ConnectSettings {
126            session: self.resolve_session(Some(host)),
127            ssh_options,
128            no_server_start: pick(
129                hc.and_then(|c| c.no_server_start),
130                dc.and_then(|c| c.no_server_start),
131            ),
132        }
133    }
134}
135
136/// Pick the most specific value: host override > default > false.
137fn pick(host_val: Option<bool>, default_val: Option<bool>) -> bool {
138    host_val.or(default_val).unwrap_or(false)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn empty_config_returns_defaults() {
147        let cfg: ConfigFile = toml::from_str("").unwrap();
148        let s = cfg.resolve_session(None);
149        assert_eq!(s, SessionSettings::default());
150    }
151
152    #[test]
153    fn defaults_apply_when_no_host() {
154        let cfg: ConfigFile = toml::from_str(
155            r#"
156            [defaults]
157            forward-agent = true
158            forward-open = true
159            "#,
160        )
161        .unwrap();
162        let s = cfg.resolve_session(None);
163        assert!(s.forward_agent);
164        assert!(s.forward_open);
165        assert!(!s.no_escape);
166    }
167
168    #[test]
169    fn host_overrides_defaults() {
170        let cfg: ConfigFile = toml::from_str(
171            r#"
172            [defaults]
173            forward-agent = true
174            forward-open = false
175
176            [host.devbox]
177            forward-agent = false
178            forward-open = true
179            "#,
180        )
181        .unwrap();
182        let s = cfg.resolve_session(Some("devbox"));
183        assert!(!s.forward_agent);
184        assert!(s.forward_open);
185    }
186
187    #[test]
188    fn unknown_host_uses_defaults() {
189        let cfg: ConfigFile = toml::from_str(
190            r#"
191            [defaults]
192            forward-agent = true
193
194            [host.devbox]
195            forward-open = true
196            "#,
197        )
198        .unwrap();
199        let s = cfg.resolve_session(Some("unknown"));
200        assert!(s.forward_agent);
201        assert!(!s.forward_open);
202    }
203
204    #[test]
205    fn host_partial_override_inherits_defaults() {
206        let cfg: ConfigFile = toml::from_str(
207            r#"
208            [defaults]
209            forward-agent = true
210            no-escape = true
211
212            [host.devbox]
213            forward-open = true
214            "#,
215        )
216        .unwrap();
217        let s = cfg.resolve_session(Some("devbox"));
218        assert!(s.forward_agent); // from defaults
219        assert!(s.forward_open); // from host
220        assert!(s.no_escape); // from defaults
221    }
222
223    #[test]
224    fn connect_settings_merge_ssh_options() {
225        let cfg: ConfigFile = toml::from_str(
226            r#"
227            [defaults.connect]
228            ssh-options = ["Compression=yes"]
229
230            [host.devbox.connect]
231            ssh-options = ["IdentityFile=~/.ssh/key"]
232            "#,
233        )
234        .unwrap();
235        let c = cfg.resolve_connect("devbox");
236        // Host-specific first, then defaults
237        assert_eq!(c.ssh_options, vec!["IdentityFile=~/.ssh/key", "Compression=yes"]);
238    }
239
240    #[test]
241    fn connect_settings_no_host_ssh_options() {
242        let cfg: ConfigFile = toml::from_str(
243            r#"
244            [defaults.connect]
245            ssh-options = ["Compression=yes"]
246            "#,
247        )
248        .unwrap();
249        let c = cfg.resolve_connect("unknown");
250        assert_eq!(c.ssh_options, vec!["Compression=yes"]);
251    }
252
253    #[test]
254    fn connect_no_server_start() {
255        let cfg: ConfigFile = toml::from_str(
256            r#"
257            [host.prod.connect]
258            no-server-start = true
259            "#,
260        )
261        .unwrap();
262        let c = cfg.resolve_connect("prod");
263        assert!(c.no_server_start);
264        assert!(!cfg.resolve_connect("devbox").no_server_start);
265    }
266
267    #[test]
268    fn missing_file_returns_default() {
269        let cfg = ConfigFile::load_from(std::path::Path::new("/nonexistent/config.toml"));
270        assert_eq!(cfg.resolve_session(None), SessionSettings::default());
271    }
272
273    #[test]
274    fn config_path_uses_xdg() {
275        // Can't safely set env vars in tests (Rust 2024), but we can verify the
276        // function returns a path ending in gritty/config.toml
277        let p = config_path();
278        assert!(p.ends_with("gritty/config.toml"), "got: {}", p.display());
279    }
280
281    #[test]
282    fn no_redraw_configurable() {
283        let cfg: ConfigFile = toml::from_str(
284            r#"
285            [defaults]
286            no-redraw = true
287
288            [host.devbox]
289            no-redraw = false
290            "#,
291        )
292        .unwrap();
293        assert!(cfg.resolve_session(None).no_redraw);
294        assert!(cfg.resolve_session(Some("unknown")).no_redraw);
295        assert!(!cfg.resolve_session(Some("devbox")).no_redraw);
296    }
297
298    #[test]
299    fn unknown_keys_ignored() {
300        let cfg: ConfigFile = toml::from_str(
301            r#"
302            [defaults]
303            forward-agent = true
304            some-future-setting = "ignored"
305            "#,
306        )
307        .unwrap();
308        assert!(cfg.resolve_session(None).forward_agent);
309    }
310
311    #[test]
312    fn connect_session_settings_resolved() {
313        let cfg: ConfigFile = toml::from_str(
314            r#"
315            [defaults]
316            forward-agent = true
317
318            [host.devbox]
319            forward-open = true
320            "#,
321        )
322        .unwrap();
323        let c = cfg.resolve_connect("devbox");
324        assert!(c.session.forward_agent);
325        assert!(c.session.forward_open);
326    }
327}