1
use 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]
12
85
pub fn command_tree() -> Vec<CommandNode> {
13
85
    vec![
14
85
        CliVersion::node(),
15
85
        CommandNode {
16
85
            name: "transaction".to_string(),
17
85
            command: None,
18
85
            comment: "Access to transactions".to_string(),
19
85
            subcommands: vec![CliTransactionList::node(), CliTransactionCreate::node()],
20
85
            arguments: vec![],
21
85
        },
22
85
        CommandNode {
23
85
            name: "account".to_string(),
24
85
            command: None,
25
85
            comment: "Access to accounts".to_string(),
26
85
            subcommands: vec![
27
85
                CliAccountList::node(),
28
85
                CliAccountBalance::node(),
29
85
                CliAccountCreate::node(),
30
85
            ],
31
85
            arguments: vec![],
32
85
        },
33
85
        CommandNode {
34
85
            name: "commodity".to_string(),
35
85
            command: None,
36
85
            comment: "Access to commodities".to_string(),
37
85
            subcommands: vec![CliCommodityList::node(), CliCommodityCreate::node()],
38
85
            arguments: vec![],
39
85
        },
40
85
        CommandNode {
41
85
            name: "config".to_string(),
42
85
            command: None,
43
85
            comment: "Access to configuration".to_string(),
44
85
            subcommands: vec![CliGetConfig::node(), CliSetConfig::node()],
45
85
            arguments: vec![],
46
85
        },
47
85
        CommandNode {
48
85
            name: "sql".to_string(),
49
85
            command: None,
50
85
            comment: "Access to SQL database".to_string(),
51
85
            subcommands: vec![CliSelectColumn::node()],
52
85
            arguments: vec![],
53
85
        },
54
85
        CommandNode {
55
85
            name: "reports".to_string(),
56
85
            command: None,
57
85
            comment: "Text-rendered report charts".to_string(),
58
85
            subcommands: vec![
59
85
                CliReportsBalance::node(),
60
85
                CliReportsActivity::node(),
61
85
                CliReportsBreakdown::node(),
62
85
            ],
63
85
            arguments: vec![],
64
85
        },
65
85
        CommandNode {
66
85
            name: "ssh-key".to_string(),
67
85
            command: None,
68
85
            comment: "Manage SSH public keys for remote TUI access".to_string(),
69
85
            subcommands: vec![
70
85
                CliSshKeyAdd::node(),
71
85
                CliSshKeyList::node(),
72
85
                CliSshKeyRemove::node(),
73
85
            ],
74
85
            arguments: vec![],
75
85
        },
76
    ]
77
85
}
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]
83
144
pub fn find_leaf<'a>(tree: &'a [CommandNode], path: &[&str]) -> Option<&'a CommandNode> {
84
144
    let (head, rest) = path.split_first()?;
85
576
    let node = tree.iter().find(|n| n.name == *head)?;
86
122
    if rest.is_empty() {
87
82
        node.command.as_ref().map(|_| node)
88
    } else {
89
40
        find_leaf(&node.subcommands, rest)
90
    }
91
144
}
92

            
93
#[cfg(test)]
94
mod tests {
95
    use super::*;
96

            
97
25
    fn all_leaf_paths<'a>(
98
25
        tree: &'a [CommandNode],
99
25
        prefix: &[&'a str],
100
25
        out: &mut Vec<Vec<&'a str>>,
101
25
    ) {
102
25
        for node in tree {
103
24
            let mut here: Vec<&'a str> = prefix.to_vec();
104
24
            here.push(node.name.as_str());
105
24
            if node.command.is_some() {
106
17
                out.push(here.clone());
107
17
            }
108
24
            all_leaf_paths(&node.subcommands, &here, out);
109
        }
110
25
    }
111

            
112
    #[test]
113
1
    fn tree_exposes_expected_top_level_groups() {
114
1
        let tree = command_tree();
115
8
        let names: Vec<&str> = tree.iter().map(|n| n.name.as_str()).collect();
116
1
        assert!(names.contains(&"version"));
117
1
        assert!(names.contains(&"transaction"));
118
1
        assert!(names.contains(&"account"));
119
1
        assert!(names.contains(&"commodity"));
120
1
        assert!(names.contains(&"config"));
121
1
        assert!(names.contains(&"sql"));
122
1
        assert!(names.contains(&"reports"));
123
1
    }
124

            
125
    #[test]
126
1
    fn every_leaf_resolves_via_find_leaf() {
127
1
        let tree = command_tree();
128
1
        let mut leaves = Vec::new();
129
1
        all_leaf_paths(&tree, &[], &mut leaves);
130
1
        assert!(!leaves.is_empty());
131
17
        for path in leaves {
132
17
            let found = find_leaf(&tree, &path);
133
17
            assert!(
134
17
                found.is_some(),
135
                "leaf {path:?} should be resolvable via find_leaf"
136
            );
137
17
            assert!(found.unwrap().command.is_some());
138
        }
139
1
    }
140

            
141
    #[test]
142
1
    fn find_leaf_returns_none_for_unknown_path() {
143
1
        let tree = command_tree();
144
1
        assert!(find_leaf(&tree, &["does-not-exist"]).is_none());
145
1
        assert!(find_leaf(&tree, &["reports", "nonsense"]).is_none());
146
1
    }
147

            
148
    #[test]
149
1
    fn find_leaf_returns_none_for_group_without_command() {
150
1
        let tree = command_tree();
151
1
        assert!(find_leaf(&tree, &["reports"]).is_none());
152
1
        assert!(find_leaf(&tree, &["account"]).is_none());
153
1
    }
154

            
155
    #[test]
156
1
    fn reports_leaves_are_present() {
157
1
        let tree = command_tree();
158
1
        assert!(find_leaf(&tree, &["reports", "balance"]).is_some());
159
1
        assert!(find_leaf(&tree, &["reports", "activity"]).is_some());
160
1
        assert!(find_leaf(&tree, &["reports", "breakdown"]).is_some());
161
1
    }
162
}