Skip to main content

cli_core/
tree.rs

1use crate::commands::{
2    CliAccountBalance, CliAccountCreate, CliAccountList, CliCommand, CliCommodityCreate,
3    CliCommodityList, CliGetConfig, CliReportsActivity, CliReportsBalance, CliReportsBreakdown,
4    CliSelectColumn, CliSetConfig, CliSshKeyAdd, CliSshKeyList, CliSshKeyRemove,
5    CliTransactionCreate, CliTransactionList, CliVersion, CommandNode,
6};
7
8/// Canonical command tree shared between the automation CLI and the TUI
9/// command palette. Keeping this in one place guarantees the same grammar
10/// is exposed in both surfaces.
11#[must_use]
12pub fn command_tree() -> Vec<CommandNode> {
13    vec![
14        CliVersion::node(),
15        CommandNode {
16            name: "transaction".to_string(),
17            command: None,
18            comment: "Access to transactions".to_string(),
19            subcommands: vec![CliTransactionList::node(), CliTransactionCreate::node()],
20            arguments: vec![],
21        },
22        CommandNode {
23            name: "account".to_string(),
24            command: None,
25            comment: "Access to accounts".to_string(),
26            subcommands: vec![
27                CliAccountList::node(),
28                CliAccountBalance::node(),
29                CliAccountCreate::node(),
30            ],
31            arguments: vec![],
32        },
33        CommandNode {
34            name: "commodity".to_string(),
35            command: None,
36            comment: "Access to commodities".to_string(),
37            subcommands: vec![CliCommodityList::node(), CliCommodityCreate::node()],
38            arguments: vec![],
39        },
40        CommandNode {
41            name: "config".to_string(),
42            command: None,
43            comment: "Access to configuration".to_string(),
44            subcommands: vec![CliGetConfig::node(), CliSetConfig::node()],
45            arguments: vec![],
46        },
47        CommandNode {
48            name: "sql".to_string(),
49            command: None,
50            comment: "Access to SQL database".to_string(),
51            subcommands: vec![CliSelectColumn::node()],
52            arguments: vec![],
53        },
54        CommandNode {
55            name: "reports".to_string(),
56            command: None,
57            comment: "Text-rendered report charts".to_string(),
58            subcommands: vec![
59                CliReportsBalance::node(),
60                CliReportsActivity::node(),
61                CliReportsBreakdown::node(),
62            ],
63            arguments: vec![],
64        },
65        CommandNode {
66            name: "ssh-key".to_string(),
67            command: None,
68            comment: "Manage SSH public keys for remote TUI access".to_string(),
69            subcommands: vec![
70                CliSshKeyAdd::node(),
71                CliSshKeyList::node(),
72                CliSshKeyRemove::node(),
73            ],
74            arguments: vec![],
75        },
76    ]
77}
78
79/// Walk a command path (`["reports", "balance"]`) down the tree and return
80/// the matching leaf. Returns `None` when any segment is unknown or when
81/// the path does not terminate at a runnable command.
82#[must_use]
83pub fn find_leaf<'a>(tree: &'a [CommandNode], path: &[&str]) -> Option<&'a CommandNode> {
84    let (head, rest) = path.split_first()?;
85    let node = tree.iter().find(|n| n.name == *head)?;
86    if rest.is_empty() {
87        node.command.as_ref().map(|_| node)
88    } else {
89        find_leaf(&node.subcommands, rest)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    fn all_leaf_paths<'a>(
98        tree: &'a [CommandNode],
99        prefix: &[&'a str],
100        out: &mut Vec<Vec<&'a str>>,
101    ) {
102        for node in tree {
103            let mut here: Vec<&'a str> = prefix.to_vec();
104            here.push(node.name.as_str());
105            if node.command.is_some() {
106                out.push(here.clone());
107            }
108            all_leaf_paths(&node.subcommands, &here, out);
109        }
110    }
111
112    #[test]
113    fn tree_exposes_expected_top_level_groups() {
114        let tree = command_tree();
115        let names: Vec<&str> = tree.iter().map(|n| n.name.as_str()).collect();
116        assert!(names.contains(&"version"));
117        assert!(names.contains(&"transaction"));
118        assert!(names.contains(&"account"));
119        assert!(names.contains(&"commodity"));
120        assert!(names.contains(&"config"));
121        assert!(names.contains(&"sql"));
122        assert!(names.contains(&"reports"));
123    }
124
125    #[test]
126    fn every_leaf_resolves_via_find_leaf() {
127        let tree = command_tree();
128        let mut leaves = Vec::new();
129        all_leaf_paths(&tree, &[], &mut leaves);
130        assert!(!leaves.is_empty());
131        for path in leaves {
132            let found = find_leaf(&tree, &path);
133            assert!(
134                found.is_some(),
135                "leaf {path:?} should be resolvable via find_leaf"
136            );
137            assert!(found.unwrap().command.is_some());
138        }
139    }
140
141    #[test]
142    fn find_leaf_returns_none_for_unknown_path() {
143        let tree = command_tree();
144        assert!(find_leaf(&tree, &["does-not-exist"]).is_none());
145        assert!(find_leaf(&tree, &["reports", "nonsense"]).is_none());
146    }
147
148    #[test]
149    fn find_leaf_returns_none_for_group_without_command() {
150        let tree = command_tree();
151        assert!(find_leaf(&tree, &["reports"]).is_none());
152        assert!(find_leaf(&tree, &["account"]).is_none());
153    }
154
155    #[test]
156    fn reports_leaves_are_present() {
157        let tree = command_tree();
158        assert!(find_leaf(&tree, &["reports", "balance"]).is_some());
159        assert!(find_leaf(&tree, &["reports", "activity"]).is_some());
160        assert!(find_leaf(&tree, &["reports", "breakdown"]).is_some());
161    }
162}