1use std::collections::HashMap;
10use std::path::PathBuf;
11
12use crate::keys::Key;
13
14#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16pub enum AppCommand {
17 Down,
19 Up,
21 Expand,
24 Collapse,
26 ExpandRecursively,
29 Toggle,
31 ToggleRecursively,
33 CollapseRecursively,
36 Select,
38 Accept,
40 AcceptAlternate,
42 Descend,
44 Root,
46 PopRoot,
48 Back,
50 NextSibling,
52 PrevSibling,
54 PageDown,
56 PageUp,
58 HalfPageDown,
60 HalfPageUp,
62 First,
64 Last,
66 Jump,
68 Quit,
70 ToggleKeybindingPanel,
73}
74
75impl AppCommand {
76 pub fn parse(s: &str) -> Result<Self, String> {
77 let cmd = match s {
78 "down" => Self::Down,
79 "up" => Self::Up,
80 "expand" => Self::Expand,
81 "collapse" => Self::Collapse,
82 "expand-recursively" => Self::ExpandRecursively,
83 "toggle" => Self::Toggle,
84 "toggle-recursively" => Self::ToggleRecursively,
85 "collapse-recursively" => Self::CollapseRecursively,
86 "select" => Self::Select,
87 "accept" => Self::Accept,
88 "accept-alternate" => Self::AcceptAlternate,
89 "descend" => Self::Descend,
90 "root" => Self::Root,
91 "pop-root" => Self::PopRoot,
92 "back" => Self::Back,
93 "next-sibling" => Self::NextSibling,
94 "prev-sibling" => Self::PrevSibling,
95 "page-down" => Self::PageDown,
96 "page-up" => Self::PageUp,
97 "half-page-down" => Self::HalfPageDown,
98 "half-page-up" => Self::HalfPageUp,
99 "first" => Self::First,
100 "last" => Self::Last,
101 "jump" => Self::Jump,
102 "quit" => Self::Quit,
103 _ => return Err(format!("unknown app command: {s:?}")),
104 };
105 Ok(cmd)
106 }
107
108 pub fn description(self) -> &'static str {
110 match self {
111 Self::Down => "Down",
112 Self::Up => "Up",
113 Self::Expand => "Expand",
114 Self::Collapse => "Collapse",
115 Self::ExpandRecursively => "Expand all",
116 Self::Toggle => "Toggle",
117 Self::ToggleRecursively => "Toggle all",
118 Self::CollapseRecursively => "Collapse all",
119 Self::Select => "Select",
120 Self::Accept => "Accept",
121 Self::AcceptAlternate => "Accept alternate",
122 Self::Descend => "Descend",
123 Self::Root => "Root",
124 Self::PopRoot => "Previous root",
125 Self::Back => "Back",
126 Self::NextSibling => "Next sibling",
127 Self::PrevSibling => "Previous sibling",
128 Self::PageDown => "Page down",
129 Self::PageUp => "Page up",
130 Self::HalfPageDown => "Half page down",
131 Self::HalfPageUp => "Half page up",
132 Self::First => "First",
133 Self::Last => "Last",
134 Self::Jump => "Jump",
135 Self::Quit => "Quit",
136 Self::ToggleKeybindingPanel => "Shortcuts",
137 }
138 }
139}
140
141#[derive(Clone, PartialEq, Debug)]
143pub enum BindingAction {
144 Sh(String),
146 Cmd(AppCommand),
148}
149
150#[derive(Clone, PartialEq, Debug)]
151pub struct Binding {
152 pub action: BindingAction,
153 pub help: Option<String>,
156 pub exit: bool,
158 pub bg: bool,
160}
161
162#[derive(Clone, Debug, Default)]
163pub struct Config {
164 pub bindings: HashMap<Key, Binding>,
165}
166
167impl Config {
168 pub fn parse(toml_src: &str) -> Result<Self, String> {
170 let toml_src = quote_key_table_headers(toml_src);
171 let doc: toml::Table = toml_src.parse().map_err(|e| format!("invalid TOML: {e}"))?;
172 let mut bindings = HashMap::new();
173 for (name, value) in doc {
174 if let toml::Value::Table(table) = value {
177 let key = Key::parse(&name)?;
178 bindings.insert(key, parse_binding(&name, &table)?);
179 }
180 }
181 Ok(Self { bindings })
182 }
183
184 pub fn merge(&mut self, other: Config) {
186 self.bindings.extend(other.bindings);
187 }
188
189 pub fn load_files(paths: &[PathBuf]) -> Result<Self, String> {
191 let mut config = Self::default();
192 for path in paths {
193 let src = std::fs::read_to_string(path)
194 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
195 let parsed = Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))?;
196 config.merge(parsed);
197 }
198 Ok(config)
199 }
200
201 pub fn user_config_path() -> Option<PathBuf> {
204 let base = std::env::var_os("XDG_CONFIG_HOME")
205 .filter(|v| !v.is_empty())
206 .map(PathBuf::from)
207 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
208 Some(base.join("ite").join("config.toml"))
209 }
210}
211
212fn quote_key_table_headers(src: &str) -> String {
216 src.lines()
217 .map(|line| {
218 let trimmed = line.trim();
219 if let Some(inner) = trimmed
220 .strip_prefix('[')
221 .and_then(|rest| rest.strip_suffix(']'))
222 {
223 let inner = inner.trim();
224 if !inner.starts_with(['"', '\'']) {
225 return format!("[\"{inner}\"]");
226 }
227 }
228 line.to_string()
229 })
230 .collect::<Vec<_>>()
231 .join("\n")
232}
233
234fn parse_binding(key: &str, table: &toml::Table) -> Result<Binding, String> {
235 let sh = get_str(key, table, "sh")?;
236 let cmd = get_str(key, table, "cmd")?;
237 let help = get_str(key, table, "help")?;
238 let action = match (sh, cmd) {
239 (Some(sh), None) => BindingAction::Sh(sh),
240 (None, Some(cmd)) => BindingAction::Cmd(AppCommand::parse(&cmd)?),
241 (Some(_), Some(_)) => {
242 return Err(format!("[{key}]: `sh` and `cmd` are mutually exclusive"));
243 }
244 (None, None) => return Err(format!("[{key}]: needs either `sh` or `cmd`")),
245 };
246 Ok(Binding {
247 action,
248 help,
249 exit: get_bool(key, table, "exit")?.unwrap_or(false),
250 bg: get_bool(key, table, "bg")?.unwrap_or(false),
251 })
252}
253
254fn get_str(key: &str, table: &toml::Table, field: &str) -> Result<Option<String>, String> {
255 match table.get(field) {
256 None => Ok(None),
257 Some(toml::Value::String(s)) => Ok(Some(s.clone())),
258 Some(_) => Err(format!("[{key}]: `{field}` must be a string")),
259 }
260}
261
262fn get_bool(key: &str, table: &toml::Table, field: &str) -> Result<Option<bool>, String> {
263 match table.get(field) {
264 None => Ok(None),
265 Some(toml::Value::Boolean(b)) => Ok(Some(*b)),
266 Some(_) => Err(format!("[{key}]: `{field}` must be a boolean")),
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn parses_sh_binding_with_flags() {
276 let cfg = Config::parse(
277 r#"
278[ctrl+e]
279sh = "vim $path"
280exit = true
281"#,
282 )
283 .unwrap();
284 let b = &cfg.bindings[&Key::parse("ctrl+e").unwrap()];
285 assert_eq!(b.action, BindingAction::Sh("vim $path".into()));
286 assert!(b.exit);
287 assert!(!b.bg);
288 }
289
290 #[test]
291 fn parses_bg_binding() {
292 let cfg = Config::parse(
293 r#"
294[alt+s]
295sh = "some-command $relpath"
296bg = true
297"#,
298 )
299 .unwrap();
300 let b = &cfg.bindings[&Key::parse("alt+s").unwrap()];
301 assert!(b.bg);
302 assert!(!b.exit);
303 }
304
305 #[test]
306 fn parses_cmd_binding() {
307 let cfg = Config::parse(
308 r#"
309[ctrl+l]
310cmd = "expand-recursively"
311"#,
312 )
313 .unwrap();
314 let b = &cfg.bindings[&Key::parse("ctrl+l").unwrap()];
315 assert_eq!(b.action, BindingAction::Cmd(AppCommand::ExpandRecursively));
316 }
317
318 #[test]
319 fn parses_optional_help_verbatim() {
320 let cfg = Config::parse(
322 r#"
323[ctrl+l]
324cmd = "expand-recursively"
325help = " Custom\tCOPY\u0007\nignored"
326"#,
327 )
328 .unwrap();
329 let b = &cfg.bindings[&Key::parse("ctrl+l").unwrap()];
330 assert_eq!(b.help.as_deref(), Some(" Custom\tCOPY\u{7}\nignored"));
331 }
332
333 #[test]
334 fn help_must_be_a_string() {
335 let error = Config::parse("[x]\nsh = \"printf x\"\nhelp = true\n").unwrap_err();
336 assert!(error.contains("`help` must be a string"), "{error}");
337 }
338
339 #[test]
340 fn accepts_quoted_key_headers() {
341 let cfg = Config::parse("[\"ctrl+e\"]\nsh = \"x\"\n").unwrap();
342 assert!(cfg.bindings.contains_key(&Key::parse("ctrl+e").unwrap()));
343 }
344
345 #[test]
346 fn rejects_binding_with_both_sh_and_cmd() {
347 assert!(Config::parse("[ctrl+e]\nsh = \"x\"\ncmd = \"up\"\n").is_err());
348 }
349
350 #[test]
351 fn rejects_binding_with_neither_sh_nor_cmd() {
352 assert!(Config::parse("[ctrl+e]\nexit = true\n").is_err());
353 }
354
355 #[test]
356 fn rejects_bad_key_name() {
357 assert!(Config::parse("[bogus+e]\nsh = \"x\"\n").is_err());
358 }
359
360 #[test]
361 fn rejects_unknown_app_command() {
362 assert!(Config::parse("[ctrl+e]\ncmd = \"frobnicate\"\n").is_err());
363 }
364
365 #[test]
366 fn tolerates_top_level_options() {
367 let cfg = Config::parse("some_option = false\n[ctrl+e]\nsh = \"x\"\n").unwrap();
369 assert_eq!(cfg.bindings.len(), 1);
370 }
371
372 #[test]
373 fn merge_later_wins() {
374 let mut a = Config::parse("[ctrl+e]\nsh = \"first\"\n").unwrap();
375 let b = Config::parse("[ctrl+e]\nsh = \"second\"\n[ctrl+x]\ncmd = \"quit\"\n").unwrap();
376 a.merge(b);
377 let key = Key::parse("ctrl+e").unwrap();
378 assert_eq!(a.bindings[&key].action, BindingAction::Sh("second".into()));
379 assert_eq!(a.bindings.len(), 2);
380 }
381
382 #[test]
383 fn app_command_names_parse() {
384 for (name, cmd) in [
385 ("down", AppCommand::Down),
386 ("up", AppCommand::Up),
387 ("expand", AppCommand::Expand),
388 ("collapse", AppCommand::Collapse),
389 ("expand-recursively", AppCommand::ExpandRecursively),
390 ("collapse-recursively", AppCommand::CollapseRecursively),
391 ("toggle", AppCommand::Toggle),
392 ("toggle-recursively", AppCommand::ToggleRecursively),
393 ("select", AppCommand::Select),
394 ("accept", AppCommand::Accept),
395 ("accept-alternate", AppCommand::AcceptAlternate),
396 ("descend", AppCommand::Descend),
397 ("root", AppCommand::Root),
398 ("pop-root", AppCommand::PopRoot),
399 ("back", AppCommand::Back),
400 ("next-sibling", AppCommand::NextSibling),
401 ("prev-sibling", AppCommand::PrevSibling),
402 ("page-down", AppCommand::PageDown),
403 ("page-up", AppCommand::PageUp),
404 ("half-page-down", AppCommand::HalfPageDown),
405 ("half-page-up", AppCommand::HalfPageUp),
406 ("first", AppCommand::First),
407 ("last", AppCommand::Last),
408 ("jump", AppCommand::Jump),
409 ("quit", AppCommand::Quit),
410 ] {
411 assert_eq!(AppCommand::parse(name).unwrap(), cmd, "{name}");
412 }
413 }
414
415 #[test]
416 fn toggle_commands_have_keybinding_panel_descriptions() {
417 assert_eq!(AppCommand::Toggle.description(), "Toggle");
418 assert_eq!(AppCommand::ToggleRecursively.description(), "Toggle all");
419 }
420
421 #[test]
422 fn load_files_merges_in_order() {
423 let dir = tempfile::tempdir().unwrap();
424 let p1 = dir.path().join("a.toml");
425 let p2 = dir.path().join("b.toml");
426 std::fs::write(&p1, "[ctrl+e]\nsh = \"first\"\n").unwrap();
427 std::fs::write(&p2, "[ctrl+e]\nsh = \"second\"\n").unwrap();
428 let cfg = Config::load_files(&[p1, p2]).unwrap();
429 let key = Key::parse("ctrl+e").unwrap();
430 assert_eq!(
431 cfg.bindings[&key].action,
432 BindingAction::Sh("second".into())
433 );
434 }
435}