Skip to main content

tui/
palette.rs

1//! `:`-driven command palette. Parses a typed command string into
2//! (path, args), looks the path up in the shared [`cli_core`] command
3//! tree, and returns the runnable leaf the event layer should execute.
4//!
5//! Grammar (matches the TUI's prior single-pane mode so automation and
6//! interactive grammar agree):
7//!
8//! ```text
9//! word (word)* (key=value)*
10//! ```
11//!
12//! `word` tokens identify the path (e.g. `reports balance`). `key=value`
13//! tokens become arguments. Values with embedded spaces are not
14//! currently supported — this matches the existing CLI grammar.
15
16use cli_core::{CommandNode, find_leaf};
17
18#[derive(Debug, PartialEq, Eq)]
19pub struct PaletteQuery {
20    pub path: Vec<String>,
21    pub args: Vec<(String, String)>,
22}
23
24/// Parse `input` into (path, args). Empty input yields an empty query.
25#[must_use]
26pub fn parse(input: &str) -> PaletteQuery {
27    let mut path = Vec::new();
28    let mut args = Vec::new();
29    for token in input.split_whitespace() {
30        if let Some((k, v)) = token.split_once('=') {
31            args.push((k.to_string(), v.to_string()));
32        } else {
33            path.push(token.to_string());
34        }
35    }
36    PaletteQuery { path, args }
37}
38
39/// Resolve a parsed query against the command tree. Returns the leaf
40/// node's name and its argument list if the path is complete, or
41/// `None` when the path refers to a group without a runnable command.
42#[must_use]
43pub fn resolve<'a>(tree: &'a [CommandNode], query: &PaletteQuery) -> Option<&'a CommandNode> {
44    let path_refs: Vec<&str> = query.path.iter().map(String::as_str).collect();
45    find_leaf(tree, &path_refs)
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use cli_core::command_tree;
52
53    #[test]
54    fn parses_empty_input_to_empty_query() {
55        let q = parse("");
56        assert!(q.path.is_empty());
57        assert!(q.args.is_empty());
58    }
59
60    #[test]
61    fn parses_single_word_path() {
62        let q = parse("version");
63        assert_eq!(q.path, vec!["version"]);
64        assert!(q.args.is_empty());
65    }
66
67    #[test]
68    fn parses_nested_path() {
69        let q = parse("reports balance");
70        assert_eq!(q.path, vec!["reports", "balance"]);
71    }
72
73    #[test]
74    fn parses_key_value_arguments() {
75        let q = parse("reports balance from=2026-01-01 to=2026-04-30 chart=bar");
76        assert_eq!(q.path, vec!["reports", "balance"]);
77        assert_eq!(q.args.len(), 3);
78        assert_eq!(q.args[0], ("from".to_string(), "2026-01-01".to_string()));
79        assert_eq!(q.args[2], ("chart".to_string(), "bar".to_string()));
80    }
81
82    #[test]
83    fn resolves_version_leaf() {
84        let tree = command_tree();
85        let q = parse("version");
86        let leaf = resolve(&tree, &q).expect("version resolves");
87        assert_eq!(leaf.name, "version");
88        assert!(leaf.command.is_some());
89    }
90
91    #[test]
92    fn resolves_reports_balance_leaf() {
93        let tree = command_tree();
94        let q = parse("reports balance from=2026-01-01");
95        let leaf = resolve(&tree, &q).expect("reports balance resolves");
96        assert_eq!(leaf.name, "balance");
97        assert!(leaf.command.is_some());
98    }
99
100    #[test]
101    fn rejects_unknown_path() {
102        let tree = command_tree();
103        let q = parse("nope");
104        assert!(resolve(&tree, &q).is_none());
105    }
106
107    #[test]
108    fn rejects_group_without_command() {
109        let tree = command_tree();
110        let q = parse("reports");
111        assert!(resolve(&tree, &q).is_none());
112    }
113}