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