Skip to main content

ite_cli/
config.rs

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