Skip to main content

figue/
completions.rs

1//! Shell completion script generation for command-line interfaces.
2//!
3//! This module generates completion scripts for various shells (bash, zsh, fish)
4//! based on Schema metadata built from Facet types.
5
6use heck::ToKebabCase;
7use std::string::String;
8use std::vec::Vec;
9
10use crate::schema::{ArgLevelSchema, ArgSchema, Schema, Subcommand};
11
12/// Supported shells for completion generation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, facet::Facet)]
14#[repr(u8)]
15pub enum Shell {
16    /// Bash shell
17    Bash,
18    /// Zsh shell
19    Zsh,
20    /// Fish shell
21    Fish,
22}
23
24/// Generate shell completion script for a shape.
25///
26/// This is a convenience function that builds a Schema internally.
27/// If you already have a Schema, use [generate_completions_for_schema] instead.
28pub fn generate_completions_for_shape(
29    shape: &'static facet_core::Shape,
30    shell: Shell,
31    program_name: &str,
32) -> String {
33    let schema = match Schema::from_shape(shape) {
34        Ok(s) => s,
35        Err(_) => {
36            // Fall back to a minimal completion script
37            return format!("# Could not generate completions for {program_name}\n");
38        }
39    };
40
41    generate_completions_for_schema(&schema, shell, program_name)
42}
43
44/// Generate shell completion script from a schema.
45pub fn generate_completions_for_schema(
46    schema: &Schema,
47    shell: Shell,
48    program_name: &str,
49) -> String {
50    match shell {
51        Shell::Bash => generate_bash(schema.args(), program_name),
52        Shell::Zsh => generate_zsh(schema.args(), program_name),
53        Shell::Fish => generate_fish(schema.args(), program_name),
54    }
55}
56
57// === Bash Completion ===
58
59fn generate_bash(args: &ArgLevelSchema, program_name: &str) -> String {
60    let mut out = String::new();
61
62    // Generate the main completion function
63    generate_bash_function(&mut out, args, program_name, &[]);
64
65    // Generate subcommand functions recursively
66    generate_bash_subcommand_functions(&mut out, args, program_name, &[]);
67
68    // Register the completion
69    out.push_str(&format!("complete -F _{program_name} {program_name}\n"));
70
71    out
72}
73
74fn generate_bash_function(
75    out: &mut String,
76    args: &ArgLevelSchema,
77    program_name: &str,
78    path: &[&str],
79) {
80    let func_name = if path.is_empty() {
81        program_name.to_string()
82    } else {
83        format!("{}_{}", program_name, path.join("_").replace('-', "_"))
84    };
85
86    let (flags, subcommands) = collect_options(args);
87
88    out.push_str(&format!(
89        r#"_{func_name}() {{
90    local cur prev words cword
91    _init_completion || return
92
93"#
94    ));
95
96    // Build flags string
97    if !flags.is_empty() {
98        out.push_str("    local flags=\"");
99        for (i, flag) in flags.iter().enumerate() {
100            if i > 0 {
101                out.push(' ');
102            }
103            out.push_str(&format!("--{}", flag.long));
104            if let Some(short) = flag.short {
105                out.push_str(&format!(" -{short}"));
106            }
107        }
108        out.push_str("\"\n");
109    } else {
110        out.push_str("    local flags=\"\"\n");
111    }
112
113    // Build commands string
114    if !subcommands.is_empty() {
115        out.push_str("    local commands=\"");
116        for (i, cmd) in subcommands.iter().enumerate() {
117            if i > 0 {
118                out.push(' ');
119            }
120            out.push_str(&cmd.name);
121        }
122        out.push_str("\"\n");
123    } else {
124        out.push_str("    local commands=\"\"\n");
125    }
126
127    // Handle flags that take values
128    let value_flags: Vec<_> = flags.iter().filter(|f| f.takes_value).collect();
129    if !value_flags.is_empty() {
130        out.push_str("\n    case \"$prev\" in\n");
131        for flag in &value_flags {
132            let mut cases = vec![format!("--{}", flag.long)];
133            if let Some(short) = flag.short {
134                cases.push(format!("-{short}"));
135            }
136            out.push_str(&format!(
137                "        {})\n            # Value expected, provide default file completion\n            return\n            ;;\n",
138                cases.join("|")
139            ));
140        }
141        out.push_str("    esac\n");
142    }
143
144    if !subcommands.is_empty() {
145        // Find the subcommand position and dispatch
146        out.push_str(&format!(
147            r#"
148    # Find the subcommand
149    local cmd_idx={}
150    local cmd=""
151    for ((i=1; i < cword; i++)); do
152        case "${{words[i]}}" in
153            -*)
154                # Skip flags
155"#,
156            path.len() + 1
157        ));
158
159        // Skip flags that take values (they consume the next argument)
160        if !value_flags.is_empty() {
161            out.push_str("                case \"${words[i]}\" in\n");
162            for flag in &value_flags {
163                let mut cases = vec![format!("--{}", flag.long)];
164                if let Some(short) = flag.short {
165                    cases.push(format!("-{short}"));
166                }
167                out.push_str(&format!(
168                    "                    {}) ((i++)) ;;\n",
169                    cases.join("|")
170                ));
171            }
172            out.push_str("                esac\n");
173        }
174
175        out.push_str(
176            r#"                ;;
177            *)
178                cmd="${words[i]}"
179                cmd_idx=$i
180                break
181                ;;
182        esac
183    done
184
185    # If we're past the subcommand, dispatch to subcommand completer
186    if [[ -n "$cmd" && $cword -gt $cmd_idx ]]; then
187        case "$cmd" in
188"#,
189        );
190
191        for cmd in &subcommands {
192            let sub_func = if path.is_empty() {
193                format!("{}_{}", program_name, cmd.name.replace('-', "_"))
194            } else {
195                format!("{}_{}", func_name, cmd.name.replace('-', "_"))
196            };
197            out.push_str(&format!(
198                "            {})\n                _{sub_func}\n                return\n                ;;\n",
199                cmd.name
200            ));
201        }
202
203        out.push_str(
204            r#"        esac
205    fi
206
207    # Complete flags or subcommands
208    if [[ "$cur" == -* ]]; then
209        COMPREPLY=($(compgen -W "$flags" -- "$cur"))
210    else
211        COMPREPLY=($(compgen -W "$commands" -- "$cur"))
212    fi
213}
214
215"#,
216        );
217    } else {
218        // No subcommands, just complete flags
219        out.push_str(
220            r#"
221    if [[ "$cur" == -* ]]; then
222        COMPREPLY=($(compgen -W "$flags" -- "$cur"))
223    fi
224}
225
226"#,
227        );
228    }
229}
230
231fn generate_bash_subcommand_functions(
232    out: &mut String,
233    args: &ArgLevelSchema,
234    program_name: &str,
235    path: &[&str],
236) {
237    for (_, sub) in args.subcommands() {
238        let mut new_path = path.to_vec();
239        new_path.push(sub.cli_name());
240        generate_bash_function(out, sub.args(), program_name, &new_path);
241        generate_bash_subcommand_functions(out, sub.args(), program_name, &new_path);
242    }
243}
244
245// === Zsh Completion ===
246
247fn generate_zsh(args: &ArgLevelSchema, program_name: &str) -> String {
248    let mut out = String::new();
249
250    // Header with compdef directive
251    out.push_str(&format!("#compdef {program_name}\n\n"));
252
253    // Generate the main completion function
254    generate_zsh_function(&mut out, args, program_name, program_name);
255
256    // Generate subcommand helper functions
257    generate_zsh_subcommand_helpers(&mut out, args, program_name);
258
259    // Footer: works for both autoload (fpath) and inline sourcing (eval/source)
260    out.push_str(&format!(
261        r#"
262if [ "$funcstack[1]" = "_{program_name}" ]; then
263    _{program_name} "$@"
264else
265    compdef _{program_name} {program_name}
266fi
267"#
268    ));
269
270    out
271}
272
273fn generate_zsh_function(
274    out: &mut String,
275    args: &ArgLevelSchema,
276    func_name: &str,
277    _program_name: &str,
278) {
279    out.push_str(&format!(
280        r#"_{func_name}() {{
281    local -a options
282    local -a commands
283    local ret=1
284
285"#
286    ));
287
288    let (flags, subcommands) = collect_options(args);
289
290    // Build options array with proper zsh _arguments format
291    out.push_str("    options=(\n");
292    for flag in &flags {
293        let desc = flag.doc.as_deref().unwrap_or("");
294        let escaped_desc = escape_zsh_description(desc);
295
296        // For flags that take values, add the value placeholder
297        let value_spec = if flag.takes_value {
298            ":value:_default"
299        } else {
300            ""
301        };
302
303        if let Some(short) = flag.short {
304            // Short and long are mutually exclusive in completion
305            out.push_str(&format!(
306                "        '(-{short} --{long})'-{short}'[{escaped_desc}]{value_spec}'\n",
307                long = flag.long,
308            ));
309            out.push_str(&format!(
310                "        '(-{short} --{long})'--{long}'[{escaped_desc}]{value_spec}'\n",
311                long = flag.long,
312            ));
313        } else {
314            out.push_str(&format!(
315                "        '--{long}[{escaped_desc}]{value_spec}'\n",
316                long = flag.long,
317            ));
318        }
319    }
320    out.push_str("    )\n\n");
321
322    if !subcommands.is_empty() {
323        // Build commands array
324        out.push_str("    commands=(\n");
325        for cmd in &subcommands {
326            let desc = cmd.doc.as_deref().unwrap_or("");
327            let escaped_desc = escape_zsh_description(desc);
328            out.push_str(&format!(
329                "        '{name}:{escaped_desc}'\n",
330                name = cmd.name
331            ));
332        }
333        out.push_str("    )\n\n");
334
335        // Use _arguments with state machine for subcommands
336        out.push_str(&format!(
337            r#"    _arguments -C \
338        $options \
339        "1: :->command" \
340        "*::arg:->args" \
341        && ret=0
342
343    case $state in
344        command)
345            _describe -t commands '{func_name} commands' commands && ret=0
346            ;;
347        args)
348            case $words[1] in
349"#
350        ));
351
352        // Add case for each subcommand
353        for cmd in &subcommands {
354            let sub_func = format!("{}_{}", func_name, cmd.name.replace('-', "_"));
355            out.push_str(&format!(
356                "                {name})\n                    _{sub_func} && ret=0\n                    ;;\n",
357                name = cmd.name,
358            ));
359        }
360
361        out.push_str(
362            r#"            esac
363            ;;
364    esac
365
366    return ret
367}
368
369"#,
370        );
371    } else {
372        // No subcommands, just complete options
373        out.push_str(
374            r#"    _arguments $options && ret=0
375    return ret
376}
377
378"#,
379        );
380    }
381}
382
383fn generate_zsh_subcommand_helpers(out: &mut String, args: &ArgLevelSchema, parent_func: &str) {
384    for (_, sub) in args.subcommands() {
385        let func_name = format!("{}_{}", parent_func, sub.cli_name().replace('-', "_"));
386        generate_zsh_function(out, sub.args(), &func_name, parent_func);
387        // Recurse for nested subcommands
388        generate_zsh_subcommand_helpers(out, sub.args(), &func_name);
389    }
390}
391
392fn escape_zsh_description(s: &str) -> String {
393    s.replace('\'', "'\\''")
394        .replace('[', "\\[")
395        .replace(']', "\\]")
396        .replace(':', "\\:")
397}
398
399// === Fish Completion ===
400
401fn generate_fish(args: &ArgLevelSchema, program_name: &str) -> String {
402    let mut out = String::new();
403
404    out.push_str(&format!("# Fish completion for {program_name}\n\n"));
405
406    // Generate completions for top level
407    generate_fish_level(&mut out, args, program_name, &[]);
408
409    // Generate completions for subcommands recursively
410    generate_fish_subcommands(&mut out, args, program_name, &[]);
411
412    out
413}
414
415fn generate_fish_level(out: &mut String, args: &ArgLevelSchema, program_name: &str, path: &[&str]) {
416    let (flags, subcommands) = collect_options(args);
417
418    // Build the condition for this level
419    let condition = if path.is_empty() {
420        "__fish_use_subcommand".to_string()
421    } else {
422        // Check that we're in this subcommand context
423        let seen_checks: Vec<String> = path
424            .iter()
425            .map(|cmd| format!("__fish_seen_subcommand_from {cmd}"))
426            .collect();
427        seen_checks.join("; and ")
428    };
429
430    // Add comment for this level
431    if !path.is_empty() {
432        out.push_str(&format!("\n# {} subcommand\n", path.join(" ")));
433    }
434
435    // Add flag completions for this level
436    for flag in &flags {
437        let desc = flag.doc.as_deref().unwrap_or("");
438        out.push_str(&format!("complete -c {program_name}"));
439
440        // Add condition if we're in a subcommand
441        if !path.is_empty() {
442            out.push_str(&format!(" -n '{condition}'"));
443        }
444
445        if let Some(short) = flag.short {
446            out.push_str(&format!(" -s {short}"));
447        }
448        out.push_str(&format!(" -l {}", flag.long));
449
450        // If flag takes a value, require an argument
451        if flag.takes_value {
452            out.push_str(" -r");
453        }
454
455        if !desc.is_empty() {
456            let escaped_desc = desc.replace('\'', "'\\''");
457            out.push_str(&format!(" -d '{escaped_desc}'"));
458        }
459        out.push('\n');
460    }
461
462    // Add subcommand completions
463    if !subcommands.is_empty() {
464        out.push_str(&format!(
465            "\n# {prefix}subcommands\n",
466            prefix = if path.is_empty() { "" } else { "Nested " }
467        ));
468
469        // Build condition that no subcommand of THIS level has been seen yet
470        let sub_names: Vec<&str> = subcommands.iter().map(|s| s.name.as_str()).collect();
471        let no_sub_condition = if path.is_empty() {
472            "__fish_use_subcommand".to_string()
473        } else {
474            format!(
475                "{}; and not __fish_seen_subcommand_from {}",
476                condition,
477                sub_names.join(" ")
478            )
479        };
480
481        for cmd in &subcommands {
482            let desc = cmd.doc.as_deref().unwrap_or("");
483            out.push_str(&format!(
484                "complete -c {program_name} -n '{no_sub_condition}' -f -a {name}",
485                name = cmd.name
486            ));
487            if !desc.is_empty() {
488                let escaped_desc = desc.replace('\'', "'\\''");
489                out.push_str(&format!(" -d '{escaped_desc}'"));
490            }
491            out.push('\n');
492        }
493    }
494}
495
496fn generate_fish_subcommands(
497    out: &mut String,
498    args: &ArgLevelSchema,
499    program_name: &str,
500    path: &[&str],
501) {
502    for (_, sub) in args.subcommands() {
503        let mut new_path = path.to_vec();
504        new_path.push(sub.cli_name());
505        generate_fish_level(out, sub.args(), program_name, &new_path);
506        generate_fish_subcommands(out, sub.args(), program_name, &new_path);
507    }
508}
509
510// === Helper types and functions ===
511
512struct FlagInfo {
513    long: String,
514    short: Option<char>,
515    doc: Option<String>,
516    takes_value: bool,
517}
518
519struct SubcommandInfo {
520    name: String,
521    doc: Option<String>,
522}
523
524/// Collect flags and subcommands from an ArgLevelSchema.
525///
526/// This uses the Schema which already has:
527/// - Flattened fields at the correct level
528/// - Renames applied (effective names)
529fn collect_options(args: &ArgLevelSchema) -> (Vec<FlagInfo>, Vec<SubcommandInfo>) {
530    let mut flags = Vec::new();
531    let mut subcommands = Vec::new();
532
533    // Collect flags from args (Schema already handles flatten)
534    for (name, arg) in args.args() {
535        if !arg.kind().is_positional() {
536            flags.push(arg_to_flag(name, arg));
537        }
538    }
539
540    // Collect subcommands (Schema already handles renames via cli_name)
541    for sub in args.subcommands().values() {
542        subcommands.push(subcommand_to_info(sub));
543    }
544
545    (flags, subcommands)
546}
547
548/// Convert an ArgSchema to FlagInfo.
549fn arg_to_flag(name: &str, arg: &ArgSchema) -> FlagInfo {
550    // Determine if this flag takes a value (not a boolean flag)
551    let takes_value = !arg.value().inner_if_option().is_bool();
552
553    FlagInfo {
554        // Use kebab-case for the CLI flag name
555        long: name.to_kebab_case(),
556        short: arg.kind().short(),
557        doc: arg.docs().summary().map(|s| s.trim().to_string()),
558        takes_value,
559    }
560}
561
562/// Convert a Subcommand to SubcommandInfo.
563fn subcommand_to_info(sub: &Subcommand) -> SubcommandInfo {
564    SubcommandInfo {
565        // cli_name is already kebab-case and respects renames
566        name: sub.cli_name().to_string(),
567        doc: sub.docs().summary().map(|s| s.trim().to_string()),
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use facet::Facet;
575    use figue_attrs as args;
576
577    /// Common arguments that can be flattened into other structs
578    #[derive(Facet)]
579    struct CommonArgs {
580        /// Enable verbose output
581        #[facet(args::named, crate::short = 'v')]
582        verbose: bool,
583
584        /// Enable quiet mode
585        #[facet(args::named, crate::short = 'q')]
586        quiet: bool,
587    }
588
589    /// Args struct with flattened common args
590    #[derive(Facet)]
591    struct ArgsWithFlatten {
592        /// Input file
593        #[facet(args::positional)]
594        input: String,
595
596        /// Common options
597        #[facet(flatten)]
598        common: CommonArgs,
599    }
600
601    #[test]
602    fn test_flatten_args_appear_in_completions() {
603        let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
604        let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
605
606        // Flattened fields should appear at top level
607        assert!(
608            completions.contains("--verbose"),
609            "completions should contain --verbose from flattened CommonArgs"
610        );
611        assert!(
612            completions.contains("-v"),
613            "completions should contain -v short flag"
614        );
615        assert!(
616            completions.contains("--quiet"),
617            "completions should contain --quiet from flattened CommonArgs"
618        );
619        assert!(
620            completions.contains("-q"),
621            "completions should contain -q short flag"
622        );
623
624        // The flattened field name 'common' should NOT appear as a flag
625        assert!(
626            !completions.contains("--common"),
627            "completions should not show --common as a flag"
628        );
629    }
630
631    /// Test struct with a renamed field
632    #[derive(Facet)]
633    struct ArgsWithRename {
634        /// Enable debug mode
635        #[facet(args::named, rename = "debug-mode")]
636        debug: bool,
637
638        /// Set output file
639        #[facet(args::named, rename = "out")]
640        output_file: String,
641    }
642
643    #[test]
644    fn test_rename_respected_in_completions() {
645        let schema = Schema::from_shape(ArgsWithRename::SHAPE).unwrap();
646        let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
647
648        // Renamed flags should use the renamed name
649        assert!(
650            completions.contains("--debug-mode"),
651            "completions should contain --debug-mode (renamed from debug)"
652        );
653        assert!(
654            completions.contains("--out"),
655            "completions should contain --out (renamed from output_file)"
656        );
657
658        // Original names should NOT appear
659        assert!(
660            !completions.contains("--debug ") && !completions.contains("--debug\n"),
661            "completions should not show --debug (was renamed to --debug-mode)"
662        );
663        assert!(
664            !completions.contains("--output-file"),
665            "completions should not show --output-file (was renamed to --out)"
666        );
667    }
668
669    /// Subcommand enum with renamed variant
670    #[derive(Facet)]
671    #[repr(u8)]
672    #[allow(dead_code)]
673    enum CommandWithRename {
674        /// List all items
675        List,
676        /// Remove an item
677        #[facet(rename = "rm")]
678        Remove,
679    }
680
681    /// Args with subcommand that has a renamed variant
682    #[derive(Facet)]
683    struct ArgsWithRenamedSubcommand {
684        #[facet(args::subcommand)]
685        command: Option<CommandWithRename>,
686    }
687
688    #[test]
689    fn test_subcommand_rename_respected_in_completions() {
690        let schema = Schema::from_shape(ArgsWithRenamedSubcommand::SHAPE).unwrap();
691        let completions = generate_completions_for_schema(&schema, Shell::Bash, "myapp");
692
693        // Should use the CLI name (kebab-case of effective name)
694        // Bash format is: commands="list rm"
695        assert!(
696            completions.contains("list"),
697            "completions should contain 'list' subcommand"
698        );
699        assert!(
700            completions.contains("rm"),
701            "completions should contain 'rm' subcommand (renamed from Remove)"
702        );
703
704        // Original name 'remove' should NOT appear (was renamed to 'rm')
705        // We need to be careful: "remove" should not appear as a standalone subcommand
706        assert!(
707            !completions.contains("remove"),
708            "completions should not show 'remove' (was renamed to 'rm')"
709        );
710    }
711
712    #[test]
713    fn test_zsh_completions_with_docs() {
714        let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
715        let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
716
717        // Doc comments should appear in zsh completions
718        assert!(
719            completions.contains("verbose output"),
720            "zsh completions should include doc for --verbose"
721        );
722        assert!(
723            completions.contains("quiet mode"),
724            "zsh completions should include doc for --quiet"
725        );
726    }
727
728    #[test]
729    fn test_fish_completions_with_docs() {
730        let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
731        let completions = generate_completions_for_schema(&schema, Shell::Fish, "myapp");
732
733        // Doc comments should appear in fish completions
734        assert!(
735            completions.contains("verbose output"),
736            "fish completions should include doc for --verbose"
737        );
738        assert!(
739            completions.contains("quiet mode"),
740            "fish completions should include doc for --quiet"
741        );
742    }
743
744    /// Git-like CLI with nested subcommands for testing
745    #[derive(Facet)]
746    #[allow(dead_code)]
747    struct GitLikeArgs {
748        /// Show version information
749        #[facet(args::named)]
750        version: bool,
751
752        /// Git command to run
753        #[facet(args::subcommand)]
754        command: Option<GitCommand>,
755    }
756
757    #[derive(Facet)]
758    #[repr(u8)]
759    #[allow(dead_code)]
760    enum GitCommand {
761        /// Clone a repository
762        Clone {
763            /// The repository URL to clone
764            #[facet(args::positional)]
765            url: String,
766
767            /// Clone only the specified branch
768            #[facet(default, args::named, args::short = 'b')]
769            branch: Option<String>,
770        },
771        /// Manage remotes
772        Remote {
773            /// Remote action to perform
774            #[facet(args::subcommand)]
775            action: RemoteAction,
776        },
777    }
778
779    #[derive(Facet)]
780    #[repr(u8)]
781    #[allow(dead_code)]
782    enum RemoteAction {
783        /// Add a remote
784        Add {
785            /// Name of the remote
786            #[facet(args::positional)]
787            name: String,
788        },
789        /// Remove a remote
790        #[facet(rename = "rm")]
791        Remove {
792            /// Name of the remote
793            #[facet(args::positional)]
794            name: String,
795        },
796    }
797
798    #[test]
799    fn test_zsh_inline_sourcing_format() {
800        let schema = Schema::from_shape(ArgsWithFlatten::SHAPE).unwrap();
801        let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
802
803        // Should have the inline-compatible footer
804        assert!(
805            completions.contains("compdef _myapp myapp"),
806            "zsh completions should have compdef for inline sourcing"
807        );
808        assert!(
809            completions.contains(r#"if [ "$funcstack[1]" = "_myapp" ]"#),
810            "zsh completions should detect autoload vs inline sourcing"
811        );
812    }
813
814    #[test]
815    fn test_nested_subcommands_zsh() {
816        let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
817        let completions = generate_completions_for_schema(&schema, Shell::Zsh, "git");
818
819        // Should have top-level subcommands
820        assert!(
821            completions.contains("'clone:"),
822            "zsh completions should have clone subcommand"
823        );
824        assert!(
825            completions.contains("'remote:"),
826            "zsh completions should have remote subcommand"
827        );
828
829        // Should have nested subcommand function for 'remote'
830        assert!(
831            completions.contains("_git_remote()"),
832            "zsh completions should generate function for remote subcommand"
833        );
834
835        // Should have nested subcommand 'rm' (renamed from Remove)
836        assert!(
837            completions.contains("'rm:"),
838            "zsh completions should have 'rm' subcommand (renamed from Remove)"
839        );
840    }
841
842    #[test]
843    fn test_nested_subcommands_bash() {
844        let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
845        let completions = generate_completions_for_schema(&schema, Shell::Bash, "git");
846
847        // Should have top-level subcommand handling
848        assert!(
849            completions.contains("clone"),
850            "bash completions should have clone subcommand"
851        );
852        assert!(
853            completions.contains("remote"),
854            "bash completions should have remote subcommand"
855        );
856
857        // Should generate subcommand completion functions
858        assert!(
859            completions.contains("_git_remote()"),
860            "bash completions should generate function for remote subcommand"
861        );
862
863        // Nested subcommand 'rm' should appear
864        assert!(
865            completions.contains("rm"),
866            "bash completions should have 'rm' subcommand"
867        );
868    }
869
870    #[test]
871    fn test_nested_subcommands_fish() {
872        let schema = Schema::from_shape(GitLikeArgs::SHAPE).unwrap();
873        let completions = generate_completions_for_schema(&schema, Shell::Fish, "git");
874
875        // Should have top-level subcommands
876        assert!(
877            completions.contains("-a clone"),
878            "fish completions should have clone subcommand"
879        );
880        assert!(
881            completions.contains("-a remote"),
882            "fish completions should have remote subcommand"
883        );
884
885        // Should have nested subcommand handling
886        assert!(
887            completions.contains("__fish_seen_subcommand_from remote"),
888            "fish completions should handle remote subcommand context"
889        );
890
891        // Nested 'rm' subcommand
892        assert!(
893            completions.contains("-a rm"),
894            "fish completions should have 'rm' subcommand"
895        );
896    }
897
898    #[test]
899    fn test_value_flags_distinguished_from_bool_flags() {
900        let schema = Schema::from_shape(ArgsWithRename::SHAPE).unwrap();
901        let completions = generate_completions_for_schema(&schema, Shell::Zsh, "myapp");
902
903        // Bool flag (debug-mode) should NOT have :value:_default
904        // The debug field is bool
905        assert!(
906            !completions.contains("--debug-mode[Enable debug mode]:value"),
907            "bool flag should not require value"
908        );
909
910        // String flag (out) SHOULD have :value:_default
911        assert!(
912            completions.contains("--out[Set output file]:value:_default"),
913            "string flag should require value"
914        );
915    }
916}