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, or focus its first child if already expanded.
16    Expand,
17    /// Collapse the focused expanded branch, or focus its parent.
18    Collapse,
19    /// Expand the focused non-leaf recursively.
20    ExpandRecursively,
21    /// Collapse the focused non-leaf recursively.
22    CollapseRecursively,
23    /// Enter semantics: expand a non-leaf, run the default action on a leaf.
24    Select,
25    /// Run the default action regardless of leaf-ness.
26    Accept,
27    /// Run the source-specific alternate action regardless of leaf-ness.
28    AcceptAlternate,
29    /// Descend into a non-leaf, expanding it first if collapsed.
30    Descend,
31    /// Focus next sibling, skipping over expanded children.
32    NextSibling,
33    /// Focus previous sibling.
34    PrevSibling,
35    /// Scroll down one page.
36    PageDown,
37    /// Scroll up one page.
38    PageUp,
39    /// Scroll down half a page.
40    HalfPageDown,
41    /// Scroll up half a page.
42    HalfPageUp,
43    /// Go to the first line.
44    First,
45    /// Go to the last visible line.
46    Last,
47    /// Open the jump picker: fuzzy-find a node by path and move focus to it.
48    Jump,
49    /// Exit without printing anything.
50    Quit,
51}
52
53impl AppCommand {
54    pub fn parse(s: &str) -> Result<Self, String> {
55        let cmd = match s {
56            "down" => Self::Down,
57            "up" => Self::Up,
58            "expand" => Self::Expand,
59            "collapse" => Self::Collapse,
60            "expand-recursively" => Self::ExpandRecursively,
61            "collapse-recursively" => Self::CollapseRecursively,
62            "select" => Self::Select,
63            "accept" => Self::Accept,
64            "accept-alternate" => Self::AcceptAlternate,
65            "descend" => Self::Descend,
66            "next-sibling" => Self::NextSibling,
67            "prev-sibling" => Self::PrevSibling,
68            "page-down" => Self::PageDown,
69            "page-up" => Self::PageUp,
70            "half-page-down" => Self::HalfPageDown,
71            "half-page-up" => Self::HalfPageUp,
72            "first" => Self::First,
73            "last" => Self::Last,
74            "jump" => Self::Jump,
75            "quit" => Self::Quit,
76            _ => return Err(format!("unknown app command: {s:?}")),
77        };
78        Ok(cmd)
79    }
80}
81
82/// What a keybinding does.
83#[derive(Clone, PartialEq, Debug)]
84pub enum BindingAction {
85    /// Run a shell command; `$path` / `$relpath` env vars point at the focused node.
86    Sh(String),
87    /// Run an app command.
88    Cmd(AppCommand),
89}
90
91#[derive(Clone, PartialEq, Debug)]
92pub struct Binding {
93    pub action: BindingAction,
94    /// Exit the program after running (only meaningful for `sh`).
95    pub exit: bool,
96    /// Run in the background without suspending the TUI.
97    pub bg: bool,
98}
99
100#[derive(Clone, Debug, Default)]
101pub struct Config {
102    pub bindings: HashMap<Key, Binding>,
103}
104
105impl Config {
106    /// Parse a single TOML config document.
107    pub fn parse(toml_src: &str) -> Result<Self, String> {
108        let toml_src = quote_key_table_headers(toml_src);
109        let doc: toml::Table = toml_src.parse().map_err(|e| format!("invalid TOML: {e}"))?;
110        let mut bindings = HashMap::new();
111        for (name, value) in doc {
112            // Tables are keybindings; other top-level values are options
113            // (none defined yet; tolerated and ignored).
114            if let toml::Value::Table(table) = value {
115                let key = Key::parse(&name)?;
116                bindings.insert(key, parse_binding(&name, &table)?);
117            }
118        }
119        Ok(Self { bindings })
120    }
121
122    /// Merge `other` into `self`; bindings in `other` win.
123    pub fn merge(&mut self, other: Config) {
124        self.bindings.extend(other.bindings);
125    }
126
127    /// Load and merge the given config files in order (later files win).
128    pub fn load_files(paths: &[PathBuf]) -> Result<Self, String> {
129        let mut config = Self::default();
130        for path in paths {
131            let src = std::fs::read_to_string(path)
132                .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
133            let parsed = Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))?;
134            config.merge(parsed);
135        }
136        Ok(config)
137    }
138
139    /// The default user config path: `$XDG_CONFIG_HOME/ite/config.toml`
140    /// (falling back to `~/.config/ite/config.toml`).
141    pub fn user_config_path() -> Option<PathBuf> {
142        let base = std::env::var_os("XDG_CONFIG_HOME")
143            .filter(|v| !v.is_empty())
144            .map(PathBuf::from)
145            .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
146        Some(base.join("ite").join("config.toml"))
147    }
148}
149
150/// TOML bare keys cannot contain `+`, but the config format wants headers like
151/// `[ctrl+e]`. Quote the inside of any unquoted table header so both `[ctrl+e]`
152/// and `["ctrl+e"]` parse.
153fn quote_key_table_headers(src: &str) -> String {
154    src.lines()
155        .map(|line| {
156            let trimmed = line.trim();
157            if let Some(inner) = trimmed
158                .strip_prefix('[')
159                .and_then(|rest| rest.strip_suffix(']'))
160            {
161                let inner = inner.trim();
162                if !inner.starts_with(['"', '\'']) {
163                    return format!("[\"{inner}\"]");
164                }
165            }
166            line.to_string()
167        })
168        .collect::<Vec<_>>()
169        .join("\n")
170}
171
172fn parse_binding(key: &str, table: &toml::Table) -> Result<Binding, String> {
173    let sh = get_str(key, table, "sh")?;
174    let cmd = get_str(key, table, "cmd")?;
175    let action = match (sh, cmd) {
176        (Some(sh), None) => BindingAction::Sh(sh),
177        (None, Some(cmd)) => BindingAction::Cmd(AppCommand::parse(&cmd)?),
178        (Some(_), Some(_)) => {
179            return Err(format!("[{key}]: `sh` and `cmd` are mutually exclusive"));
180        }
181        (None, None) => return Err(format!("[{key}]: needs either `sh` or `cmd`")),
182    };
183    Ok(Binding {
184        action,
185        exit: get_bool(key, table, "exit")?.unwrap_or(false),
186        bg: get_bool(key, table, "bg")?.unwrap_or(false),
187    })
188}
189
190fn get_str(key: &str, table: &toml::Table, field: &str) -> Result<Option<String>, String> {
191    match table.get(field) {
192        None => Ok(None),
193        Some(toml::Value::String(s)) => Ok(Some(s.clone())),
194        Some(_) => Err(format!("[{key}]: `{field}` must be a string")),
195    }
196}
197
198fn get_bool(key: &str, table: &toml::Table, field: &str) -> Result<Option<bool>, String> {
199    match table.get(field) {
200        None => Ok(None),
201        Some(toml::Value::Boolean(b)) => Ok(Some(*b)),
202        Some(_) => Err(format!("[{key}]: `{field}` must be a boolean")),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn parses_sh_binding_with_flags() {
212        let cfg = Config::parse(
213            r#"
214[ctrl+e]
215sh = "vim $path"
216exit = true
217"#,
218        )
219        .unwrap();
220        let b = &cfg.bindings[&Key::parse("ctrl+e").unwrap()];
221        assert_eq!(b.action, BindingAction::Sh("vim $path".into()));
222        assert!(b.exit);
223        assert!(!b.bg);
224    }
225
226    #[test]
227    fn parses_bg_binding() {
228        let cfg = Config::parse(
229            r#"
230[alt+s]
231sh = "some-command $relpath"
232bg = true
233"#,
234        )
235        .unwrap();
236        let b = &cfg.bindings[&Key::parse("alt+s").unwrap()];
237        assert!(b.bg);
238        assert!(!b.exit);
239    }
240
241    #[test]
242    fn parses_cmd_binding() {
243        let cfg = Config::parse(
244            r#"
245[ctrl+l]
246cmd = "expand-recursively"
247"#,
248        )
249        .unwrap();
250        let b = &cfg.bindings[&Key::parse("ctrl+l").unwrap()];
251        assert_eq!(b.action, BindingAction::Cmd(AppCommand::ExpandRecursively));
252    }
253
254    #[test]
255    fn accepts_quoted_key_headers() {
256        let cfg = Config::parse("[\"ctrl+e\"]\nsh = \"x\"\n").unwrap();
257        assert!(cfg.bindings.contains_key(&Key::parse("ctrl+e").unwrap()));
258    }
259
260    #[test]
261    fn rejects_binding_with_both_sh_and_cmd() {
262        assert!(Config::parse("[ctrl+e]\nsh = \"x\"\ncmd = \"up\"\n").is_err());
263    }
264
265    #[test]
266    fn rejects_binding_with_neither_sh_nor_cmd() {
267        assert!(Config::parse("[ctrl+e]\nexit = true\n").is_err());
268    }
269
270    #[test]
271    fn rejects_bad_key_name() {
272        assert!(Config::parse("[bogus+e]\nsh = \"x\"\n").is_err());
273    }
274
275    #[test]
276    fn rejects_unknown_app_command() {
277        assert!(Config::parse("[ctrl+e]\ncmd = \"frobnicate\"\n").is_err());
278    }
279
280    #[test]
281    fn tolerates_top_level_options() {
282        // Top-level scalar keys are options; unknown ones are ignored for now.
283        let cfg = Config::parse("some_option = false\n[ctrl+e]\nsh = \"x\"\n").unwrap();
284        assert_eq!(cfg.bindings.len(), 1);
285    }
286
287    #[test]
288    fn merge_later_wins() {
289        let mut a = Config::parse("[ctrl+e]\nsh = \"first\"\n").unwrap();
290        let b = Config::parse("[ctrl+e]\nsh = \"second\"\n[ctrl+x]\ncmd = \"quit\"\n").unwrap();
291        a.merge(b);
292        let key = Key::parse("ctrl+e").unwrap();
293        assert_eq!(a.bindings[&key].action, BindingAction::Sh("second".into()));
294        assert_eq!(a.bindings.len(), 2);
295    }
296
297    #[test]
298    fn app_command_names_parse() {
299        for (name, cmd) in [
300            ("down", AppCommand::Down),
301            ("up", AppCommand::Up),
302            ("expand", AppCommand::Expand),
303            ("collapse", AppCommand::Collapse),
304            ("expand-recursively", AppCommand::ExpandRecursively),
305            ("collapse-recursively", AppCommand::CollapseRecursively),
306            ("select", AppCommand::Select),
307            ("accept", AppCommand::Accept),
308            ("accept-alternate", AppCommand::AcceptAlternate),
309            ("descend", AppCommand::Descend),
310            ("next-sibling", AppCommand::NextSibling),
311            ("prev-sibling", AppCommand::PrevSibling),
312            ("page-down", AppCommand::PageDown),
313            ("page-up", AppCommand::PageUp),
314            ("half-page-down", AppCommand::HalfPageDown),
315            ("half-page-up", AppCommand::HalfPageUp),
316            ("first", AppCommand::First),
317            ("last", AppCommand::Last),
318            ("jump", AppCommand::Jump),
319            ("quit", AppCommand::Quit),
320        ] {
321            assert_eq!(AppCommand::parse(name).unwrap(), cmd, "{name}");
322        }
323    }
324
325    #[test]
326    fn load_files_merges_in_order() {
327        let dir = tempfile::tempdir().unwrap();
328        let p1 = dir.path().join("a.toml");
329        let p2 = dir.path().join("b.toml");
330        std::fs::write(&p1, "[ctrl+e]\nsh = \"first\"\n").unwrap();
331        std::fs::write(&p2, "[ctrl+e]\nsh = \"second\"\n").unwrap();
332        let cfg = Config::load_files(&[p1, p2]).unwrap();
333        let key = Key::parse("ctrl+e").unwrap();
334        assert_eq!(
335            cfg.bindings[&key].action,
336            BindingAction::Sh("second".into())
337        );
338    }
339}