1
use crate::commands::{
2
    ArgumentNode, CliAccountBalance, CliAccountCreate, CliAccountList, CliCommand,
3
    CliCommodityCreate, CliCommodityList, CliGetConfig, CliSelectColumn, CliSetConfig,
4
    CliSshKeyAdd, CliSshKeyRemove, CliTransactionCreate, CliTransactionList, CliVersion,
5
    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
508
pub fn command_tree() -> Vec<CommandNode> {
13
508
    vec![
14
508
        CliVersion::node(),
15
508
        CommandNode {
16
508
            name: "transaction".to_string(),
17
508
            is_leaf: false,
18
508
            comment: "Access to transactions".to_string(),
19
508
            subcommands: vec![
20
508
                CliTransactionList::node(),
21
508
                CliTransactionCreate::node(),
22
508
                CommandNode {
23
508
                    name: "tag".to_string(),
24
508
                    is_leaf: true,
25
508
                    comment: "Set a tag on a transaction (opens form)".to_string(),
26
508
                    subcommands: vec![],
27
508
                    arguments: vec![],
28
508
                },
29
508
            ],
30
508
            arguments: vec![],
31
508
        },
32
508
        CommandNode {
33
508
            name: "account".to_string(),
34
508
            is_leaf: false,
35
508
            comment: "Access to accounts".to_string(),
36
508
            subcommands: vec![
37
508
                CliAccountList::node(),
38
508
                CliAccountBalance::node(),
39
508
                CliAccountCreate::node(),
40
508
                CommandNode {
41
508
                    name: "tag".to_string(),
42
508
                    is_leaf: true,
43
508
                    comment: "Set a tag on an account (opens form)".to_string(),
44
508
                    subcommands: vec![],
45
508
                    arguments: vec![],
46
508
                },
47
508
            ],
48
508
            arguments: vec![],
49
508
        },
50
508
        CommandNode {
51
508
            name: "commodity".to_string(),
52
508
            is_leaf: false,
53
508
            comment: "Access to commodities".to_string(),
54
508
            subcommands: vec![
55
508
                CliCommodityList::node(),
56
508
                CliCommodityCreate::node(),
57
508
                CommandNode {
58
508
                    name: "convert".to_string(),
59
508
                    is_leaf: true,
60
508
                    comment: "Convert an amount between two commodities (opens form)".to_string(),
61
508
                    subcommands: vec![],
62
508
                    arguments: vec![],
63
508
                },
64
508
            ],
65
508
            arguments: vec![],
66
508
        },
67
508
        CommandNode {
68
508
            name: "config".to_string(),
69
508
            is_leaf: false,
70
508
            comment: "Access to configuration".to_string(),
71
508
            subcommands: vec![CliGetConfig::node(), CliSetConfig::node()],
72
508
            arguments: vec![],
73
508
        },
74
508
        CommandNode {
75
508
            name: "sql".to_string(),
76
508
            is_leaf: false,
77
508
            comment: "Access to SQL database".to_string(),
78
508
            subcommands: vec![CliSelectColumn::node()],
79
508
            arguments: vec![],
80
508
        },
81
508
        CommandNode {
82
508
            name: "reports".to_string(),
83
508
            is_leaf: false,
84
508
            comment: "Text-rendered report charts".to_string(),
85
508
            subcommands: vec![
86
508
                CommandNode {
87
508
                    name: "balance".to_string(),
88
508
                    is_leaf: true,
89
508
                    comment: "Balance chart (top-level accounts by magnitude)".to_string(),
90
508
                    subcommands: vec![],
91
508
                    arguments: vec![ArgumentNode {
92
508
                        name: "chart".to_string(),
93
508
                        comment: "Chart kind: bar (default) | line | stacked | kitty | text"
94
508
                            .to_string(),
95
508
                        completions: None,
96
508
                    }],
97
508
                },
98
508
                CommandNode {
99
508
                    name: "activity".to_string(),
100
508
                    is_leaf: true,
101
508
                    comment: "Activity chart (Income vs Expense over a period)".to_string(),
102
508
                    subcommands: vec![],
103
508
                    arguments: vec![
104
508
                        ArgumentNode {
105
508
                            name: "from".to_string(),
106
508
                            comment: "Period start (YYYY-MM-DD) — required.".to_string(),
107
508
                            completions: None,
108
508
                        },
109
508
                        ArgumentNode {
110
508
                            name: "to".to_string(),
111
508
                            comment: "Period end (YYYY-MM-DD) — required.".to_string(),
112
508
                            completions: None,
113
508
                        },
114
508
                        ArgumentNode {
115
508
                            name: "chart".to_string(),
116
508
                            comment: "Chart kind: bar (default) | line | stacked | kitty | text"
117
508
                                .to_string(),
118
508
                            completions: None,
119
508
                        },
120
508
                    ],
121
508
                },
122
508
                CommandNode {
123
508
                    name: "breakdown".to_string(),
124
508
                    is_leaf: true,
125
508
                    comment: "Category breakdown chart (top-N tag values)".to_string(),
126
508
                    subcommands: vec![],
127
508
                    arguments: vec![
128
508
                        ArgumentNode {
129
508
                            name: "from".to_string(),
130
508
                            comment: "Period start (YYYY-MM-DD) — required.".to_string(),
131
508
                            completions: None,
132
508
                        },
133
508
                        ArgumentNode {
134
508
                            name: "to".to_string(),
135
508
                            comment: "Period end (YYYY-MM-DD) — required.".to_string(),
136
508
                            completions: None,
137
508
                        },
138
508
                        ArgumentNode {
139
508
                            name: "chart".to_string(),
140
508
                            comment: "Chart kind: bar (default) | line | stacked | kitty | text"
141
508
                                .to_string(),
142
508
                            completions: None,
143
508
                        },
144
508
                    ],
145
508
                },
146
508
            ],
147
508
            arguments: vec![],
148
508
        },
149
508
        CommandNode {
150
508
            name: "ssh-key".to_string(),
151
508
            is_leaf: false,
152
508
            comment: "Manage SSH public keys for remote TUI access".to_string(),
153
508
            subcommands: vec![
154
508
                CliSshKeyAdd::node(),
155
508
                CommandNode {
156
508
                    name: "list".to_string(),
157
508
                    is_leaf: true,
158
508
                    comment: "List the SSH keys registered for a user".to_string(),
159
508
                    subcommands: vec![],
160
508
                    arguments: vec![],
161
508
                },
162
508
                CliSshKeyRemove::node(),
163
508
            ],
164
508
            arguments: vec![],
165
508
        },
166
    ]
167
508
}
168

            
169
/// Walk a command path (`["reports", "balance"]`) down the tree and return
170
/// the matching leaf. Returns `None` when any segment is unknown or when
171
/// the path does not terminate at a leaf command.
172
#[must_use]
173
797
pub fn find_leaf<'a>(tree: &'a [CommandNode], path: &[&str]) -> Option<&'a CommandNode> {
174
797
    let (head, rest) = path.split_first()?;
