Skip to main content

ite_cli/
config.rs

1//! User-configuration boundary and application-command vocabulary. It loads
2//! and merges TOML files, parses keybinding tables into actions, and names the
3//! commands that `app` can execute.
4//!
5//! Key syntax is delegated to `keys`; shell execution remains in `runner`.
6//! Before TOML parsing, table headers such as `[ctrl+e]` are quoted because `+`
7//! is not valid in a TOML bare key.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12use crate::keys::Key;
13
14/// A command understood by the application itself (the `cmd` binding kind).
15#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16pub enum AppCommand {
17    /// Move focus to the next on-screen line.
18    Down,
19    /// Move focus to the previous on-screen line.
20    Up,
21    /// Expand the focused non-leaf, focus its first child if already expanded,
22    /// or focus the next sibling of a leaf.
23    Expand,
24    /// Collapse the focused expanded branch, or focus its parent.
25    Collapse,
26    /// Expand the focused non-leaf recursively, or focus the next sibling of
27    /// a leaf.
28    ExpandRecursively,
29    /// Flip the focused container between expanded and collapsed.
30    Toggle,
31    /// Flip the focused container, expanding or collapsing recursively.
32    ToggleRecursively,
33    /// Collapse the focused expanded branch recursively, or collapse its
34    /// parent recursively and focus it.
35    CollapseRecursively,
36    /// Enter semantics: expand a non-leaf, run the default action on a leaf.
37    Select,
38    /// Run the default action regardless of leaf-ness.
39    Accept,
40    /// Run the source-specific alternate action regardless of leaf-ness.
41    AcceptAlternate,
42    /// Descend into a non-leaf, expanding it first if collapsed.
43    Descend,
44    /// Make the focused node the root of the visible tree.
45    Root,
46    /// Restore the previous visible root.
47    PopRoot,
48    /// Restore the previous visible root, or quit at the original forest.
49    Back,
50    /// Focus next sibling, skipping over expanded children.
51    NextSibling,
52    /// Focus previous sibling.
53    PrevSibling,
54    /// Scroll down one page.
55    PageDown,
56    /// Scroll up one page.
57    PageUp,
58    /// Scroll down half a page.
59    HalfPageDown,
60    /// Scroll up half a page.
61    HalfPageUp,
62    /// Go to the first line.
63    First,
64    /// Go to the last visible line.
65    Last,
66    /// Open the jump picker: fuzzy-find a node by path and move focus to it.
67    Jump,
68    /// Exit without printing anything.
69    Quit,
70    /// Toggle the keybinding panel. Bound to the reserved `?` key and not
71    /// nameable in config, so it has no entry in [`AppCommand::parse`].
72    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    /// The keybinding panel's description of this command.
109    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/// What a keybinding does.
142#[derive(Clone, PartialEq, Debug)]
143pub enum BindingAction {
144    /// Run a shell command; `$path` / `$relpath` env vars point at the focused node.
145    Sh(String),
146    /// Run an app command.
147    Cmd(AppCommand),
148}
149
150#[derive(Clone, PartialEq, Debug)]
151pub struct Binding {
152    pub action: BindingAction,
153    /// Optional panel description supplied by the user, stored verbatim; the
154    /// panel displays its cleaned first line.
155    pub help: Option<String>,
156    /// Exit the program after running (only meaningful for `sh`).
157    pub exit: bool,
158    /// Run in the background without suspending the TUI.
159    pub bg: bool,
160}
161
162#[derive(Clone, Debug, Default)]
163pub struct Config {
164    pub bindings: HashMap<Key, Binding>,
165}
166
167impl Config {
168    /// Parse a single TOML config document.
169    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            // Tables are keybindings; other top-level values are options
175            // (none defined yet; tolerated and ignored).
176            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    /// Merge `other` into `self`; bindings in `other` win.
185    pub fn merge(&mut self, other: Config) {
186        self.bindings.extend(other.bindings);
187    }
188
189    /// Load and merge the given config files in order (later files win).
190    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    /// The default user config path: `$XDG_CONFIG_HOME/ite/config.toml`
202    /// (falling back to `~/.config/ite/config.toml`).
203    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
212/// TOML bare keys cannot contain `+`, but the config format wants headers like
213/// `[ctrl+e]`. Quote the inside of any unquoted table header so both `[ctrl+e]`
214/// and `["ctrl+e"]` parse.
215fn 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        // Cleaning for display is the panel's job, not the parser's.
321        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        // Top-level scalar keys are options; unknown ones are ignored for now.
368        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}