1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::keys::Key;
7
8#[derive(Clone, Copy, PartialEq, Eq, Debug)]
10pub enum AppCommand {
11 Down,
13 Up,
15 Expand,
17 Collapse,
19 ExpandRecursively,
21 CollapseRecursively,
23 Select,
25 Accept,
27 AcceptAlternate,
29 Descend,
31 NextSibling,
33 PrevSibling,
35 PageDown,
37 PageUp,
39 HalfPageDown,
41 HalfPageUp,
43 First,
45 Last,
47 Jump,
49 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#[derive(Clone, PartialEq, Debug)]
84pub enum BindingAction {
85 Sh(String),
87 Cmd(AppCommand),
89}
90
91#[derive(Clone, PartialEq, Debug)]
92pub struct Binding {
93 pub action: BindingAction,
94 pub exit: bool,
96 pub bg: bool,
98}
99
100#[derive(Clone, Debug, Default)]
101pub struct Config {
102 pub bindings: HashMap<Key, Binding>,
103}
104
105impl Config {
106 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 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 pub fn merge(&mut self, other: Config) {
124 self.bindings.extend(other.bindings);
125 }
126
127 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 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
150fn 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 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}