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