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