Skip to main content

reddb_server/cli/
complete.rs

1/// Shell completion generation for the RedDB CLI.
2///
3/// Generates static completion scripts for bash, zsh, and fish shells,
4/// plus a dynamic `complete_partial` function for runtime tab-completion.
5///
6/// Supported shell targets for completion script generation.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Shell {
9    Bash,
10    Zsh,
11    Fish,
12}
13
14/// Generate a full completion script for the given shell.
15///
16/// * `shell`        - Target shell.
17/// * `domains`      - `(name, aliases)` for each domain.
18/// * `global_flags` - `(long_name, optional_short)` for global flags.
19pub fn generate_completion_script(
20    shell: Shell,
21    domains: &[(String, Vec<String>)],
22    global_flags: &[(&str, Option<char>)],
23) -> String {
24    match shell {
25        Shell::Bash => generate_bash(domains, global_flags),
26        Shell::Zsh => generate_zsh(domains, global_flags),
27        Shell::Fish => generate_fish(domains, global_flags),
28    }
29}
30
31/// Complete partial input tokens at runtime.
32///
33/// * `tokens`  - Words typed so far.
34/// * `domains` - `domain_name -> [(resource_name, [verb_names])]`.
35///
36/// Returns candidate completions for the next position.
37pub fn complete_partial(
38    tokens: &[&str],
39    domains: &[(String, Vec<(String, Vec<String>)>)],
40) -> Vec<String> {
41    let domain_names: Vec<&str> = domains.iter().map(|(n, _)| n.as_str()).collect();
42
43    match tokens.len() {
44        // No input yet: return all domain names.
45        0 => domain_names.iter().map(|s| s.to_string()).collect(),
46
47        // Single partial token: filter matching domains.
48        1 => {
49            let prefix = tokens[0];
50            domain_names
51                .iter()
52                .filter(|d| d.starts_with(prefix))
53                .map(|d| d.to_string())
54                .collect()
55        }
56
57        // Two tokens: domain given, complete the resource.
58        2 => {
59            let domain = tokens[0];
60            let prefix = tokens[1];
61            domains
62                .iter()
63                .find(|(name, _)| name == domain)
64                .map(|(_, resources)| {
65                    resources
66                        .iter()
67                        .map(|(r, _)| r.as_str())
68                        .filter(|r| r.starts_with(prefix))
69                        .map(|r| r.to_string())
70                        .collect()
71                })
72                .unwrap_or_default()
73        }
74
75        // Three tokens: domain + resource given, complete the verb.
76        3 => {
77            let domain = tokens[0];
78            let prefix = tokens[2];
79            domains
80                .iter()
81                .find(|(name, _)| name == domain)
82                .and_then(|(_, resources)| {
83                    resources
84                        .iter()
85                        .find(|(r, _)| r == tokens[1])
86                        .map(|(_, verbs)| {
87                            verbs
88                                .iter()
89                                .filter(|v| v.starts_with(prefix))
90                                .cloned()
91                                .collect()
92                        })
93                })
94                .unwrap_or_default()
95        }
96
97        // Four or more tokens: suggest flag names starting with --
98        _ => {
99            let last = *tokens.last().unwrap_or(&"");
100            if let Some(prefix) = last.strip_prefix("--") {
101                let flag_names = ["help", "json", "output", "verbose", "no-color", "version"];
102                flag_names
103                    .iter()
104                    .filter(|f| f.starts_with(prefix))
105                    .map(|f| format!("--{}", f))
106                    .collect()
107            } else if last.starts_with('-') && last.len() == 1 {
108                vec![
109                    "-h".to_string(),
110                    "-j".to_string(),
111                    "-o".to_string(),
112                    "-v".to_string(),
113                ]
114            } else {
115                Vec::new()
116            }
117        }
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Bash completion
123// ---------------------------------------------------------------------------
124
125fn generate_bash(
126    domains: &[(String, Vec<String>)],
127    global_flags: &[(&str, Option<char>)],
128) -> String {
129    let all_domains: Vec<&str> = domains.iter().map(|(n, _)| n.as_str()).collect();
130    let domain_word_list = all_domains.join(" ");
131
132    let flag_word_list: String = global_flags
133        .iter()
134        .map(|(long, short)| {
135            let mut parts = vec![format!("--{}", long)];
136            if let Some(ch) = short {
137                parts.push(format!("-{}", ch));
138            }
139            parts.join(" ")
140        })
141        .collect::<Vec<_>>()
142        .join(" ");
143
144    format!(
145        r#"_red_completions() {{
146    local cur prev words cword
147    _init_completion || return
148
149    # Global flags at any position
150    if [[ "$cur" == -* ]]; then
151        COMPREPLY=($(compgen -W "{flags}" -- "$cur"))
152        return
153    fi
154
155    case $cword in
156        1)
157            COMPREPLY=($(compgen -W "{domains} help version" -- "$cur"))
158            ;;
159        *)
160            # Delegate deeper completions to the binary when available
161            if command -v red &>/dev/null; then
162                local completions
163                completions=$(red --complete "${{words[@]:1}}" 2>/dev/null)
164                if [[ -n "$completions" ]]; then
165                    COMPREPLY=($(compgen -W "$completions" -- "$cur"))
166                fi
167            fi
168            ;;
169    esac
170}}
171complete -F _red_completions red
172"#,
173        flags = flag_word_list,
174        domains = domain_word_list,
175    )
176}
177
178// ---------------------------------------------------------------------------
179// Zsh completion
180// ---------------------------------------------------------------------------
181
182fn generate_zsh(
183    domains: &[(String, Vec<String>)],
184    global_flags: &[(&str, Option<char>)],
185) -> String {
186    let mut out = String::with_capacity(1024);
187
188    out.push_str("#compdef red\n\n");
189    out.push_str("_red() {\n");
190    out.push_str("    local -a global_flags\n");
191    out.push_str("    global_flags=(\n");
192    for (long, short) in global_flags {
193        match short {
194            Some(ch) => {
195                out.push_str(&format!(
196                    "        '(-{ch} --{long})'{{-{ch},--{long}}}'[{long}]'\n",
197                    ch = ch,
198                    long = long,
199                ));
200            }
201            None => {
202                out.push_str(&format!("        '--{long}[{long}]'\n", long = long));
203            }
204        }
205    }
206    out.push_str("    )\n\n");
207
208    out.push_str("    _arguments -C \\\n");
209    out.push_str("        $global_flags \\\n");
210    out.push_str("        '1:command:->command' \\\n");
211    out.push_str("        '*::arg:->args'\n\n");
212
213    out.push_str("    case $state in\n");
214    out.push_str("        command)\n");
215    out.push_str("            local -a commands\n");
216    out.push_str("            commands=(\n");
217    for (name, _) in domains {
218        out.push_str(&format!("                '{}'\n", name));
219    }
220    out.push_str("                'help'\n");
221    out.push_str("                'version'\n");
222    out.push_str("            )\n");
223    out.push_str("            _describe 'command' commands\n");
224    out.push_str("            ;;\n");
225
226    out.push_str("        args)\n");
227    out.push_str("            # Delegate to binary for deeper completions\n");
228    out.push_str("            if (( $+commands[red] )); then\n");
229    out.push_str("                local completions\n");
230    out.push_str(
231        "                completions=(${(f)\"$(red --complete ${words[2,-1]} 2>/dev/null)\"})\n",
232    );
233    out.push_str("                _describe 'subcommand' completions\n");
234    out.push_str("            fi\n");
235    out.push_str("            ;;\n");
236    out.push_str("    esac\n");
237    out.push_str("}\n\n");
238    out.push_str("_red\n");
239    out
240}
241
242// ---------------------------------------------------------------------------
243// Fish completion
244// ---------------------------------------------------------------------------
245
246fn generate_fish(
247    domains: &[(String, Vec<String>)],
248    global_flags: &[(&str, Option<char>)],
249) -> String {
250    let mut out = String::with_capacity(1024);
251
252    out.push_str("# Fish completions for red (reddb)\n\n");
253
254    // Global flags
255    for (long, short) in global_flags {
256        match short {
257            Some(ch) => {
258                out.push_str(&format!(
259                    "complete -c red -s {} -l {} -d '{}'\n",
260                    ch, long, long
261                ));
262            }
263            None => {
264                out.push_str(&format!("complete -c red -l {} -d '{}'\n", long, long));
265            }
266        }
267    }
268    out.push('\n');
269
270    // Domain completions: only when no subcommand has been given yet
271    out.push_str("# Command completions\n");
272    for (name, _) in domains {
273        out.push_str(&format!(
274            "complete -c red -n '__fish_use_subcommand' -a {} -d '{}'\n",
275            name, name
276        ));
277    }
278    out.push_str("complete -c red -n '__fish_use_subcommand' -a help -d 'Show help'\n");
279    out.push_str("complete -c red -n '__fish_use_subcommand' -a version -d 'Show version'\n");
280    out.push('\n');
281
282    // Deeper completions via binary
283    out.push_str("# Delegate deeper completions to the binary\n");
284    out.push_str("complete -c red -n 'not __fish_use_subcommand' -a '(red --complete (commandline -cop) 2>/dev/null)'\n");
285
286    out
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn sample_domains() -> Vec<(String, Vec<String>)> {
294        vec![
295            ("server".to_string(), vec![]),
296            ("query".to_string(), vec!["q".to_string()]),
297            ("health".to_string(), vec![]),
298        ]
299    }
300
301    fn sample_global_flags() -> Vec<(&'static str, Option<char>)> {
302        vec![
303            ("help", Some('h')),
304            ("json", Some('j')),
305            ("output", Some('o')),
306            ("verbose", Some('v')),
307            ("no-color", None),
308        ]
309    }
310
311    fn sample_domain_tree() -> Vec<(String, Vec<(String, Vec<String>)>)> {
312        vec![
313            (
314                "server".to_string(),
315                vec![(
316                    "grpc".to_string(),
317                    vec!["start".to_string(), "stop".to_string()],
318                )],
319            ),
320            (
321                "query".to_string(),
322                vec![
323                    (
324                        "sql".to_string(),
325                        vec!["execute".to_string(), "explain".to_string()],
326                    ),
327                    ("graph".to_string(), vec!["traverse".to_string()]),
328                ],
329            ),
330            (
331                "health".to_string(),
332                vec![(
333                    "check".to_string(),
334                    vec!["status".to_string(), "ping".to_string()],
335                )],
336            ),
337        ]
338    }
339
340    // ----------------------------------------------------------------
341    // complete_partial tests
342    // ----------------------------------------------------------------
343
344    #[test]
345    fn test_complete_partial_domains() {
346        let tree = sample_domain_tree();
347        let result = complete_partial(&[], &tree);
348        assert!(result.contains(&"server".to_string()));
349        assert!(result.contains(&"query".to_string()));
350        assert!(result.contains(&"health".to_string()));
351        assert_eq!(result.len(), 3);
352    }
353
354    #[test]
355    fn test_complete_partial_domains_filter() {
356        let tree = sample_domain_tree();
357        let result = complete_partial(&["s"], &tree);
358        assert_eq!(result, vec!["server".to_string()]);
359    }
360
361    #[test]
362    fn test_complete_partial_resources() {
363        let tree = sample_domain_tree();
364        let result = complete_partial(&["query", ""], &tree);
365        assert!(result.contains(&"sql".to_string()));
366        assert!(result.contains(&"graph".to_string()));
367    }
368
369    #[test]
370    fn test_complete_partial_resources_filter() {
371        let tree = sample_domain_tree();
372        let result = complete_partial(&["query", "s"], &tree);
373        assert_eq!(result, vec!["sql".to_string()]);
374    }
375
376    #[test]
377    fn test_complete_partial_verbs() {
378        let tree = sample_domain_tree();
379        let result = complete_partial(&["server", "grpc", ""], &tree);
380        assert!(result.contains(&"start".to_string()));
381        assert!(result.contains(&"stop".to_string()));
382    }
383
384    #[test]
385    fn test_complete_partial_verbs_filter() {
386        let tree = sample_domain_tree();
387        let result = complete_partial(&["server", "grpc", "sta"], &tree);
388        assert_eq!(result, vec!["start".to_string()]);
389    }
390
391    #[test]
392    fn test_complete_partial_flags() {
393        let tree = sample_domain_tree();
394        let result = complete_partial(&["server", "grpc", "start", "--"], &tree);
395        // All global flags start with empty prefix after --
396        assert!(result.contains(&"--help".to_string()));
397        assert!(result.contains(&"--json".to_string()));
398        assert!(result.contains(&"--verbose".to_string()));
399    }
400
401    #[test]
402    fn test_complete_partial_unknown_domain() {
403        let tree = sample_domain_tree();
404        let result = complete_partial(&["unknown", ""], &tree);
405        assert!(result.is_empty());
406    }
407
408    // ----------------------------------------------------------------
409    // Bash completion script tests
410    // ----------------------------------------------------------------
411
412    #[test]
413    fn test_bash_completion_script() {
414        let script =
415            generate_completion_script(Shell::Bash, &sample_domains(), &sample_global_flags());
416        assert!(script.contains("_red_completions()"));
417        assert!(script.contains("complete -F _red_completions red"));
418        assert!(script.contains("server"));
419        assert!(script.contains("query"));
420        assert!(script.contains("health"));
421        assert!(script.contains("--help"));
422        assert!(script.contains("-h"));
423        assert!(script.contains("help version"));
424    }
425
426    // ----------------------------------------------------------------
427    // Zsh completion script tests
428    // ----------------------------------------------------------------
429
430    #[test]
431    fn test_zsh_completion_script() {
432        let script =
433            generate_completion_script(Shell::Zsh, &sample_domains(), &sample_global_flags());
434        assert!(script.contains("#compdef red"));
435        assert!(script.contains("_red()"));
436        assert!(script.contains("_arguments"));
437        assert!(script.contains("server"));
438        assert!(script.contains("query"));
439        assert!(script.contains("health"));
440        assert!(script.contains("--help"));
441    }
442
443    // ----------------------------------------------------------------
444    // Fish completion script tests
445    // ----------------------------------------------------------------
446
447    #[test]
448    fn test_fish_completion_script() {
449        let script =
450            generate_completion_script(Shell::Fish, &sample_domains(), &sample_global_flags());
451        assert!(script.contains("complete -c red"));
452        assert!(script.contains("-s h -l help"));
453        assert!(script.contains("__fish_use_subcommand"));
454        assert!(script.contains("server"));
455        assert!(script.contains("query"));
456    }
457}