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

            
16
use cli_core::{CommandNode, find_leaf};
17

            
18
#[derive(Debug, PartialEq, Eq)]
19
pub 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]
26
12
pub fn parse(input: &str) -> PaletteQuery {
27
12
    let mut path = Vec::new();
28
12
    let mut args = Vec::new();
29
21
    for token in input.split_whitespace() {
30
21
        if let Some((k, v)) = token.split_once('=') {
31
6
            args.push((k.to_string(), v.to_string()));
32
15
        } else {
33
15
            path.push(token.to_string());
34
15
        }
35
    }
36
12
    PaletteQuery { path, args }
37
12
}
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]
43
8
pub fn resolve<'a>(tree: &'a [CommandNode], query: &PaletteQuery) -> Option<&'a CommandNode> {
44
8
    let path_refs: Vec<&str> = query.path.iter().map(String::as_str).collect();
45
8
    find_leaf(tree, &path_refs)
46
8
}
47

            
48
#[cfg(test)]
49
mod tests {
50
    use super::*;
51
    use cli_core::command_tree;
52

            
53
    #[test]
54
1
    fn parses_empty_input_to_empty_query() {
55
1
        let q = parse("");
56
1
        assert!(q.path.is_empty());
57
1
        assert!(q.args.is_empty());
58
1
    }
59

            
60
    #[test]
61
1
    fn parses_single_word_path() {
62
1
        let q = parse("version");
63
1
        assert_eq!(q.path, vec!["version"]);
64
1
        assert!(q.args.is_empty());
65
1
    }
66

            
67
    #[test]
68
1
    fn parses_nested_path() {
69
1
        let q = parse("reports balance");
70
1
        assert_eq!(q.path, vec!["reports", "balance"]);
71
1
    }
72

            
73
    #[test]
74
1
    fn parses_key_value_arguments() {
75
1
        let q = parse("reports balance from=2026-01-01 to=2026-04-30 chart=bar");
76
1
        assert_eq!(q.path, vec!["reports", "balance"]);
77
1
        assert_eq!(q.args.len(), 3);
78
1
        assert_eq!(q.args[0], ("from".to_string(), "2026-01-01".to_string()));
79
1
        assert_eq!(q.args[2], ("chart".to_string(), "bar".to_string()));
80
1
    }
81

            
82
    #[test]
83
1
    fn resolves_version_leaf() {
84
1
        let tree = command_tree();
85
1
        let q = parse("version");
86
1
        let leaf = resolve(&tree, &q).expect("version resolves");
87
1
        assert_eq!(leaf.name, "version");
88
1
        assert!(leaf.command.is_some());
89
1
    }
90

            
91
    #[test]
92
1
    fn resolves_reports_balance_leaf() {
93
1
        let tree = command_tree();
94
1
        let q = parse("reports balance from=2026-01-01");
95
1
        let leaf = resolve(&tree, &q).expect("reports balance resolves");
96
1
        assert_eq!(leaf.name, "balance");
97
1
        assert!(leaf.command.is_some());
98
1
    }
99

            
100
    #[test]
101
1
    fn rejects_unknown_path() {
102
1
        let tree = command_tree();
103
1
        let q = parse("nope");
104
1
        assert!(resolve(&tree, &q).is_none());
105
1
    }
106

            
107
    #[test]
108
1
    fn rejects_group_without_command() {
109
1
        let tree = command_tree();
110
1
        let q = parse("reports");
111
1
        assert!(resolve(&tree, &q).is_none());
112
1
    }
113
}