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    /// Center the focused node in the viewport.
63    Center,
64    /// Go to the first line.
65    First,
66    /// Go to the last visible line.
67    Last,
68    /// Open the jump picker: fuzzy-find a node by path and move focus to it.
69    Jump,
70    /// Hand the focused leaf's filesystem path to the platform's default
71    /// opener. Nodes without one — containers, JSON — are left alone.
72    Open,
73    /// Exit without printing anything.
74    Quit,
75    /// Toggle the keybinding panel. Bound to the reserved `?` key and not
76    /// nameable in config, so it has no entry in [`AppCommand::parse`].
77    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    /// The keybinding panel's description of this command.
116    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/// What a keybinding does.
151#[derive(Clone, PartialEq, Debug)]
152pub enum BindingAction {
153    /// Run a shell command; `$path` / `$relpath` env vars point at the focused node.
154    Sh(String),
155    /// Run an app command.
156    Cmd(AppCommand),
157}
158
159#[derive(Clone, PartialEq, Debug)]
160pub struct Binding {
161    pub action: BindingAction,
162    /// Optional panel description supplied by the user, stored verbatim; the
163    /// panel displays its cleaned first line.
164    pub help: Option<String>,
165    /// Exit the program after running (only meaningful for `sh`).
166    pub exit: bool,
167    /// Run in the background without suspending the TUI.
168    pub bg: bool,
169}
170
171#[derive(Clone, Debug, Default)]
172pub struct Config {
173    pub bindings: HashMap<Key, Binding>,
174}
175
176impl Config {
177    /// Parse a single TOML config document.
178    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            // Tables are keybindings; other top-level values are options
184            // (none defined yet; tolerated and ignored).
185            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    /// Merge `other` into `self`; bindings in `other` win.
194    pub fn merge(&mut self, other: Config) {
195        self.bindings.extend(other.bindings);
196    }
197
198    /// Load and merge the given config files in order (later files win).
199    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    /// The default user config path: `$XDG_CONFIG_HOME/ite/config.toml`
211    /// (falling back to `~/.config/ite/config.toml`).
212    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
221/// TOML bare keys cannot contain `+`, but the config format wants headers like
222/// `[ctrl+e]`. Quote the inside of any unquoted table header so both `[ctrl+e]`
223/// and `["ctrl+e"]` parse.
224fn 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        // Cleaning for display is the panel's job, not the parser's.
330        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        // Top-level scalar keys are options; unknown ones are ignored for now.
377        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}