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(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 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 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 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
140fn 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); assert!(s.forward_open); assert!(s.no_escape); }
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 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 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}