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    /// Hand the focused leaf's filesystem path to the platform's default
69    /// opener. Nodes without one — containers, JSON — are left alone.
70    Open,
71    /// Exit without printing anything.
72    Quit,
73    /// Toggle the keybinding panel. Bound to the reserved `?` key and not
74    /// nameable in config, so it has no entry in [`AppCommand::parse`].
75    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    /// The keybinding panel's description of this command.
113    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/// What a keybinding does.
147#[derive(Clone, PartialEq, Debug)]
148pub enum BindingAction {
149    /// Run a shell command; `$path` / `$relpath` env vars point at the focused node.
150    Sh(String),
151    /// Run an app command.
152    Cmd(AppCommand),
153}
154
155#[derive(Clone, PartialEq, Debug)]
156pub struct Binding {
157    pub action: BindingAction,
158    /// Optional panel description supplied by the user, stored verbatim; the
159    /// panel displays its cleaned first line.
160    pub help: Option<String>,
161    /// Exit the program after running (only meaningful for `sh`).
162    pub exit: bool,
163    /// Run in the background without suspending the TUI.
164    pub bg: bool,
165}
166
167#[derive(Clone, Debug, Default)]
168pub struct Config {
169    pub bindings: HashMap<Key, Binding>,
170}
171
172impl Config {
173    /// Parse a single TOML config document.
174    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            // Tables are keybindings; other top-level values are options
180            // (none defined yet; tolerated and ignored).
181            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    /// Merge `other` into `self`; bindings in `other` win.
190    pub fn merge(&mut self, other: Config) {
191        self.bindings.extend(other.bindings);
192    }
193
194    /// Load and merge the given config files in order (later files win).
195    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    /// The default user config path: `$XDG_CONFIG_HOME/ite/config.toml`
207    /// (falling back to `~/.config/ite/config.toml`).
208    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
217/// TOML bare keys cannot contain `+`, but the config format wants headers like
218/// `[ctrl+e]`. Quote the inside of any unquoted table header so both `[ctrl+e]`
219/// and `["ctrl+e"]` parse.
220fn 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        // Cleaning for display is the panel's job, not the parser's.
326        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        // Top-level scalar keys are options; unknown ones are ignored for now.
373        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}