Skip to main content

git_worktree_manager/
shell_functions.rs

1/// Shell function generation for gw-cd.
2///
3/// Outputs shell-specific function definitions for bash/zsh/fish/powershell.
4/// Generate shell function for the specified shell.
5pub fn generate(shell: &str) -> Option<String> {
6    match shell {
7        "bash" | "zsh" => Some(BASH_ZSH_FUNCTION.to_string()),
8        "fish" => Some(FISH_FUNCTION.to_string()),
9        "powershell" | "pwsh" => Some(POWERSHELL_FUNCTION.to_string()),
10        _ => None,
11    }
12}
13
14const BASH_ZSH_FUNCTION: &str = r#"# git-worktree-manager shell functions for bash/zsh
15# Source this file to enable shell functions:
16#   source <(gw _shell-function bash)
17
18# Navigate to a worktree by branch name
19# If no argument is provided, show interactive worktree selector
20# Use -g/--global to search across all registered repositories
21# Supports repo:branch notation (auto-enables global mode)
22gw-cd() {
23    local branch=""
24    local global_mode=0
25
26    # Parse arguments
27    while [ $# -gt 0 ]; do
28        case "$1" in
29            -g|--global)
30                global_mode=1
31                shift
32                ;;
33            -*)
34                echo "Error: Unknown option '$1'" >&2
35                echo "Usage: gw-cd [-g|--global] [branch|repo:branch]" >&2
36                return 1
37                ;;
38            *)
39                branch="$1"
40                shift
41                ;;
42        esac
43    done
44
45    # Auto-detect repo:branch notation → enable global mode
46    if [ $global_mode -eq 0 ] && [[ "$branch" == *:* ]]; then
47        global_mode=1
48    fi
49
50    local worktree_path
51
52    if [ -z "$branch" ]; then
53        # No argument — interactive selector
54        if [ $global_mode -eq 1 ]; then
55            worktree_path=$(gw _path -g --interactive)
56        else
57            worktree_path=$(gw _path --interactive)
58        fi
59        if [ $? -ne 0 ]; then return 1; fi
60    elif [ $global_mode -eq 1 ]; then
61        # Global mode: delegate to gw _path -g
62        worktree_path=$(gw _path -g "$branch")
63        if [ $? -ne 0 ]; then return 1; fi
64    else
65        # Local mode: get worktree path from git directly
66        worktree_path=$(git worktree list --porcelain 2>/dev/null | awk -v branch="$branch" '
67            /^worktree / { path=$2 }
68            /^branch / && $2 == "refs/heads/"branch { print path; exit }
69        ')
70    fi
71
72    if [ -z "$worktree_path" ]; then
73        echo "Error: No worktree found for branch '$branch'" >&2
74        return 1
75    fi
76
77    if [ -d "$worktree_path" ]; then
78        cd "$worktree_path" || return 1
79        echo "Switched to worktree: $worktree_path"
80    else
81        echo "Error: Worktree directory not found: $worktree_path" >&2
82        return 1
83    fi
84}
85
86# Tab completion for gw-cd (bash)
87_gw_cd_completion() {
88    local cur="${COMP_WORDS[COMP_CWORD]}"
89    local has_global=0
90
91    # Remove colon from word break chars for repo:branch completion
92    COMP_WORDBREAKS=${COMP_WORDBREAKS//:}
93
94    # Check if -g or --global is already in the command
95    local i
96    for i in "${COMP_WORDS[@]}"; do
97        case "$i" in -g|--global) has_global=1 ;; esac
98    done
99
100    # If current word starts with -, complete flags
101    if [[ "$cur" == -* ]]; then
102        COMPREPLY=($(compgen -W "-g --global" -- "$cur"))
103        return
104    fi
105
106    local branches
107    if [ $has_global -eq 1 ]; then
108        # Global mode: get repo:branch from all registered repos
109        branches=$(gw _path --list-branches -g 2>/dev/null)
110    else
111        # Local mode: get branches directly from git
112        branches=$(git worktree list --porcelain 2>/dev/null | grep "^branch " | sed 's/^branch refs\/heads\///' | sort -u)
113    fi
114    COMPREPLY=($(compgen -W "$branches" -- "$cur"))
115}
116
117# Register completion for bash
118if [ -n "$BASH_VERSION" ]; then
119    complete -F _gw_cd_completion gw-cd
120    complete -F _gw_cd_completion cw-cd
121    eval "$(gw --generate-completion bash 2>/dev/null || true)"
122
123    # Wrap _gw to add dynamic completion (config keys + branch names)
124    _gw_with_config() {
125        local cur="${COMP_WORDS[COMP_CWORD]}"
126        local subcmd="${COMP_WORDS[1]}"
127
128        # --term/-T value completion for new/resume commands
129        if [[ ($subcmd == "new" || $subcmd == "resume") && (${COMP_WORDS[COMP_CWORD-1]} == "--term" || ${COMP_WORDS[COMP_CWORD-1]} == "-T") ]]; then
130            local methods
131            methods=$(gw _term-values 2>/dev/null)
132            COMPREPLY=($(compgen -W "$methods" -- "$cur"))
133            return
134        fi
135
136        # Config key completion: "config get <key>" or "config set <key>"
137        if [[ $subcmd == "config" && ( ${COMP_WORDS[2]} == "get" || ${COMP_WORDS[2]} == "set" ) && $COMP_CWORD -eq 3 ]]; then
138            local keys
139            keys=$(gw _config-keys 2>/dev/null)
140            COMPREPLY=($(compgen -W "$keys" -- "$cur"))
141            return
142        fi
143
144        # Config set value completion: "config set <key> <value>"
145        if [[ $subcmd == "config" && ${COMP_WORDS[2]} == "set" && $COMP_CWORD -eq 4 ]]; then
146            local key="${COMP_WORDS[3]}"
147            case "$key" in
148                launch.method)
149                    COMPREPLY=($(compgen -W "$(gw _term-values 2>/dev/null)" -- "$cur"))
150                    return ;;
151                ai_tool.command)
152                    COMPREPLY=($(compgen -W "$(gw _preset-names 2>/dev/null)" -- "$cur"))
153                    return ;;
154            esac
155        fi
156
157        # Branch completion for subcommands with positional branch args
158        if [[ "$cur" != -* ]]; then
159            # Check for global mode
160            local gflag=""
161            local i
162            for i in "${COMP_WORDS[@]}"; do
163                case "$i" in -g|--global) gflag="-g" ;; esac
164            done
165
166            # Count non-flag positional args after subcommand (skip flag values)
167            local pos_count=0
168            local start_idx=2
169            local max_pos=1
170
171            case "$subcmd" in
172                pr|merge|resume|shell|delete|sync)
173                    max_pos=1
174                    ;;
175                diff|change-base)
176                    max_pos=2
177                    ;;
178                backup)
179                    if [[ ${COMP_WORDS[2]} =~ ^(create|list|restore)$ ]]; then
180                        start_idx=3; max_pos=1
181                    else
182                        max_pos=0
183                    fi
184                    ;;
185                stash)
186                    if [[ ${COMP_WORDS[2]} == "apply" ]]; then
187                        start_idx=3; max_pos=1
188                    else
189                        max_pos=0
190                    fi
191                    ;;
192                *)
193                    max_pos=0
194                    ;;
195            esac
196
197            if [[ $max_pos -gt 0 ]]; then
198                for ((i=start_idx; i<COMP_CWORD; i++)); do
199                    [[ ${COMP_WORDS[i]} != -* ]] && ((pos_count++))
200                done
201                if [[ $pos_count -lt $max_pos ]]; then
202                    local branches
203                    branches=$(gw _path --list-branches $gflag 2>/dev/null)
204                    COMPREPLY=($(compgen -W "$branches" -- "$cur"))
205                    return
206                fi
207            fi
208        fi
209
210        _gw "$@"
211    }
212    complete -F _gw_with_config -o bashdefault -o default gw
213    complete -F _gw_with_config -o bashdefault -o default cw
214fi
215
216# Tab completion for zsh
217if [ -n "$ZSH_VERSION" ]; then
218    # Register clap completion for gw/cw CLI inline
219    eval "$(gw --generate-completion zsh 2>/dev/null)"
220
221    # Wrap _gw to add dynamic completion (config keys + branch names)
222    _gw_with_config() {
223        local subcmd="${words[2]}"
224
225        # --term/-T value completion for new/resume commands
226        if [[ ($subcmd == "new" || $subcmd == "resume") && (${words[CURRENT-1]} == "--term" || ${words[CURRENT-1]} == "-T") ]]; then
227            local -a methods
228            methods=(${(f)"$(gw _term-values 2>/dev/null)"})
229            compadd -a methods
230            return
231        fi
232
233        # Config key completion: "config get <key>" or "config set <key>"
234        if [[ $subcmd == "config" && ( ${words[3]} == "get" || ${words[3]} == "set" ) && $CURRENT -eq 4 ]]; then
235            local -a keys
236            keys=(${(f)"$(gw _config-keys 2>/dev/null)"})
237            _describe 'config key' keys
238            return
239        fi
240
241        # Config set value completion: "config set <key> <value>"
242        if [[ $subcmd == "config" && ${words[3]} == "set" && $CURRENT -eq 5 ]]; then
243            local key="${words[4]}"
244            case "$key" in
245                launch.method)
246                    local -a methods
247                    methods=(${(f)"$(gw _term-values 2>/dev/null)"})
248                    compadd -a methods
249                    return ;;
250                ai_tool.command)
251                    local -a presets
252                    presets=(${(f)"$(gw _preset-names 2>/dev/null)"})
253                    compadd -a presets
254                    return ;;
255            esac
256        fi
257
258        # Branch completion for subcommands with positional branch args
259        if [[ "${words[CURRENT]}" != -* ]]; then
260            # Check for global mode
261            local gflag=""
262            local w
263            for w in "${words[@]}"; do
264                case "$w" in -g|--global) gflag="-g" ;; esac
265            done
266
267            # Count non-flag positional args after subcommand
268            local -i pos_count=0
269            local -i start_idx=3
270            local -i max_pos=0
271
272            case "$subcmd" in
273                pr|merge|resume|shell|delete|sync)
274                    max_pos=1
275                    ;;
276                diff|change-base)
277                    max_pos=2
278                    ;;
279                backup)
280                    case "${words[3]}" in create|list|restore)
281                        start_idx=4; max_pos=1 ;; esac
282                    ;;
283                stash)
284                    if [[ ${words[3]} == "apply" ]]; then
285                        start_idx=4; max_pos=1
286                    fi
287                    ;;
288            esac
289
290            if [[ $max_pos -gt 0 ]]; then
291                local -i i
292                for ((i=start_idx; i<CURRENT; i++)); do
293                    [[ ${words[i]} != -* ]] && ((pos_count++))
294                done
295                if [[ $pos_count -lt $max_pos ]]; then
296                    local -a branches
297                    branches=(${(f)"$(gw _path --list-branches $gflag 2>/dev/null)"})
298                    compadd -a branches
299                    return
300                fi
301            fi
302        fi
303
304        _gw "$@"
305    }
306    compdef _gw_with_config gw
307    compdef _gw_with_config cw
308
309    _gw_cd_zsh() {
310        local has_global=0
311        local i
312        for i in "${words[@]}"; do
313            case "$i" in -g|--global) has_global=1 ;; esac
314        done
315
316        # Complete flags
317        if [[ "$PREFIX" == -* ]]; then
318            local -a flags
319            flags=('-g:Search all registered repositories' '--global:Search all registered repositories')
320            _describe 'flags' flags
321            return
322        fi
323
324        local -a branches
325        if [ $has_global -eq 1 ]; then
326            branches=(${(f)"$(gw _path --list-branches -g 2>/dev/null)"})
327        else
328            branches=(${(f)"$(git worktree list --porcelain 2>/dev/null | grep '^branch ' | sed 's/^branch refs\/heads\///' | sort -u)"})
329        fi
330        compadd -a branches
331    }
332    compdef _gw_cd_zsh gw-cd
333fi
334
335# Backward compatibility: cw-cd alias
336cw-cd() { gw-cd "$@"; }
337if [ -n "$BASH_VERSION" ]; then
338    complete -F _gw_cd_completion cw-cd
339fi
340if [ -n "$ZSH_VERSION" ]; then
341    compdef _gw_cd_zsh cw-cd
342fi
343"#;
344
345const FISH_FUNCTION: &str = r#"# git-worktree-manager shell functions for fish
346# Source this file to enable shell functions:
347#   gw _shell-function fish | source
348
349# Navigate to a worktree by branch name
350# If no argument is provided, show interactive worktree selector
351# Use -g/--global to search across all registered repositories
352# Supports repo:branch notation (auto-enables global mode)
353function gw-cd
354    set -l global_mode 0
355    set -l branch ""
356
357    # Parse arguments
358    for arg in $argv
359        switch $arg
360            case -g --global
361                set global_mode 1
362            case '-*'
363                echo "Error: Unknown option '$arg'" >&2
364                echo "Usage: gw-cd [-g|--global] [branch|repo:branch]" >&2
365                return 1
366            case '*'
367                set branch $arg
368        end
369    end
370
371    # Auto-detect repo:branch notation → enable global mode
372    if test $global_mode -eq 0; and string match -q '*:*' -- "$branch"
373        set global_mode 1
374    end
375
376    set -l worktree_path
377
378    if test -z "$branch"
379        # No argument — interactive selector
380        if test $global_mode -eq 1
381            set worktree_path (gw _path -g --interactive)
382        else
383            set worktree_path (gw _path --interactive)
384        end
385        if test $status -ne 0
386            return 1
387        end
388    else if test $global_mode -eq 1
389        # Global mode: delegate to gw _path -g
390        set worktree_path (gw _path -g "$branch")
391        if test $status -ne 0
392            return 1
393        end
394    else
395        # Local mode: get worktree path from git directly
396        set worktree_path (git worktree list --porcelain 2>/dev/null | awk -v branch="$branch" '
397            /^worktree / { path=$2 }
398            /^branch / && $2 == "refs/heads/"branch { print path; exit }
399        ')
400    end
401
402    if test -z "$worktree_path"
403        if test -z "$branch"
404            echo "Error: No worktree found (not in a git repository?)" >&2
405        else
406            echo "Error: No worktree found for branch '$branch'" >&2
407        end
408        return 1
409    end
410
411    if test -d "$worktree_path"
412        cd "$worktree_path"; or return 1
413        echo "Switched to worktree: $worktree_path"
414    else
415        echo "Error: Worktree directory not found: $worktree_path" >&2
416        return 1
417    end
418end
419
420# Tab completion for gw-cd
421# Complete -g/--global flag
422complete -c gw-cd -s g -l global -d 'Search all registered repositories'
423
424# Complete branch names: global mode if -g is present, otherwise local git
425complete -c gw-cd -f -n '__fish_contains_opt -s g global' -a '(gw _path --list-branches -g 2>/dev/null)'
426complete -c gw-cd -f -n 'not __fish_contains_opt -s g global' -a '(git worktree list --porcelain 2>/dev/null | grep "^branch " | sed "s|^branch refs/heads/||" | sort -u)'
427
428# Backward compatibility: cw-cd alias
429function cw-cd; gw-cd $argv; end
430complete -c cw-cd -w gw-cd
431
432# Tab completion for gw/cw CLI (clap-generated)
433gw --generate-completion fish 2>/dev/null | source
434
435# Config key completion for gw config get/set
436complete -c gw -f -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set' -a '(gw _config-keys 2>/dev/null)'
437complete -c cw -f -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set' -a '(gw _config-keys 2>/dev/null)'
438
439# Branch completion for subcommands with positional branch args
440for cmd in pr merge resume shell delete sync diff change-base
441    complete -c gw -f -n "__fish_seen_subcommand_from $cmd" -a '(gw _path --list-branches 2>/dev/null)'
442    complete -c cw -f -n "__fish_seen_subcommand_from $cmd" -a '(gw _path --list-branches 2>/dev/null)'
443end
444
445# Branch completion for nested subcommands: backup create/list/restore, stash apply
446complete -c gw -f -n '__fish_seen_subcommand_from backup; and __fish_seen_subcommand_from create list restore' -a '(gw _path --list-branches 2>/dev/null)'
447complete -c cw -f -n '__fish_seen_subcommand_from backup; and __fish_seen_subcommand_from create list restore' -a '(gw _path --list-branches 2>/dev/null)'
448complete -c gw -f -n '__fish_seen_subcommand_from stash; and __fish_seen_subcommand_from apply' -a '(gw _path --list-branches 2>/dev/null)'
449complete -c cw -f -n '__fish_seen_subcommand_from stash; and __fish_seen_subcommand_from apply' -a '(gw _path --list-branches 2>/dev/null)'
450
451# --term/-T value completion for new/resume commands
452function __gw_prev_arg_is_term
453    set -l tokens (commandline -opc)
454    set -l prev $tokens[-1]
455    test "$prev" = "--term" -o "$prev" = "-T"
456end
457complete -c gw -f -n '__fish_seen_subcommand_from new resume; and __gw_prev_arg_is_term' -a '(gw _term-values 2>/dev/null)'
458complete -c cw -f -n '__fish_seen_subcommand_from new resume; and __gw_prev_arg_is_term' -a '(gw _term-values 2>/dev/null)'
459
460# Config set value completion: "config set launch.method <value>" / "config set ai_tool.command <value>"
461function __gw_config_set_value_launch
462    set -l tokens (commandline -opc)
463    test (count $tokens) -ge 4; and test "$tokens[2]" = "config"; and test "$tokens[3]" = "set"; and test "$tokens[4]" = "launch.method"
464end
465function __gw_config_set_value_ai_tool
466    set -l tokens (commandline -opc)
467    test (count $tokens) -ge 4; and test "$tokens[2]" = "config"; and test "$tokens[3]" = "set"; and test "$tokens[4]" = "ai_tool.command"
468end
469complete -c gw -f -n '__gw_config_set_value_launch' -a '(gw _term-values 2>/dev/null)'
470complete -c cw -f -n '__gw_config_set_value_launch' -a '(gw _term-values 2>/dev/null)'
471complete -c gw -f -n '__gw_config_set_value_ai_tool' -a '(gw _preset-names 2>/dev/null)'
472complete -c cw -f -n '__gw_config_set_value_ai_tool' -a '(gw _preset-names 2>/dev/null)'
473"#;
474
475const POWERSHELL_FUNCTION: &str = r#"# git-worktree-manager shell functions for PowerShell
476# Source this file to enable shell functions:
477#   gw _shell-function powershell | Out-String | Invoke-Expression
478
479# Navigate to a worktree by branch name
480# If no argument is provided, show interactive worktree selector
481# Use -g to search across all registered repositories
482# Supports repo:branch notation (auto-enables global mode)
483function gw-cd {
484    param(
485        [Parameter(Mandatory=$false, Position=0)]
486        [string]$Branch,
487        [Alias('global')]
488        [switch]$g
489    )
490
491    # Auto-detect repo:branch notation → enable global mode
492    if (-not $g -and $Branch -match ':') {
493        $g = [switch]::Present
494    }
495
496    $worktreePath = $null
497
498    if (-not $Branch) {
499        # No argument — interactive selector
500        if ($g) {
501            $worktreePath = gw _path -g --interactive
502        } else {
503            $worktreePath = gw _path --interactive
504        }
505        if ($LASTEXITCODE -ne 0) {
506            return
507        }
508    } elseif ($g) {
509        # Global mode: delegate to gw _path -g
510        $worktreePath = gw _path -g $Branch
511        if ($LASTEXITCODE -ne 0) {
512            return
513        }
514    } else {
515        # Local mode: get worktree path from git directly
516        $worktreePath = git worktree list --porcelain 2>&1 |
517            Where-Object { $_ -is [string] } |
518            ForEach-Object {
519                if ($_ -match '^worktree (.+)$') { $path = $Matches[1] }
520                if ($_ -match "^branch refs/heads/$Branch$") { $path }
521            } | Select-Object -First 1
522    }
523
524    if (-not $worktreePath) {
525        if (-not $Branch) {
526            Write-Error "Error: No worktree found (not in a git repository?)"
527        } else {
528            Write-Error "Error: No worktree found for branch '$Branch'"
529        }
530        return
531    }
532
533    if (Test-Path -Path $worktreePath -PathType Container) {
534        Set-Location -Path $worktreePath
535        Write-Host "Switched to worktree: $worktreePath"
536    } else {
537        Write-Error "Error: Worktree directory not found: $worktreePath"
538        return
539    }
540}
541
542# Backward compatibility: cw-cd alias
543Set-Alias -Name cw-cd -Value gw-cd
544
545# Tab completion for gw-cd
546Register-ArgumentCompleter -CommandName gw-cd -ParameterName Branch -ScriptBlock {
547    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
548
549    $branches = $null
550    if ($fakeBoundParameters.ContainsKey('g')) {
551        # Global mode: get repo:branch from all registered repos
552        $branches = gw _path --list-branches -g 2>&1 |
553            Where-Object { $_ -is [string] -and $_.Trim() } |
554            Sort-Object -Unique
555    } else {
556        # Local mode: get branches from git
557        $branches = git worktree list --porcelain 2>&1 |
558            Where-Object { $_ -is [string] } |
559            Select-String -Pattern '^branch ' |
560            ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
561            Sort-Object -Unique
562    }
563
564    # Filter branches that match the current word
565    $branches | Where-Object { $_ -like "$wordToComplete*" } |
566        ForEach-Object {
567            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
568        }
569}
570
571# Tab completion for cw-cd (backward compat)
572Register-ArgumentCompleter -CommandName cw-cd -ParameterName Branch -ScriptBlock {
573    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
574
575    $branches = $null
576    if ($fakeBoundParameters.ContainsKey('g')) {
577        $branches = gw _path --list-branches -g 2>&1 |
578            Where-Object { $_ -is [string] -and $_.Trim() } |
579            Sort-Object -Unique
580    } else {
581        $branches = git worktree list --porcelain 2>&1 |
582            Where-Object { $_ -is [string] } |
583            Select-String -Pattern '^branch ' |
584            ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
585            Sort-Object -Unique
586    }
587
588    $branches | Where-Object { $_ -like "$wordToComplete*" } |
589        ForEach-Object {
590            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
591        }
592}
593"#;
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn test_generate_bash() {
601        let result = generate("bash");
602        assert!(result.is_some());
603        let script = result.unwrap();
604        assert!(script.contains("gw-cd()"));
605        assert!(script.contains("_gw_cd_completion"));
606        assert!(script.contains("cw-cd"));
607        assert!(script.contains("BASH_VERSION"));
608        assert!(script.contains("ZSH_VERSION"));
609        assert!(script.contains("_gw_cd_zsh"));
610    }
611
612    #[test]
613    fn test_generate_zsh() {
614        let result = generate("zsh");
615        assert!(result.is_some());
616        let script = result.unwrap();
617        assert!(script.contains("compdef _gw_cd_zsh gw-cd"));
618        assert!(script.contains("compdef _gw_cd_zsh cw-cd"));
619    }
620
621    #[test]
622    fn test_generate_fish() {
623        let result = generate("fish");
624        assert!(result.is_some());
625        let script = result.unwrap();
626        assert!(script.contains("function gw-cd"));
627        assert!(script.contains("complete -c gw-cd"));
628        assert!(script.contains("function cw-cd"));
629        assert!(script.contains("complete -c cw-cd -w gw-cd"));
630    }
631
632    #[test]
633    fn test_generate_powershell() {
634        let result = generate("powershell");
635        assert!(result.is_some());
636        let script = result.unwrap();
637        assert!(script.contains("function gw-cd"));
638        assert!(script.contains("Register-ArgumentCompleter"));
639        assert!(script.contains("Set-Alias -Name cw-cd -Value gw-cd"));
640    }
641
642    #[test]
643    fn test_generate_pwsh_alias() {
644        let result = generate("pwsh");
645        assert!(result.is_some());
646        // pwsh should return the same as powershell
647        assert_eq!(result, generate("powershell"));
648    }
649
650    #[test]
651    fn test_generate_unknown() {
652        assert!(generate("unknown").is_none());
653        assert!(generate("").is_none());
654    }
655
656    /// Verify bash/zsh script has valid syntax using `bash -n`.
657    #[test]
658    #[cfg(not(windows))]
659    fn test_bash_script_syntax() {
660        let script = generate("bash").unwrap();
661
662        // bash -n: check syntax without executing
663        let output = std::process::Command::new("bash")
664            .arg("-n")
665            .stdin(std::process::Stdio::piped())
666            .stdout(std::process::Stdio::piped())
667            .stderr(std::process::Stdio::piped())
668            .spawn()
669            .and_then(|mut child| {
670                use std::io::Write;
671                child.stdin.take().unwrap().write_all(script.as_bytes())?;
672                child.wait_with_output()
673            });
674
675        match output {
676            Ok(out) => {
677                let stderr = String::from_utf8_lossy(&out.stderr);
678                assert!(
679                    out.status.success(),
680                    "bash -n failed for generated bash/zsh script:\n{}",
681                    stderr
682                );
683            }
684            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
685                eprintln!("bash not found, skipping syntax check");
686            }
687            Err(e) => panic!("failed to run bash -n: {}", e),
688        }
689    }
690
691    /// Verify fish script has valid syntax using `fish --no-execute`.
692    #[test]
693    fn test_fish_script_syntax() {
694        let script = generate("fish").unwrap();
695
696        let output = std::process::Command::new("fish")
697            .arg("--no-execute")
698            .stdin(std::process::Stdio::piped())
699            .stdout(std::process::Stdio::piped())
700            .stderr(std::process::Stdio::piped())
701            .spawn()
702            .and_then(|mut child| {
703                use std::io::Write;
704                child.stdin.take().unwrap().write_all(script.as_bytes())?;
705                child.wait_with_output()
706            });
707
708        match output {
709            Ok(out) => {
710                let stderr = String::from_utf8_lossy(&out.stderr);
711                assert!(
712                    out.status.success(),
713                    "fish --no-execute failed for generated fish script:\n{}",
714                    stderr
715                );
716            }
717            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
718                eprintln!("fish not found, skipping syntax check");
719            }
720            Err(e) => panic!("failed to run fish --no-execute: {}", e),
721        }
722    }
723}