175
2310
    let node = tree.iter().find(|n| n.name == *head)?;
176
769
    if rest.is_empty() {
177
418
        node.is_leaf.then_some(node)
178
    } else {
179
351
        find_leaf(&node.subcommands, rest)
180
    }
181
797
}
182

            
183
/// Return candidate next-tokens for `input` against `tree`.
184
///
185
/// If `input` ends with a space the last segment is considered complete
186
/// and candidates are the children of the resolved parent. Otherwise the
187
/// last whitespace-delimited token is treated as a prefix and only
188
/// children whose names start with it are returned.
189
#[must_use]
190
85
pub fn complete(tree: &[CommandNode], input: &str) -> Vec<String> {
191
85
    let trailing = input.ends_with(' ');
192
85
    let tokens: Vec<&str> = input.split_whitespace().collect();
193
85
    let (parent_path, prefix): (&[&str], &str) = if trailing {
194
41
        (tokens.as_slice(), "")
195
    } else {
196
44
        match tokens.split_last() {
197
8
            None => return tree.iter().map(|n| n.name.clone()).collect(),
198
43
            Some((last, parents)) => (parents, *last),
199
        }
200
    };
201
84
    descend(tree, parent_path)
202
84
        .iter()
203
492
        .filter(|n| n.name.starts_with(prefix))
204
202
        .map(|n| n.name.clone())
205
84
        .collect()
206
85
}
207

            
208
129
fn descend<'a>(tree: &'a [CommandNode], path: &[&str]) -> &'a [CommandNode] {
209
129
    match path.split_first() {
210
84
        None => tree,
211
133
        Some((head, rest)) => match tree.iter().find(|n| n.name == *head) {
212
            None => &[],
213
45
            Some(node) => descend(&node.subcommands, rest),
214
        },
215
    }
