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

            
48
/// Return the longest string that is a common prefix of all `candidates`.
49
/// Returns an empty string when `candidates` is empty or contains no common prefix.
50
#[must_use]
51
11
pub fn longest_common_prefix(candidates: &[String]) -> String {
52
11
    match candidates {
53
11
        [] => String::new(),
54
2
        [one] => one.clone(),
55
7
        [first, rest @ ..] => {
56
13
            let len = rest.iter().fold(first.len(), |n, s| {
57
13
                first
58
13
                    .bytes()
59
13
                    .zip(s.bytes())
60
19
                    .take_while(|(a, b)| a == b)
61
13
                    .count()
62
13
                    .min(n)
63
13
            });
64
7
            first[..len].to_string()
65
        }
66
    }
67
11
}
68

            
69
#[cfg(test)]
70
mod tests {
71
    use super::*;
72
    use cli_core::command_tree;
73

            
74
    #[test]
75
1
    fn parses_empty_input_to_empty_query() {
76
1
        let q = parse("");
77
1
        assert!(q.path.is_empty());
78
1
        assert!(q.args.is_empty());
79
1
    }
80

            
81
    #[test]
82
1
    fn parses_single_word_path() {
83
1
        let q = parse("version");
84
1
        assert_eq!(q.path, vec!["version"]);
85
1
        assert!(q.args.is_empty());
86
1
    }
87

            
88
    #[test]
89
1
    fn parses_nested_path() {
90
1
        let q = parse("reports balance");
91
1
        assert_eq!(q.path, vec!["reports", "balance"]);
92
1
    }
93

            
94
    #[test]
95
1
    fn parses_key_value_arguments() {
96
1
        let q = parse("reports balance from=2026-01-01 to=2026-04-30 chart=bar");
97
1
        assert_eq!(q.path, vec!["reports", "balance"]);
98
1
        assert_eq!(q.args.len(), 3);
99
1
        assert_eq!(q.args[0], ("from".to_string(), "2026-01-01".to_string()));
100
1
        assert_eq!(q.args[2], ("chart".to_string(), "bar".to_string()));
101
1
    }
102

            
103
    #[test]
104
1
    fn resolves_version_leaf() {
105
1
        let tree = command_tree();
106
1
        let q = parse("version");
107
1
        let leaf = resolve(&tree, &q).expect("version resolves");
108
1
        assert_eq!(leaf.name, "version");
109
1
        assert!(leaf.is_leaf);
110
1
    }
111

            
112
    #[test]
113
1
    fn resolves_reports_balance_leaf() {
114
1
        let tree = command_tree();
115
1
        let q = parse("reports balance from=2026-01-01");
116
1
        let leaf = resolve(&tree, &q).expect("reports balance resolves");
117
1
        assert_eq!(leaf.name, "balance");
118
1
        assert!(leaf.is_leaf);
119
1
    }
120

            
121
    #[test]
122
1
    fn rejects_unknown_path() {
123
1
        let tree = command_tree();
124
1
        let q = parse("nope");
125
1
        assert!(resolve(&tree, &q).is_none());
126
1
    }
127

            
128
    #[test]
129
1
    fn rejects_group_without_command() {
130
1
        let tree = command_tree();
131
1
        let q = parse("reports");
132
1
        assert!(resolve(&tree, &q).is_none());
133
1
    }
134

            
135
    #[test]
136
1
    fn lcp_empty_candidates_returns_empty() {
137
1
        assert_eq!(longest_common_prefix(&[]), "");
138
1
    }
139

            
140
    #[test]
141
1
    fn lcp_single_candidate_returns_it() {
142
1
        assert_eq!(longest_common_prefix(&["account".to_string()]), "account");
143
1
    }
144

            
145
    #[test]
146
1
    fn lcp_shared_prefix() {
147
1
        let cands = vec!["create".to_string(), "commodity".to_string()];
148
1
        assert_eq!(longest_common_prefix(&cands), "c");
149
1
    }
150

            
151
    #[test]
152
1
    fn lcp_no_shared_prefix() {
153
1
        let cands = vec!["account".to_string(), "transaction".to_string()];
154
1
        assert_eq!(longest_common_prefix(&cands), "");
155
1
    }
156

            
157
    #[test]
158
1
    fn lcp_identical_candidates() {
159
1
        let cands = vec!["list".to_string(), "list".to_string()];
160
1
        assert_eq!(longest_common_prefix(&cands), "list");
161
1
    }
162
}