1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6pub const DEFAULT_CONFIG: &str = include_str!("../config.toml");
8
9#[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#[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#[derive(Debug, Clone, Default, Deserialize)]
28#[serde(default)]
29pub struct ConfigFile {
30 pub defaults: Defaults,
31 pub host: HashMap<String, HostConfig>,
32}
33
34#[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#[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#[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
64pub 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 pub fn load() -> Self {
78 Self::load_from(&config_path())
79 }
80
81 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 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 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 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
136fn 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); assert!(s.forward_open); assert!(s.no_escape); }
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 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 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}