Lines
99.65 %
Functions
97.06 %
Branches
100 %
use crate::commands::{
ArgumentNode, CliAccountBalance, CliAccountCreate, CliAccountList, CliCommand,
CliCommodityCreate, CliCommodityList, CliGetConfig, CliSelectColumn, CliSetConfig,
CliSshKeyAdd, CliSshKeyRemove, CliTransactionCreate, CliTransactionList, CliVersion,
CommandNode,
};
/// Canonical command tree shared between the automation CLI and the TUI
/// command palette. Keeping this in one place guarantees the same grammar
/// is exposed in both surfaces.
#[must_use]
pub fn command_tree() -> Vec<CommandNode> {
vec![
CliVersion::node(),
CommandNode {
name: "transaction".to_string(),
is_leaf: false,
comment: "Access to transactions".to_string(),
subcommands: vec![
CliTransactionList::node(),
CliTransactionCreate::node(),
name: "tag".to_string(),
is_leaf: true,
comment: "Set a tag on a transaction (opens form)".to_string(),
subcommands: vec![],
arguments: vec![],
},
],
name: "account".to_string(),
comment: "Access to accounts".to_string(),
CliAccountList::node(),
CliAccountBalance::node(),
CliAccountCreate::node(),
comment: "Set a tag on an account (opens form)".to_string(),
name: "commodity".to_string(),
comment: "Access to commodities".to_string(),
CliCommodityList::node(),
CliCommodityCreate::node(),
name: "convert".to_string(),
comment: "Convert an amount between two commodities (opens form)".to_string(),
name: "config".to_string(),
comment: "Access to configuration".to_string(),
subcommands: vec![CliGetConfig::node(), CliSetConfig::node()],
name: "sql".to_string(),
comment: "Access to SQL database".to_string(),
subcommands: vec![CliSelectColumn::node()],
name: "reports".to_string(),
comment: "Text-rendered report charts".to_string(),
name: "balance".to_string(),
comment: "Balance chart (top-level accounts by magnitude)".to_string(),
arguments: vec![ArgumentNode {
name: "chart".to_string(),
comment: "Chart kind: bar (default) | line | stacked | kitty | text"
.to_string(),
completions: None,
}],
name: "activity".to_string(),
comment: "Activity chart (Income vs Expense over a period)".to_string(),
arguments: vec![
ArgumentNode {
name: "from".to_string(),
comment: "Period start (YYYY-MM-DD) — required.".to_string(),
name: "to".to_string(),
comment: "Period end (YYYY-MM-DD) — required.".to_string(),
name: "breakdown".to_string(),
comment: "Category breakdown chart (top-N tag values)".to_string(),
name: "ssh-key".to_string(),
comment: "Manage SSH public keys for remote TUI access".to_string(),
CliSshKeyAdd::node(),
name: "list".to_string(),
comment: "List the SSH keys registered for a user".to_string(),
CliSshKeyRemove::node(),
]
}
/// Walk a command path (`["reports", "balance"]`) down the tree and return
/// the matching leaf. Returns `None` when any segment is unknown or when
/// the path does not terminate at a leaf command.
pub fn find_leaf<'a>(tree: &'a [CommandNode], path: &[&str]) -> Option<&'a CommandNode> {
let (head, rest) = path.split_first()?;
let node = tree.iter().find(|n| n.name == *head)?;
if rest.is_empty() {
node.is_leaf.then_some(node)
} else {
find_leaf(&node.subcommands, rest)
/// Return candidate next-tokens for `input` against `tree`.
///
/// If `input` ends with a space the last segment is considered complete
/// and candidates are the children of the resolved parent. Otherwise the
/// last whitespace-delimited token is treated as a prefix and only
/// children whose names start with it are returned.
pub fn complete(tree: &[CommandNode], input: &str) -> Vec<String> {
let trailing = input.ends_with(' ');
let tokens: Vec<&str> = input.split_whitespace().collect();
let (parent_path, prefix): (&[&str], &str) = if trailing {
(tokens.as_slice(), "")
match tokens.split_last() {
None => return tree.iter().map(|n| n.name.clone()).collect(),
Some((last, parents)) => (parents, *last),
descend(tree, parent_path)
.iter()
.filter(|n| n.name.starts_with(prefix))
.map(|n| n.name.clone())
.collect()
fn descend<'a>(tree: &'a [CommandNode], path: &[&str]) -> &'a [CommandNode] {
match path.split_first() {
None => tree,
Some((head, rest)) => match tree.iter().find(|n| n.name == *head) {
None => &[],
Some(node) => descend(&node.subcommands, rest),
#[cfg(test)]
mod tests {
use super::*;
fn all_leaf_paths<'a>(
tree: &'a [CommandNode],
prefix: &[&'a str],
out: &mut Vec<Vec<&'a str>>,
) {
for node in tree {
let mut here: Vec<&'a str> = prefix.to_vec();
here.push(node.name.as_str());
if node.is_leaf {
out.push(here.clone());
all_leaf_paths(&node.subcommands, &here, out);
#[test]
fn tree_exposes_expected_top_level_groups() {
let tree = command_tree();
let names: Vec<&str> = tree.iter().map(|n| n.name.as_str()).collect();
assert!(names.contains(&"version"));
assert!(names.contains(&"transaction"));
assert!(names.contains(&"account"));
assert!(names.contains(&"commodity"));
assert!(names.contains(&"config"));
assert!(names.contains(&"sql"));
assert!(names.contains(&"reports"));
fn every_leaf_resolves_via_find_leaf() {
let mut leaves = Vec::new();
all_leaf_paths(&tree, &[], &mut leaves);
assert!(!leaves.is_empty());
for path in leaves {
let found = find_leaf(&tree, &path);
assert!(
found.is_some(),
"leaf {path:?} should be resolvable via find_leaf"
);
assert!(found.unwrap().is_leaf);
fn find_leaf_returns_none_for_unknown_path() {
assert!(find_leaf(&tree, &["does-not-exist"]).is_none());
assert!(find_leaf(&tree, &["reports", "nonsense"]).is_none());
fn find_leaf_returns_none_for_group_without_command() {
assert!(find_leaf(&tree, &["reports"]).is_none());
assert!(find_leaf(&tree, &["account"]).is_none());
fn reports_leaves_are_present() {
assert!(find_leaf(&tree, &["reports", "balance"]).is_some());
assert!(find_leaf(&tree, &["reports", "activity"]).is_some());
assert!(find_leaf(&tree, &["reports", "breakdown"]).is_some());
fn account_tag_and_transaction_tag_leaves_present() {
assert!(find_leaf(&tree, &["account", "tag"]).is_some());
assert!(find_leaf(&tree, &["transaction", "tag"]).is_some());
fn commodity_convert_leaf_is_present() {
assert!(find_leaf(&tree, &["commodity", "convert"]).is_some());
fn complete_empty_returns_all_top_level() {
let cands = complete(&tree, "");
assert!(cands.contains(&"account".to_string()));
assert!(cands.contains(&"transaction".to_string()));
assert!(cands.contains(&"version".to_string()));
fn complete_prefix_filters_top_level() {
let cands = complete(&tree, "acc");
assert_eq!(cands, vec!["account".to_string()]);
fn complete_trailing_space_lists_children() {
let cands = complete(&tree, "account ");
assert!(cands.contains(&"create".to_string()));
assert!(cands.contains(&"list".to_string()));
assert!(cands.contains(&"tag".to_string()));
assert!(!cands.contains(&"account".to_string()));
fn complete_partial_child_filters() {
let cands = complete(&tree, "account c");
assert_eq!(cands, vec!["create".to_string()]);
fn complete_no_match_returns_empty() {
let cands = complete(&tree, "account xyz");
assert!(cands.is_empty());
fn complete_exact_leaf_without_space_returns_itself() {
let cands = complete(&tree, "account list");
assert_eq!(cands, vec!["list".to_string()]);
fn complete_exact_leaf_with_trailing_space_returns_empty() {
let cands = complete(&tree, "account list ");