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/// 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]
51pub fn longest_common_prefix(candidates: &[String]) -> String {
52    match candidates {
53        [] => String::new(),
54        [one] => one.clone(),
55        [first, rest @ ..] => {
56            let len = rest.iter().fold(first.len(), |n, s| {
57                first
58                    .bytes()
59                    .zip(s.bytes())
60                    .take_while(|(a, b)| a == b)
61                    .count()
62                    .min(n)
63            });
64            first[..len].to_string()
65        }
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use cli_core::command_tree;
73
74    #[test]
75    fn parses_empty_input_to_empty_query() {
76        let q = parse("");
77        assert!(q.path.is_empty());
78        assert!(q.args.is_empty());
79    }
80
81    #[test]
82    fn parses_single_word_path() {
83        let q = parse("version");
84        assert_eq!(q.path, vec!["version"]);
85        assert!(q.args.is_empty());
86    }
87
88    #[test]
89    fn parses_nested_path() {
90        let q = parse("reports balance");
91        assert_eq!(q.path, vec!["reports", "balance"]);
92    }
93
94    #[test]
95    fn parses_key_value_arguments() {
96        let q = parse("reports balance from=2026-01-01 to=2026-04-30 chart=bar");
97        assert_eq!(q.path, vec!["reports", "balance"]);
98        assert_eq!(q.args.len(), 3);
99        assert_eq!(q.args[0], ("from".to_string(), "2026-01-01".to_string()));
100        assert_eq!(q.args[2], ("chart".to_string(), "bar".to_string()));
101    }
102
103    #[test]
104    fn resolves_version_leaf() {
105        let tree = command_tree();
106        let q = parse("version");
107        let leaf = resolve(&tree, &q).expect("version resolves");
108        assert_eq!(leaf.name, "version");
109        assert!(leaf.is_leaf);
110    }
111
112    #[test]
113    fn resolves_reports_balance_leaf() {
114        let tree = command_tree();
115        let q = parse("reports balance from=2026-01-01");
116        let leaf = resolve(&tree, &q).expect("reports balance resolves");
117        assert_eq!(leaf.name, "balance");
118        assert!(leaf.is_leaf);
119    }
120
121    #[test]
122    fn rejects_unknown_path() {
123        let tree = command_tree();
124        let q = parse("nope");
125        assert!(resolve(&tree, &q).is_none());
126    }
127
128    #[test]
129    fn rejects_group_without_command() {
130        let tree = command_tree();
131        let q = parse("reports");
132        assert!(resolve(&tree, &q).is_none());
133    }
134
135    #[test]
136    fn lcp_empty_candidates_returns_empty() {
137        assert_eq!(longest_common_prefix(&[]), "");
138    }
139
140    #[test]
141    fn lcp_single_candidate_returns_it() {
142        assert_eq!(longest_common_prefix(&["account".to_string()]), "account");
143    }
144
145    #[test]
146    fn lcp_shared_prefix() {
147        let cands = vec!["create".to_string(), "commodity".to_string()];
148        assert_eq!(longest_common_prefix(&cands), "c");
149    }
150
151    #[test]
152    fn lcp_no_shared_prefix() {
153        let cands = vec!["account".to_string(), "transaction".to_string()];
154        assert_eq!(longest_common_prefix(&cands), "");
155    }
156
157    #[test]
158    fn lcp_identical_candidates() {
159        let cands = vec!["list".to_string(), "list".to_string()];
160        assert_eq!(longest_common_prefix(&cands), "list");
161    }
162}