Skip to main content

try_rs/
shell.rs

1use crate::cli::Shell;
2use crate::config::{get_base_config_dir, get_config_dir};
3use anyhow::Result;
4use std::fs;
5use std::io::Write;
6use std::path::PathBuf;
7
8const FISH_PICKER_FUNCTION: &str = r#"function try-rs-picker
9    set -l picker_args --inline-picker
10
11    if set -q TRY_RS_PICKER_HEIGHT
12        if string match -qr '^[0-9]+$' -- "$TRY_RS_PICKER_HEIGHT"
13            set picker_args $picker_args --inline-height $TRY_RS_PICKER_HEIGHT
14        end
15    end
16
17    if status --is-interactive
18        printf "\n"
19    end
20
21    set command (command try-rs $picker_args | string collect)
22    set command_status $status
23
24    if test $command_status -eq 0; and test -n "$command"
25        eval $command
26    end
27
28    if status --is-interactive
29        printf "\033[A"
30        commandline -f repaint
31    end
32end
33"#;
34
35/// Returns the shell integration script content for the given shell type.
36/// This is used by --setup-stdout to print the content to stdout.
37pub fn get_shell_content(shell: &Shell) -> String {
38    let completions = get_completions_script(shell);
39    match shell {
40        Shell::Fish => {
41            format!(
42                r#"function try-rs
43    # Pass flags/options directly to stdout without capturing
44    for arg in $argv
45        if string match -q -- '-*' $arg
46            command try-rs $argv
47            return
48        end
49    end
50
51    # Captures the output of the binary (stdout) which is the "cd" command
52    # The TUI is rendered on stderr, so it doesn't interfere.
53    set command (command try-rs $argv | string collect)
54    set command_status $status
55
56    if test $command_status -eq 0; and test -n "$command"
57        eval $command
58    end
59end
60
61{picker_function}
62
63{completions}"#,
64                picker_function = FISH_PICKER_FUNCTION,
65            )
66        }
67        Shell::Zsh => {
68            format!(
69                r#"try-rs() {{
70    # Pass flags/options directly to stdout without capturing
71    for arg in "$@"; do
72        case "$arg" in
73            -*) command try-rs "$@"; return ;;
74        esac
75    done
76
77    # Captures the output of the binary (stdout) which is the "cd" command
78    # The TUI is rendered on stderr, so it doesn't interfere.
79    local output
80    output=$(command try-rs "$@")
81
82    if [ -n "$output" ]; then
83        eval "$output"
84    fi
85}}
86
87{completions}"#
88            )
89        }
90        Shell::Bash => {
91            format!(
92                r#"try-rs() {{
93    # Pass flags/options directly to stdout without capturing
94    for arg in "$@"; do
95        case "$arg" in
96            -*) command try-rs "$@"; return ;;
97        esac
98    done
99
100    # Captures the output of the binary (stdout) which is the "cd" command
101    # The TUI is rendered on stderr, so it doesn't interfere.
102    local output
103    output=$(command try-rs "$@")
104
105    if [ -n "$output" ]; then
106        eval "$output"
107    fi
108}}
109
110{completions}"#
111            )
112        }
113        Shell::PowerShell => {
114            format!(
115                r#"# try-rs integration for PowerShell
116function try-rs {{
117    # Pass flags/options directly to stdout without capturing
118    foreach ($a in $args) {{
119        if ($a -like '-*') {{
120            & try-rs.exe @args
121            return
122        }}
123    }}
124
125    # Captures the output of the binary (stdout) which is the "cd" or editor command
126    # The TUI is rendered on stderr, so it doesn't interfere.
127    $command = (try-rs.exe @args)
128
129    if ($command) {{
130        Invoke-Expression $command
131    }}
132}}
133
134{completions}"#
135            )
136        }
137        Shell::NuShell => {
138            format!(
139                r#"def --wrapped try-rs [...args] {{
140    # Pass flags/options directly to stdout without capturing
141    for arg in $args {{
142        if ($arg | str starts-with '-') {{
143            ^try-rs.exe ...$args
144            return
145        }}
146    }}
147
148    # Capture output. Stderr (TUI) goes directly to terminal.
149    let output = (try-rs.exe ...$args)
150
151    if ($output | is-not-empty) {{
152
153        # Grabs the path out of stdout returned by the binary and removes the single quotes
154        let $path = ($output | split row ' ').1 | str replace --all "'" ''
155        cd $path
156    }}
157}}
158
159{completions}"#
160            )
161        }
162    }
163}
164
165/// Returns the tab completion script for the given shell.
166/// This provides dynamic completion of directory names from the tries_path.
167pub fn get_completions_script(shell: &Shell) -> String {
168    match shell {
169        Shell::Fish => {
170            r#"# try-rs tab completion for directory names
171function __try_rs_get_tries_path
172    # Check TRY_PATH environment variable first
173    if set -q TRY_PATH
174        echo $TRY_PATH
175        return
176    end
177    
178    # Try to read from config file
179    set -l config_paths "$HOME/.config/try-rs/config.toml" "$HOME/.try-rs/config.toml"
180    for config_path in $config_paths
181        if test -f $config_path
182            set -l tries_path (command grep -E '^\s*tries_path\s*=' $config_path 2>/dev/null | command sed 's/.*=\s*"\?\([^"]*\)"\?.*/\1/' | command sed "s|~|$HOME|" | string trim)
183            if test -n "$tries_path"
184                echo $tries_path
185                return
186            end
187        end
188    end
189    
190    # Default path
191    echo "$HOME/work/tries"
192end
193
194function __try_rs_complete_directories
195    set -l tries_path (__try_rs_get_tries_path)
196    
197    if test -d $tries_path
198        # List directories in tries_path, filtering by current token
199        command ls -1 $tries_path 2>/dev/null | while read -l dir
200            if test -d "$tries_path/$dir"
201                echo $dir
202            end
203        end
204    end
205end
206
207complete -f -c try-rs -n '__fish_use_subcommand' -a '(__try_rs_complete_directories)' -d 'Try directory'
208"#.to_string()
209        }
210        Shell::Zsh => {
211            r#"# try-rs tab completion for directory names
212_try_rs_get_tries_path() {
213    # Check TRY_PATH environment variable first
214    if [[ -n "${TRY_PATH}" ]]; then
215        echo "${TRY_PATH}"
216        return
217    fi
218    
219    # Try to read from config file
220    local config_paths=("$HOME/.config/try-rs/config.toml" "$HOME/.try-rs/config.toml")
221    for config_path in "${config_paths[@]}"; do
222        if [[ -f "$config_path" ]]; then
223            local tries_path=$(grep -E '^\s*tries_path\s*=' "$config_path" 2>/dev/null | sed 's/.*=\s*"\?\([^"]*\)"\?.*/\1/' | sed "s|~|$HOME|" | tr -d '[:space:]')
224            if [[ -n "$tries_path" ]]; then
225                echo "$tries_path"
226                return
227            fi
228        fi
229    done
230    
231    # Default path
232    echo "$HOME/work/tries"
233}
234
235_try_rs_complete() {
236    local cur="${COMP_WORDS[COMP_CWORD]}"
237    local tries_path=$(_try_rs_get_tries_path)
238    local -a dirs=()
239    
240    if [[ -d "$tries_path" ]]; then
241        # Get list of directories
242        while IFS= read -r dir; do
243            dirs+=("$dir")
244        done < <(ls -1 "$tries_path" 2>/dev/null | while read -r dir; do
245            if [[ -d "$tries_path/$dir" ]]; then
246                echo "$dir"
247            fi
248        done)
249    fi
250    
251    COMPREPLY=($(compgen -W "${dirs[*]}" -- "$cur"))
252}
253
254complete -o default -F _try_rs_complete try-rs
255"#.to_string()
256        }
257        Shell::Bash => {
258            r#"# try-rs tab completion for directory names
259_try_rs_get_tries_path() {
260    # Check TRY_PATH environment variable first
261    if [[ -n "${TRY_PATH}" ]]; then
262        echo "${TRY_PATH}"
263        return
264    fi
265    
266    # Try to read from config file
267    local config_paths=("$HOME/.config/try-rs/config.toml" "$HOME/.try-rs/config.toml")
268    for config_path in "${config_paths[@]}"; do
269        if [[ -f "$config_path" ]]; then
270            local tries_path=$(grep -E '^[[:space:]]*tries_path[[:space:]]*=' "$config_path" 2>/dev/null | sed 's/.*=[[:space:]]*"\?\([^"]*\)"\?.*/\1/' | sed "s|~|$HOME|" | tr -d '[:space:]')
271            if [[ -n "$tries_path" ]]; then
272                echo "$tries_path"
273                return
274            fi
275        fi
276    done
277    
278    # Default path
279    echo "$HOME/work/tries"
280}
281
282_try_rs_complete() {
283    local cur="${COMP_WORDS[COMP_CWORD]}"
284    local tries_path=$(_try_rs_get_tries_path)
285    local dirs=""
286    
287    if [[ -d "$tries_path" ]]; then
288        # Get list of directories
289        while IFS= read -r dir; do
290            if [[ -d "$tries_path/$dir" ]]; then
291                dirs="$dirs $dir"
292            fi
293        done < <(ls -1 "$tries_path" 2>/dev/null)
294    fi
295    
296    COMPREPLY=($(compgen -W "$dirs" -- "$cur"))
297}
298
299complete -o default -F _try_rs_complete try-rs
300"#.to_string()
301        }
302        Shell::PowerShell => {
303            r#"# try-rs tab completion for directory names
304Register-ArgumentCompleter -CommandName try-rs -ScriptBlock {
305    param($wordToComplete, $commandAst, $cursorPosition)
306    
307    # Get tries path from environment variable or default
308    $triesPath = $env:TRY_PATH
309    if (-not $triesPath) {
310        # Try to read from config file
311        $configPaths = @(
312            "$env:USERPROFILE/.config/try-rs/config.toml",
313            "$env:USERPROFILE/.try-rs/config.toml"
314        )
315        foreach ($configPath in $configPaths) {
316            if (Test-Path $configPath) {
317                $content = Get-Content $configPath -Raw
318                if ($content -match 'tries_path\s*=\s*["'']?([^"'']+)["'']?') {
319                    $triesPath = $matches[1].Replace('~', $env:USERPROFILE).Trim()
320                    break
321                }
322            }
323        }
324    }
325    
326    # Default path
327    if (-not $triesPath) {
328        $triesPath = "$env:USERPROFILE/work/tries"
329    }
330    
331    # Get directories
332    if (Test-Path $triesPath) {
333        Get-ChildItem -Path $triesPath -Directory | 
334            Where-Object { $_.Name -like "$wordToComplete*" } |
335            ForEach-Object { 
336                [System.Management.Automation.CompletionResult]::new(
337                    $_.Name, 
338                    $_.Name, 
339                    'ParameterValue', 
340                    $_.Name
341                )
342            }
343    }
344}
345"#.to_string()
346        }
347        Shell::NuShell => {
348            r#"# try-rs tab completion for directory names
349# Add this to your Nushell config or env file
350
351export def __try_rs_get_tries_path [] {
352    # Check TRY_PATH environment variable first
353    if ($env.TRY_PATH? | is-not-empty) {
354        return $env.TRY_PATH
355    }
356    
357    # Try to read from config file
358    let config_paths = [
359        ($env.HOME | path join ".config" "try-rs" "config.toml"),
360        ($env.HOME | path join ".try-rs" "config.toml")
361    ]
362    
363    for config_path in $config_paths {
364        if ($config_path | path exists) {
365            let content = (open $config_path | str trim)
366            if ($content =~ 'tries_path\\s*=\\s*"?([^"]+)"?') {
367                let path = ($content | parse -r 'tries_path\\s*=\\s*"?([^"]+)"?' | get capture0.0? | default "")
368                if ($path | is-not-empty) {
369                    return ($path | str replace "~" $env.HOME)
370                }
371            }
372        }
373    }
374    
375    # Default path
376    ($env.HOME | path join "work" "tries")
377}
378
379export def __try_rs_complete [context: string] {
380    let tries_path = (__try_rs_get_tries_path)
381    
382    if ($tries_path | path exists) {
383        ls $tries_path | where type == "dir" | get name | path basename
384    } else {
385        []
386    }
387}
388
389# Add completion to the try-rs command
390export extern try-rs [
391    name_or_url?: string@__try_rs_complete
392    destination?: string
393    --setup: string
394    --setup-stdout: string
395    --completions: string
396    --shallow-clone(-s)
397    --worktree(-w): string
398]
399"#.to_string()
400        }
401    }
402}
403
404/// Returns only the completion script (for --completions flag)
405pub fn get_completion_script_only(shell: &Shell) -> String {
406    let completions = get_completions_script(shell);
407    match shell {
408        Shell::NuShell => {
409            // For NuShell, we need to provide a different format when used standalone
410            r#"# try-rs tab completion for directory names
411# Add this to your Nushell config
412
413def __try_rs_get_tries_path [] {
414    if ($env.TRY_PATH? | is-not-empty) {
415        return $env.TRY_PATH
416    }
417    
418    let config_paths = [
419        ($env.HOME | path join ".config" "try-rs" "config.toml"),
420        ($env.HOME | path join ".try-rs" "config.toml")
421    ]
422    
423    for config_path in $config_paths {
424        if ($config_path | path exists) {
425            let content = (open $config_path | str trim)
426            if ($content =~ 'tries_path\\s*=\\s*"?([^"]+)"?') {
427                let path = ($content | parse -r 'tries_path\\s*=\\s*"?([^"]+)"?' | get capture0.0? | default "")
428                if ($path | is-not-empty) {
429                    return ($path | str replace "~" $env.HOME)
430                }
431            }
432        }
433    }
434    
435    ($env.HOME | path join "work" "tries")
436}
437
438def __try_rs_complete [context: string] {
439    let tries_path = (__try_rs_get_tries_path)
440    
441    if ($tries_path | path exists) {
442        ls $tries_path | where type == "dir" | get name | path basename
443    } else {
444        []
445    }
446}
447
448# Register completion
449export extern try-rs [
450    name_or_url?: string@__try_rs_complete
451    destination?: string
452    --setup: string
453    --setup-stdout: string
454    --completions: string
455    --shallow-clone(-s)
456    --worktree(-w): string
457]
458"#.to_string()
459        }
460        _ => completions,
461    }
462}
463
464pub fn get_shell_integration_path(shell: &Shell) -> PathBuf {
465    let config_dir = match shell {
466        Shell::Fish => get_base_config_dir(),
467        _ => get_config_dir(),
468    };
469
470    match shell {
471        Shell::Fish => get_fish_functions_dir()
472            .join("try-rs.fish"),
473        Shell::Zsh => config_dir.join("try-rs.zsh"),
474        Shell::Bash => config_dir.join("try-rs.bash"),
475        Shell::PowerShell => config_dir.join("try-rs.ps1"),
476        Shell::NuShell => config_dir.join("try-rs.nu"),
477    }
478}
479
480fn get_fish_functions_dir() -> PathBuf {
481    if let Ok(output) = std::process::Command::new("fish")
482        .args(["-c", "echo $__fish_config_dir"])
483        .output()
484    {
485        if output.status.success() {
486            let output_str = String::from_utf8_lossy(&output.stdout);
487            let path = PathBuf::from(output_str.trim()).join("functions");
488            if path.exists() || path.parent().map(|p| p.exists()).unwrap_or(false) {
489                return path;
490            }
491        }
492    }
493    get_base_config_dir().join("fish").join("functions")
494}
495
496fn write_fish_picker_function() -> Result<PathBuf> {
497    let file_path = get_fish_functions_dir()
498        .join("try-rs-picker.fish");
499    if let Some(parent) = file_path.parent()
500        && !parent.exists()
501    {
502        fs::create_dir_all(parent)?;
503    }
504    fs::write(&file_path, FISH_PICKER_FUNCTION)?;
505    eprintln!(
506        "Fish picker function file created at: {}",
507        file_path.display()
508    );
509    Ok(file_path)
510}
511
512pub fn is_shell_integration_configured(shell: &Shell) -> bool {
513    get_shell_integration_path(shell).exists()
514}
515
516/// Appends a source command to an RC file if not already present.
517fn append_source_to_rc(rc_path: &std::path::Path, source_cmd: &str) -> Result<()> {
518    if rc_path.exists() {
519        let content = fs::read_to_string(rc_path)?;
520        if !content.contains(source_cmd) {
521            let mut file = fs::OpenOptions::new().append(true).open(rc_path)?;
522            writeln!(file, "\n# try-rs integration")?;
523            writeln!(file, "{}", source_cmd)?;
524            eprintln!("Added configuration to {}", rc_path.display());
525        } else {
526            eprintln!("Configuration already present in {}", rc_path.display());
527        }
528    } else {
529        eprintln!(
530            "You need to add the following line to {}:",
531            rc_path.display()
532        );
533        eprintln!("{}", source_cmd);
534    }
535    Ok(())
536}
537
538/// Writes the shell integration file and returns its path.
539fn write_shell_integration(shell: &Shell) -> Result<std::path::PathBuf> {
540    let file_path = get_shell_integration_path(shell);
541    if let Some(parent) = file_path.parent()
542        && !parent.exists()
543    {
544        fs::create_dir_all(parent)?;
545    }
546    fs::write(&file_path, get_shell_content(shell))?;
547    eprintln!(
548        "{:?} function file created at: {}",
549        shell,
550        file_path.display()
551    );
552    Ok(file_path)
553}
554
555/// Sets up shell integration for the given shell.
556pub fn setup_shell(shell: &Shell) -> Result<()> {
557    let file_path = write_shell_integration(shell)?;
558    let home_dir = dirs::home_dir().expect("Could not find home directory");
559
560    match shell {
561        Shell::Fish => {
562            let _picker_path = write_fish_picker_function()?;
563            let fish_config_path = home_dir.join(".config").join("fish").join("config.fish");
564            eprintln!(
565                "You may need to restart your shell or run 'source {}' to apply changes.",
566                file_path.display()
567            );
568            eprintln!(
569                "Optional: append the following to {} to bind Ctrl+T:",
570                fish_config_path.display()
571            );
572            eprintln!("bind \\ct try-rs-picker");
573            eprintln!("bind -M insert \\ct try-rs-picker");
574        }
575        Shell::Zsh => {
576            let source_cmd = format!("source '{}'", file_path.display());
577            append_source_to_rc(&home_dir.join(".zshrc"), &source_cmd)?;
578        }
579        Shell::Bash => {
580            let source_cmd = format!("source '{}'", file_path.display());
581            append_source_to_rc(&home_dir.join(".bashrc"), &source_cmd)?;
582        }
583        Shell::PowerShell => {
584            let profile_path_ps7 = home_dir
585                .join("Documents")
586                .join("PowerShell")
587                .join("Microsoft.PowerShell_profile.ps1");
588            let profile_path_ps5 = home_dir
589                .join("Documents")
590                .join("WindowsPowerShell")
591                .join("Microsoft.PowerShell_profile.ps1");
592            let profile_path = if profile_path_ps7.exists() {
593                profile_path_ps7
594            } else if profile_path_ps5.exists() {
595                profile_path_ps5
596            } else {
597                profile_path_ps7
598            };
599
600            if let Some(parent) = profile_path.parent()
601                && !parent.exists()
602            {
603                fs::create_dir_all(parent)?;
604            }
605
606            let source_cmd = format!(". '{}'", file_path.display());
607            if profile_path.exists() {
608                append_source_to_rc(&profile_path, &source_cmd)?;
609            } else {
610                let mut file = fs::File::create(&profile_path)?;
611                writeln!(file, "# try-rs integration")?;
612                writeln!(file, "{}", source_cmd)?;
613                eprintln!(
614                    "PowerShell profile created and configured at: {}",
615                    profile_path.display()
616                );
617            }
618
619            eprintln!(
620                "You may need to restart your shell or run '. {}' to apply changes.",
621                profile_path.display()
622            );
623            eprintln!(
624                "If you get an error about running scripts, you may need to run: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned"
625            );
626        }
627        Shell::NuShell => {
628            let nu_config_path = dirs::config_dir()
629                .expect("Could not find config directory")
630                .join("nushell")
631                .join("config.nu");
632            let source_cmd = format!("source '{}'", file_path.display());
633            if nu_config_path.exists() {
634                append_source_to_rc(&nu_config_path, &source_cmd)?;
635            } else {
636                eprintln!("Could not find config.nu at {}", nu_config_path.display());
637                eprintln!("Please add the following line manually:");
638                eprintln!("{}", source_cmd);
639            }
640        }
641    }
642
643    Ok(())
644}
645
646/// Generates a standalone completion script for the given shell.
647pub fn generate_completions(shell: &Shell) -> Result<()> {
648    let script = get_completion_script_only(shell);
649    print!("{}", script);
650    Ok(())
651}
652
653pub fn get_installed_shells() -> Vec<Shell> {
654    let mut shells = Vec::new();
655    for shell in [Shell::Fish, Shell::Zsh, Shell::Bash, Shell::PowerShell, Shell::NuShell] {
656        if is_shell_installed(&shell) {
657            shells.push(shell);
658        }
659    }
660    shells
661}
662
663fn is_shell_installed(shell: &Shell) -> bool {
664    let shell_name = match shell {
665        Shell::Fish => "fish",
666        Shell::Zsh => "zsh",
667        Shell::Bash => "bash",
668        Shell::PowerShell => "pwsh",
669        Shell::NuShell => "nu",
670    };
671
672    let output = std::process::Command::new("whereis")
673        .arg(shell_name)
674        .output();
675
676    match output {
677        Ok(out) => {
678            let result = String::from_utf8_lossy(&out.stdout);
679            let trimmed = result.trim();
680            !trimmed.is_empty() && !trimmed.ends_with(':') && trimmed.starts_with(&format!("{}: ", shell_name))
681        }
682        Err(_) => false,
683    }
684}
685
686pub fn clear_shell_setup() -> Result<()> {
687    let installed_shells = get_installed_shells();
688    
689    if installed_shells.is_empty() {
690        eprintln!("No supported shells found on this system.");
691        return Ok(());
692    }
693
694    eprintln!("Detected shells: {:?}\n", installed_shells);
695    eprintln!("Files to be removed:");
696
697    for shell in &installed_shells {
698        let paths = get_shell_config_paths(shell);
699        
700        for path in &paths {
701            eprintln!("  - {}", path.display());
702        }
703
704        match shell {
705            Shell::Fish => {
706                let fish_functions = get_fish_functions_dir();
707                eprintln!("  - {}", fish_functions.join("try-rs-picker.fish").display());
708            }
709            _ => {}
710        }
711    }
712
713    eprintln!("\nRemoving files...");
714
715    for shell in &installed_shells {
716        clear_shell_config(shell)?;
717    }
718
719    eprintln!("\nDone! Shell integration removed.");
720    Ok(())
721}
722
723fn clear_shell_config(shell: &Shell) -> Result<()> {
724    let paths = get_shell_config_paths(shell);
725    
726    for path in &paths {
727        if path.exists() {
728            fs::remove_file(path)?;
729            eprintln!("Removed: {}", path.display());
730        }
731    }
732
733    match shell {
734        Shell::Fish => {
735            let fish_functions = get_fish_functions_dir();
736            let picker_path = fish_functions.join("try-rs-picker.fish");
737            if picker_path.exists() {
738                fs::remove_file(&picker_path)?;
739                eprintln!("Removed: {}", picker_path.display());
740            }
741        }
742        _ => {}
743    }
744
745    Ok(())
746}
747
748fn get_shell_config_paths(shell: &Shell) -> Vec<PathBuf> {
749    let mut paths = Vec::new();
750    let config_dir = get_base_config_dir();
751    let home_dir = dirs::home_dir().expect("Could not find home directory");
752
753    match shell {
754        Shell::Fish => {
755            let fish_functions = get_fish_functions_dir();
756            paths.push(fish_functions.join("try-rs.fish"));
757        }
758        Shell::Zsh => {
759            paths.push(config_dir.join("try-rs.zsh"));
760            if home_dir.join(".zshrc").exists() {
761                if let Ok(content) = fs::read_to_string(home_dir.join(".zshrc")) {
762                    if content.contains("try-rs") {
763                        paths.push(home_dir.join(".zshrc"));
764                    }
765                }
766            }
767        }
768        Shell::Bash => {
769            paths.push(config_dir.join("try-rs.bash"));
770            if home_dir.join(".bashrc").exists() {
771                if let Ok(content) = fs::read_to_string(home_dir.join(".bashrc")) {
772                    if content.contains("try-rs") {
773                        paths.push(home_dir.join(".bashrc"));
774                    }
775                }
776            }
777        }
778        Shell::PowerShell => {
779            paths.push(config_dir.join("try-rs.ps1"));
780        }
781        Shell::NuShell => {
782            paths.push(config_dir.join("try-rs.nu"));
783        }
784    }
785
786    paths
787}