Lines
100 %
Functions
78.26 %
Branches
//! `:`-driven command palette. Parses a typed command string into
//! (path, args), looks the path up in the shared [`cli_core`] command
//! tree, and returns the runnable leaf the event layer should execute.
//!
//! Grammar (matches the TUI's prior single-pane mode so automation and
//! interactive grammar agree):
//! ```text
//! word (word)* (key=value)*
//! ```
//! `word` tokens identify the path (e.g. `reports balance`). `key=value`
//! tokens become arguments. Values with embedded spaces are not
//! currently supported — this matches the existing CLI grammar.
use cli_core::{CommandNode, find_leaf};
#[derive(Debug, PartialEq, Eq)]
pub struct PaletteQuery {
pub path: Vec<String>,
pub args: Vec<(String, String)>,
}
/// Parse `input` into (path, args). Empty input yields an empty query.
#[must_use]
pub fn parse(input: &str) -> PaletteQuery {
let mut path = Vec::new();
let mut args = Vec::new();
for token in input.split_whitespace() {
if let Some((k, v)) = token.split_once('=') {
args.push((k.to_string(), v.to_string()));
} else {
path.push(token.to_string());
PaletteQuery { path, args }
/// Resolve a parsed query against the command tree. Returns the leaf
/// node's name and its argument list if the path is complete, or
/// `None` when the path refers to a group without a runnable command.
pub fn resolve<'a>(tree: &'a [CommandNode], query: &PaletteQuery) -> Option<&'a CommandNode> {
let path_refs: Vec<&str> = query.path.iter().map(String::as_str).collect();
find_leaf(tree, &path_refs)
/// Return the longest string that is a common prefix of all `candidates`.
/// Returns an empty string when `candidates` is empty or contains no common prefix.
pub fn longest_common_prefix(candidates: &[String]) -> String {
match candidates {
[] => String::new(),
[one] => one.clone(),
[first, rest @ ..] => {
let len = rest.iter().fold(first.len(), |n, s| {
first
.bytes()
.zip(s.bytes())
.take_while(|(a, b)| a == b)
.count()
.min(n)
});
first[..len].to_string()
#[cfg(test)]
mod tests {
use super::*;
use cli_core::command_tree;
#[test]
fn parses_empty_input_to_empty_query() {
let q = parse("");
assert!(q.path.is_empty());
assert!(q.args.is_empty());
fn parses_single_word_path() {
let q = parse("version");
assert_eq!(q.path, vec!["version"]);
fn parses_nested_path() {
let q = parse("reports balance");
assert_eq!(q.path, vec!["reports", "balance"]);
fn parses_key_value_arguments() {
let q = parse("reports balance from=2026-01-01 to=2026-04-30 chart=bar");
assert_eq!(q.args.len(), 3);
assert_eq!(q.args[0], ("from".to_string(), "2026-01-01".to_string()));
assert_eq!(q.args[2], ("chart".to_string(), "bar".to_string()));
fn resolves_version_leaf() {
let tree = command_tree();
let leaf = resolve(&tree, &q).expect("version resolves");
assert_eq!(leaf.name, "version");
assert!(leaf.is_leaf);
fn resolves_reports_balance_leaf() {
let q = parse("reports balance from=2026-01-01");
let leaf = resolve(&tree, &q).expect("reports balance resolves");
assert_eq!(leaf.name, "balance");
fn rejects_unknown_path() {
let q = parse("nope");
assert!(resolve(&tree, &q).is_none());
fn rejects_group_without_command() {
let q = parse("reports");
fn lcp_empty_candidates_returns_empty() {
assert_eq!(longest_common_prefix(&[]), "");
fn lcp_single_candidate_returns_it() {
assert_eq!(longest_common_prefix(&["account".to_string()]), "account");
fn lcp_shared_prefix() {
let cands = vec!["create".to_string(), "commodity".to_string()];
assert_eq!(longest_common_prefix(&cands), "c");
fn lcp_no_shared_prefix() {
let cands = vec!["account".to_string(), "transaction".to_string()];
assert_eq!(longest_common_prefix(&cands), "");
fn lcp_identical_candidates() {
let cands = vec!["list".to_string(), "list".to_string()];
assert_eq!(longest_common_prefix(&cands), "list");