216
129
}
217

            
218
#[cfg(test)]
219
mod tests {
220
    use super::*;
221

            
222
28
    fn all_leaf_paths<'a>(
223
28
        tree: &'a [CommandNode],
224
28
        prefix: &[&'a str],
225
28
        out: &mut Vec<Vec<&'a str>>,
226
28
    ) {
227
28
        for node in tree {
228
27
            let mut here: Vec<&'a str> = prefix.to_vec();
229
27
            here.push(node.name.as_str());
230
27
            if node.is_leaf {
231
20
                out.push(here.clone());
232
20
            }
233
27
            all_leaf_paths(&node.subcommands, &here, out);
234
        }
235
28
    }
236

            
237
    #[test]
238
1
    fn tree_exposes_expected_top_level_groups() {
239
1
        let tree = command_tree();
240
8
        let names: Vec<&str> = tree.iter().map(|n| n.name.as_str()).collect();
241
1
        assert!(names.contains(&"version"));
242
1
        assert!(names.contains(&"transaction"));
243
1
        assert!(names.contains(&"account"));
244
1
        assert!(names.contains(&"commodity"));
245
1
        assert!(names.contains(&"config"));
246
1
        assert!(names.contains(&"sql"));
247
1
        assert!(names.contains(&"reports"));
248
1
    }
249

            
250
    #[test]
251
1
    fn every_leaf_resolves_via_find_leaf() {
252
1
        let tree = command_tree();
253
1
        let mut leaves = Vec::new();
254
1
        all_leaf_paths(&tree, &[], &mut leaves);
255
1
        assert!(!leaves.is_empty());
256
20
        for path in leaves {
257
20
            let found = find_leaf(&tree, &path);
258
20
            assert!(
259
20
                found.is_some(),
260
                "leaf {path:?} should be resolvable via find_leaf"
261
            );
262
20
            assert!(found.unwrap().is_leaf);
263
        }
264
1
    }
265

            
266
    #[test]
267
1
    fn find_leaf_returns_none_for_unknown_path() {
268
1
        let tree = command_tree();
269
1
        assert!(find_leaf(&tree, &["does-not-exist"]).is_none());
270
1
        assert!(find_leaf(&tree, &["reports", "nonsense"]).is_none());
271
1
    }
272

            
273
    #[test]
274
1
    fn find_leaf_returns_none_for_group_without_command() {
275
1
        let tree = command_tree();
276
1
        assert!(find_leaf(&tree, &["reports"]).is_none());
277
1
        assert!(find_leaf(&tree, &["account"]).is_none());
278
1
    }
279

            
280
    #[test]
281
1
    fn reports_leaves_are_present() {
282
1
        let tree = command_tree();
283
1
        assert!(find_leaf(&tree, &["reports", "balance"]).is_some());
284
1
        assert!(find_leaf(&tree, &["reports", "activity"]).is_some());
285
1
        assert!(find_leaf(&tree, &["reports", "breakdown"]).is_some());
286
1
    }
287

            
288
    #[test]
289
1
    fn account_tag_and_transaction_tag_leaves_present() {
290
1
        let tree = command_tree();
291
1
        assert!(find_leaf(&tree, &["account", "tag"]).is_some());
292
1
        assert!(find_leaf(&tree, &["transaction", "tag"]).is_some());
293
1
    }
294

            
295
    #[test]
296
1
    fn commodity_convert_leaf_is_present() {
297
1
        let tree = command_tree();
298
1
        assert!(find_leaf(&tree, &["commodity", "convert"]).is_some());
299
1
    }
300

            
301
    #[test]
302
1
    fn complete_empty_returns_all_top_level() {
303
1
        let tree = command_tree();
304
1
        let cands = complete(&tree, "");
305
1
        assert!(cands.contains(&"account".to_string()));
306
1
        assert!(cands.contains(&"transaction".to_string()));
307
1
        assert!(cands.contains(&"version".to_string()));
308
1
    }
309

            
310
    #[test]
311
1
    fn complete_prefix_filters_top_level() {
312
1
        let tree = command_tree();
313
1
        let cands = complete(&tree, "acc");
314
1
        assert_eq!(cands, vec!["account".to_string()]);
315
1
    }
316

            
317
    #[test]
318
1
    fn complete_trailing_space_lists_children() {
319
1
        let tree = command_tree();
320
1
        let cands = complete(&tree, "account ");
321
1
        assert!(cands.contains(&"create".to_string()));
322
1
        assert!(cands.contains(&"list".to_string()));
323
1
        assert!(cands.contains(&"tag".to_string()));
324
1
        assert!(!cands.contains(&"account".to_string()));
325
1
    }
326

            
327
    #[test]
328
1
    fn complete_partial_child_filters() {
329
1
        let tree = command_tree();
330
1
        let cands = complete(&tree, "account c");
331
1
        assert_eq!(cands, vec!["create".to_string()]);
332
1
    }
333

            
334
    #[test]
335
1
    fn complete_no_match_returns_empty() {
336
1
        let tree = command_tree();
337
1
        let cands = complete(&tree, "account xyz");
338
1
        assert!(cands.is_empty());
339
1
    }
340

            
341
    #[test]
342
1
    fn complete_exact_leaf_without_space_returns_itself() {
343
1
        let tree = command_tree();
344
1
        let cands = complete(&tree, "account list");
345
1
        assert_eq!(cands, vec!["list".to_string()]);
346
1
    }
347

            
348
    #[test]
349
1
    fn complete_exact_leaf_with_trailing_space_returns_empty() {
350
1
        let tree = command_tree();
351
1
        let cands = complete(&tree, "account list ");
352
1
        assert!(cands.is_empty());
353
1
    }
354
}