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(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
86            Err(e) => {
87                eprintln!("warning: cannot read config {}: {e}", path.display());
88                return Self::default();
89            }
90        };
91        match toml::from_str(&content) {
92            Ok(c) => c,
93            Err(e) => {
94                tracing::warn!("malformed config at {}: {e}", path.display());
95                Self::default()
96            }
97        }
98    }
99
100    /// Resolve session settings for a given host (or None for local).
101    pub fn resolve_session(&self, host: Option<&str>) -> SessionSettings {
102        let d = &self.defaults;
103        let h = host.and_then(|name| self.host.get(name));
104
105        SessionSettings {
106            forward_agent: pick(h.and_then(|h| h.forward_agent), d.forward_agent),
107            forward_open: pick(h.and_then(|h| h.forward_open), d.forward_open),
108            no_escape: pick(h.and_then(|h| h.no_escape), d.no_escape),
109            no_redraw: pick(h.and_then(|h| h.no_redraw), d.no_redraw),
110        }
111    }
112
113    /// Resolve connect settings for a given host.
114    pub fn resolve_connect(&self, host: &str) -> ConnectSettings {
115        let d = &self.defaults;
116        let dc = d.connect.as_ref();
117        let h = self.host.get(host);
118        let hc = h.and_then(|h| h.connect.as_ref());
119
120        // ssh-options: host-specific first, then defaults (SSH uses first-match)
121        let mut ssh_options = Vec::new();
122        if let Some(opts) = hc.and_then(|c| c.ssh_options.as_ref()) {
123            ssh_options.extend(opts.iter().cloned());
124        }
125        if let Some(opts) = dc.and_then(|c| c.ssh_options.as_ref()) {
126            ssh_options.extend(opts.iter().cloned());
127        }
128
129        ConnectSettings {
130            session: self.resolve_session(Some(host)),
131            ssh_options,
132            no_server_start: pick(
133                hc.and_then(|c| c.no_server_start),
134                dc.and_then(|c| c.no_server_start),
135            ),
136        }
137    }
138}
139
140/// Pick the most specific value: host override > default > false.
141fn pick(host_val: Option<bool>, default_val: Option<bool>) -> bool {
142    host_val.or(default_val).unwrap_or(false)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn empty_config_returns_defaults() {
151        let cfg: ConfigFile = toml::from_str("").unwrap();
152        let s = cfg.resolve_session(None);
153        assert_eq!(s, SessionSettings::default());
154    }
155
156    #[test]
157    fn defaults_apply_when_no_host() {
158        let cfg: ConfigFile = toml::from_str(
159            r#"
160            [defaults]
161            forward-agent = true
162            forward-open = true
163            "#,
164        )
165        .unwrap();
166        let s = cfg.resolve_session(None);
167        assert!(s.forward_agent);
168        assert!(s.forward_open);
169        assert!(!s.no_escape);
170    }
171
172    #[test]
173    fn host_overrides_defaults() {
174        let cfg: ConfigFile = toml::from_str(
175            r#"
176            [defaults]
177            forward-agent = true
178            forward-open = false
179
180            [host.devbox]
181            forward-agent = false
182            forward-open = true
183            "#,
184        )
185        .unwrap();
186        let s = cfg.resolve_session(Some("devbox"));
187        assert!(!s.forward_agent);
188        assert!(s.forward_open);
189    }
190
191    #[test]
192    fn unknown_host_uses_defaults() {
193        let cfg: ConfigFile = toml::from_str(
194            r#"
195            [defaults]
196            forward-agent = true
197
198            [host.devbox]
199            forward-open = true
200            "#,
201        )
202        .unwrap();
203        let s = cfg.resolve_session(Some("unknown"));
204        assert!(s.forward_agent);
205        assert!(!s.forward_open);
206    }
207
208    #[test]
209    fn host_partial_override_inherits_defaults() {
210        let cfg: ConfigFile = toml::from_str(
211            r#"
212            [defaults]
213            forward-agent = true
214            no-escape = true
215
216            [host.devbox]
217            forward-open = true
218            "#,
219        )
220        .unwrap();
221        let s = cfg.resolve_session(Some("devbox"));
222        assert!(s.forward_agent); // from defaults
223        assert!(s.forward_open); // from host
224        assert!(s.no_escape); // from defaults
225    }
226
227    #[test]
228    fn connect_settings_merge_ssh_options() {
229        let cfg: ConfigFile = toml::from_str(
230            r#"
231            [defaults.connect]
232            ssh-options = ["Compression=yes"]
233
234            [host.devbox.connect]
235            ssh-options = ["IdentityFile=~/.ssh/key"]
236            "#,
237        )
238        .unwrap();
239        let c = cfg.resolve_connect("devbox");
240        // Host-specific first, then defaults
241        assert_eq!(c.ssh_options, vec!["IdentityFile=~/.ssh/key", "Compression=yes"]);
242    }
243
244    #[test]
245    fn connect_settings_no_host_ssh_options() {
246        let cfg: ConfigFile = toml::from_str(
247            r#"
248            [defaults.connect]
249            ssh-options = ["Compression=yes"]
250            "#,
251        )
252        .unwrap();
253        let c = cfg.resolve_connect("unknown");
254        assert_eq!(c.ssh_options, vec!["Compression=yes"]);
255    }
256
257    #[test]
258    fn connect_no_server_start() {
259        let cfg: ConfigFile = toml::from_str(
260            r#"
261            [host.prod.connect]
262            no-server-start = true
263            "#,
264        )
265        .unwrap();
266        let c = cfg.resolve_connect("prod");
267        assert!(c.no_server_start);
268        assert!(!cfg.resolve_connect("devbox").no_server_start);
269    }
270
271    #[test]
272    fn missing_file_returns_default() {
273        let cfg = ConfigFile::load_from(std::path::Path::new("/nonexistent/config.toml"));
274        assert_eq!(cfg.resolve_session(None), SessionSettings::default());
275    }
276
277    #[test]
278    fn config_path_ends_with_expected_suffix() {
279        // Can't safely set env vars in tests (Rust 2024), but we can verify the
280        // function returns a path ending in gritty/config.toml
281        let p = config_path();
282        assert!(p.ends_with("gritty/config.toml"), "got: {}", p.display());
283    }
284
285    #[test]
286    fn no_redraw_configurable() {
287        let cfg: ConfigFile = toml::from_str(
288            r#"
289            [defaults]
290            no-redraw = true
291
292            [host.devbox]
293            no-redraw = false
294            "#,
295        )
296        .unwrap();
297        assert!(cfg.resolve_session(None).no_redraw);
298        assert!(cfg.resolve_session(Some("unknown")).no_redraw);
299        assert!(!cfg.resolve_session(Some("devbox")).no_redraw);
300    }
301
302    #[test]
303    fn unknown_keys_ignored() {
304        let cfg: ConfigFile = toml::from_str(
305            r#"
306            [defaults]
307            forward-agent = true
308            some-future-setting = "ignored"
309            "#,
310        )
311        .unwrap();
312        assert!(cfg.resolve_session(None).forward_agent);
313    }
314
315    #[test]
316    fn connect_session_settings_resolved() {
317        let cfg: ConfigFile = toml::from_str(
318            r#"
319            [defaults]
320            forward-agent = true
321
322            [host.devbox]
323            forward-open = true
324            "#,
325        )
326        .unwrap();
327        let c = cfg.resolve_connect("devbox");
328        assert!(c.session.forward_agent);
329        assert!(c.session.forward_open);
330    }
331}