diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index abe9efca1b..d3b3308f8b 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -2,6 +2,11 @@ ## Release v0.290.0 +### CLI +* Add `completion install`, `uninstall`, and `status` subcommands ([#4581](https://github.com/databricks/cli/pull/4581)) + +### Internal + ### Dependency updates * Upgrade TF provider to 1.109.0 ([#4561](https://github.com/databricks/cli/pull/4561)) * Upgrade Go SDK to v0.110.0 ([#4552](https://github.com/databricks/cli/pull/4552)) diff --git a/acceptance/cmd/completion/out.test.toml b/acceptance/cmd/completion/out.test.toml new file mode 100644 index 0000000000..90061dedb1 --- /dev/null +++ b/acceptance/cmd/completion/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/cmd/completion/output.txt b/acceptance/cmd/completion/output.txt new file mode 100644 index 0000000000..17ed0f61eb --- /dev/null +++ b/acceptance/cmd/completion/output.txt @@ -0,0 +1,37 @@ + +>>> [CLI] completion install --shell zsh --auto-approve +Databricks CLI completions installed for zsh. +Restart your shell or run 'source home/.zshrc' to activate. + +Warning: zsh completions require the completion system to be initialized. +Add the following to your home/.zshrc: + autoload -U compinit && compinit + +>>> [CLI] completion status --shell zsh +Shell: zsh +File: home/.zshrc +Status: installed + +Warning: zsh completions require the completion system to be initialized. +Add the following to your home/.zshrc: + autoload -U compinit && compinit + +>>> [CLI] completion install --shell zsh --auto-approve +Databricks CLI completions are already installed for zsh in home/.zshrc. + +Warning: zsh completions require the completion system to be initialized. +Add the following to your home/.zshrc: + autoload -U compinit && compinit + +>>> [CLI] completion uninstall --shell zsh --auto-approve +Databricks CLI completions removed for zsh from home/.zshrc. + +>>> [CLI] completion status --shell zsh +Shell: zsh +File: home/.zshrc +Status: not installed +# bash completion V2 for databricks -*- shell-script -*- +#compdef databricks +# fish completion for databricks -*- shell-script -*- +# powershell completion for databricks -*- shell-script -*- +# bash completion V2 for databricks -*- shell-script -*- diff --git a/acceptance/cmd/completion/script b/acceptance/cmd/completion/script new file mode 100644 index 0000000000..e5b0f324d6 --- /dev/null +++ b/acceptance/cmd/completion/script @@ -0,0 +1,31 @@ +sethome "./home" + +# Track the home path for stable output across platforms. +add_repl.py "$HOME" HOME + +# Prevent Homebrew detection from affecting status output. +export HOMEBREW_PREFIX=/nonexistent + +# Test install (use zsh to avoid OS-dependent bash RC file path) +trace $CLI completion install --shell zsh --auto-approve + +# Test status shows installed +trace $CLI completion status --shell zsh + +# Test idempotent install (--auto-approve is harmless when already installed) +trace $CLI completion install --shell zsh --auto-approve + +# Test uninstall +trace $CLI completion uninstall --shell zsh --auto-approve + +# Test status after uninstall +trace $CLI completion status --shell zsh + +# Test shell subcommands produce output +$CLI completion bash 2>&1 | head -1 +$CLI completion zsh 2>&1 | head -1 +$CLI completion fish 2>&1 | head -1 +$CLI completion powershell 2>&1 | head -1 + +# Test bash --no-descriptions +$CLI completion bash --no-descriptions 2>&1 | head -1 diff --git a/acceptance/cmd/completion/test.toml b/acceptance/cmd/completion/test.toml new file mode 100644 index 0000000000..e8c2fdf6df --- /dev/null +++ b/acceptance/cmd/completion/test.toml @@ -0,0 +1,10 @@ +Ignore = [ + "home", +] + +Local = true +Cloud = false + +# Completion tests don't involve bundles, skip the engine matrix. +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/cmd/cmd.go b/cmd/cmd.go index 6d0fd090c3..014471f763 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/cmd/auth" "github.com/databricks/cli/cmd/bundle" "github.com/databricks/cli/cmd/cache" + "github.com/databricks/cli/cmd/completion" "github.com/databricks/cli/cmd/configure" "github.com/databricks/cli/cmd/experimental" "github.com/databricks/cli/cmd/fs" @@ -94,6 +95,7 @@ func New(ctx context.Context) *cobra.Command { // Add other subcommands. cli.AddCommand(api.New()) cli.AddCommand(auth.New()) + cli.AddCommand(completion.New()) cli.AddCommand(bundle.New()) cli.AddCommand(cache.New()) cli.AddCommand(experimental.New()) diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go new file mode 100644 index 0000000000..e69c3eaa4d --- /dev/null +++ b/cmd/completion/completion.go @@ -0,0 +1,204 @@ +package completion + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" + "github.com/spf13/cobra" +) + +// New returns the "completion" command with shell subcommands and +// install/uninstall/status subcommands. Providing a command named +// "completion" prevents Cobra's InitDefaultCompletionCmd from generating +// its default, so we replicate the shell subcommands here with runtime +// writer resolution to avoid the frozen-writer bug. +func New() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion", + Short: "Generate the autocompletion script for the specified shell", + Long: `Generate the autocompletion script for databricks for the specified shell. +See each sub-command's help for details on how to use the generated script. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: root.ReportUnknownSubcommand, + } + + cmd.AddCommand( + newBashCmd(), + newZshCmd(), + newFishCmd(), + newPowershellCmd(), + newInstallCmd(), + newUninstallCmd(), + newStatusCmd(), + ) + + return cmd +} + +func newBashCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "bash", + Short: "Generate the autocompletion script for bash", + Long: `Generate the autocompletion script for the bash shell. + +This script depends on the 'bash-completion' package. +If it is not installed already, you can install it via your OS's package manager. + +To load completions in your current shell session: + + source <(databricks completion bash) + +To load completions for every new session, execute once: + +#### Linux: + + databricks completion bash > /etc/bash_completion.d/databricks + +#### macOS: + + databricks completion bash > $(brew --prefix)/etc/bash_completion.d/databricks + +You will need to start a new shell for this setup to take effect. +`, + Args: cobra.NoArgs, + DisableFlagsInUseLine: true, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Root().GenBashCompletionV2(cmd.OutOrStdout(), !noDesc) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +func newZshCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "zsh", + Short: "Generate the autocompletion script for zsh", + Long: `Generate the autocompletion script for the zsh shell. + +If shell completion is not already enabled in your environment you will need +to enable it. You can execute the following once: + + echo "autoload -U compinit; compinit" >> ~/.zshrc + +To load completions in your current shell session: + + source <(databricks completion zsh) + +To load completions for every new session, execute once: + +#### Linux: + + databricks completion zsh > "${fpath[1]}/_databricks" + +#### macOS: + + databricks completion zsh > $(brew --prefix)/share/zsh/site-functions/_databricks + +You will need to start a new shell for this setup to take effect. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + if noDesc { + return cmd.Root().GenZshCompletionNoDesc(cmd.OutOrStdout()) + } + return cmd.Root().GenZshCompletion(cmd.OutOrStdout()) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +func newFishCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "fish", + Short: "Generate the autocompletion script for fish", + Long: `Generate the autocompletion script for the fish shell. + +To load completions in your current shell session: + + databricks completion fish | source + +To load completions for every new session, execute once: + + databricks completion fish > ~/.config/fish/completions/databricks.fish + +You will need to start a new shell for this setup to take effect. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), !noDesc) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +func newPowershellCmd() *cobra.Command { + var noDesc bool + cmd := &cobra.Command{ + Use: "powershell", + Short: "Generate the autocompletion script for powershell", + Long: `Generate the autocompletion script for powershell. + +To load completions in your current shell session: + + databricks completion powershell | Out-String | Invoke-Expression + +To load completions for every new session, add the output of the above command +to your powershell profile. +`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + if noDesc { + return cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout()) + } + return cmd.Root().GenPowerShellCompletionWithDesc(cmd.OutOrStdout()) + }, + } + cmd.Flags().BoolVar(&noDesc, "no-descriptions", false, "disable completion descriptions") + return cmd +} + +// warnIfCompinitMissing prints a warning when zsh completions are present but +// the user's .zshrc does not call compinit. Without compinit, neither our eval +// shim nor Homebrew's _databricks file will be loaded. +func warnIfCompinitMissing(ctx context.Context, shell libcompletion.Shell, home string) { + if shell != libcompletion.Zsh { + return + } + rcPath := libcompletion.TargetFilePath(shell, home) + content, err := os.ReadFile(rcPath) + if err != nil { + return + } + if strings.Contains(string(content), "compinit") { + return + } + cmdio.LogString(ctx, "") + cmdio.LogString(ctx, "Warning: zsh completions require the completion system to be initialized.") + cmdio.LogString(ctx, "Add the following to your "+filepath.ToSlash(rcPath)+":") + cmdio.LogString(ctx, " autoload -U compinit && compinit") +} + +// addShellFlag registers the --shell flag and its completion function on cmd. +func addShellFlag(cmd *cobra.Command, target *string) { + cmd.Flags().StringVar(target, "shell", "", "Shell type: bash, zsh, fish, powershell, powershell5") + cmd.RegisterFlagCompletionFunc("shell", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return []cobra.Completion{"bash", "zsh", "fish", "powershell", "powershell5"}, cobra.ShellCompDirectiveNoFileComp + }) +} diff --git a/cmd/completion/install.go b/cmd/completion/install.go new file mode 100644 index 0000000000..dcf5fe0b04 --- /dev/null +++ b/cmd/completion/install.go @@ -0,0 +1,105 @@ +package completion + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" + "github.com/spf13/cobra" +) + +func newInstallCmd() *cobra.Command { + var shellFlag string + var autoApprove bool + cmd := &cobra.Command{ + Use: "install", + Short: "Install shell completions", + Long: "Install Databricks CLI tab completions into your shell configuration file.", + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + shell, err := libcompletion.DetectShell(shellFlag) + if err != nil { + return err + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + filePath := libcompletion.TargetFilePath(shell, home) + displayPath := filepath.ToSlash(filePath) + + // Check if already installed — no confirmation needed. + result, err := libcompletion.Status(shell, home) + if err != nil { + return err + } + if result.Installed { + switch result.Method { + case "marker": + // Our shim is already in the RC file — nothing to do. + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) + warnIfCompinitMissing(ctx, shell, home) + return nil + case "homebrew": + // Homebrew provides completions via a separate file. The user + // may still want a CLI-managed shim in .zshrc (e.g. for a + // newer binary). Inform them and proceed with install. + cmdio.LogString(ctx, fmt.Sprintf("Note: Databricks CLI completions for %s are also provided by Homebrew.", shell)) + default: + // External file (e.g. fish installed by package manager) — we + // can't overwrite it, so report and exit. + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are already present in %s.", shell, displayPath)) + warnIfCompinitMissing(ctx, shell, home) + return nil + } + } + + // Confirm before writing. + if !autoApprove { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("use --auto-approve to skip the confirmation prompt, or run 'databricks completion status' to preview the detected shell and target file") + } + cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) + cmdio.LogString(ctx, "File: "+displayPath) + confirmed, err := cmdio.AskYesOrNo(ctx, "Proceed?") + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + _, alreadyInstalled, err := libcompletion.Install(shell, home) + if err != nil { + return err + } + if alreadyInstalled { + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions are already installed for %s in %s.", shell, displayPath)) + return nil + } + + msg := fmt.Sprintf("Databricks CLI completions installed for %s.\n", shell) + switch shell { + case libcompletion.PowerShell, libcompletion.PowerShell5: + msg += "Restart your shell to activate." + default: + msg += fmt.Sprintf("Restart your shell or run 'source %s' to activate.", displayPath) + } + cmdio.LogString(ctx, msg) + warnIfCompinitMissing(ctx, shell, home) + return nil + }, + } + cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip confirmation prompt") + addShellFlag(cmd, &shellFlag) + return cmd +} diff --git a/cmd/completion/status.go b/cmd/completion/status.go new file mode 100644 index 0000000000..c6d1c7e8da --- /dev/null +++ b/cmd/completion/status.go @@ -0,0 +1,60 @@ +package completion + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" + "github.com/spf13/cobra" +) + +func newStatusCmd() *cobra.Command { + var shellFlag string + cmd := &cobra.Command{ + Use: "status", + Short: "Show shell completion status", + Long: "Show whether Databricks CLI tab completions are installed for your shell.", + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + shell, err := libcompletion.DetectShell(shellFlag) + if err != nil { + return err + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + result, err := libcompletion.Status(shell, home) + if err != nil { + return err + } + + statusStr := "not installed" + if result.Installed { + statusStr = "installed" + if result.Method != "" && result.Method != "marker" { + statusStr = fmt.Sprintf("installed (via %s)", result.Method) + } + } + + cmdio.LogString(ctx, fmt.Sprintf("%-8s %s", "Shell:", shell.DisplayName())) + cmdio.LogString(ctx, fmt.Sprintf("%-8s %s", "File:", filepath.ToSlash(result.FilePath))) + cmdio.LogString(ctx, fmt.Sprintf("%-8s %s", "Status:", statusStr)) + + if result.Installed { + warnIfCompinitMissing(ctx, shell, home) + } + + return nil + }, + } + addShellFlag(cmd, &shellFlag) + return cmd +} diff --git a/cmd/completion/uninstall.go b/cmd/completion/uninstall.go new file mode 100644 index 0000000000..7e3dfcf0f7 --- /dev/null +++ b/cmd/completion/uninstall.go @@ -0,0 +1,100 @@ +package completion + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/databricks/cli/libs/cmdio" + libcompletion "github.com/databricks/cli/libs/completion" + "github.com/spf13/cobra" +) + +func newUninstallCmd() *cobra.Command { + var shellFlag string + var autoApprove bool + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Uninstall shell completions", + Long: "Remove Databricks CLI tab completions from your shell configuration file.", + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + shell, err := libcompletion.DetectShell(shellFlag) + if err != nil { + return err + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + filePath := libcompletion.TargetFilePath(shell, home) + displayPath := filepath.ToSlash(filePath) + + // Check current status to avoid a useless prompt. + result, err := libcompletion.Status(shell, home) + if err != nil { + return err + } + + if !result.Installed { + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions were not installed for %s.", shell)) + return nil + } + + // Installed by another method (homebrew, package manager) — we can't uninstall it. + if result.Method != "" && result.Method != "marker" { + switch result.Method { + case "homebrew": + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions for %s are provided by Homebrew. Nothing to uninstall.", shell)) + default: + cmdio.LogString(ctx, fmt.Sprintf( + "Databricks CLI completions for %s appear to be installed externally in %s. Nothing to uninstall.", + shell, + filepath.ToSlash(result.FilePath), + )) + } + warnIfCompinitMissing(ctx, shell, home) + return nil + } + + // Confirm before modifying. + if !autoApprove { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("use --auto-approve to skip the confirmation prompt, or run 'databricks completion status' to preview the detected shell and target file") + } + cmdio.LogString(ctx, "Shell: "+shell.DisplayName()) + cmdio.LogString(ctx, "File: "+displayPath) + confirmed, err := cmdio.AskYesOrNo(ctx, "Proceed?") + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + _, wasInstalled, err := libcompletion.Uninstall(shell, home) + if err != nil { + return err + } + + if !wasInstalled { + // Race: status said installed but uninstall found nothing. + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions were not installed for %s.", shell)) + return nil + } + + cmdio.LogString(ctx, fmt.Sprintf("Databricks CLI completions removed for %s from %s.", shell, displayPath)) + return nil + }, + } + cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Skip confirmation prompt") + addShellFlag(cmd, &shellFlag) + return cmd +} diff --git a/libs/completion/install.go b/libs/completion/install.go new file mode 100644 index 0000000000..462d6522d8 --- /dev/null +++ b/libs/completion/install.go @@ -0,0 +1,84 @@ +package completion + +import ( + "os" + "path/filepath" +) + +// Install configures shell completion for the given shell. homeDir is used +// as the base for RC file resolution (typically os.UserHomeDir()). +// Returns the file path modified and whether it was already installed. +func Install(shell Shell, homeDir string) (filePath string, alreadyInstalled bool, err error) { + status, err := Status(shell, homeDir) + if err != nil { + return TargetFilePath(shell, homeDir), false, err + } + filePath = status.FilePath + + // For fish, any existing file counts as "already installed" — we don't + // overwrite files that may have been installed by a package manager. + // For RC-based shells, only our marker block counts. + if shell == Fish && status.Installed { + return filePath, true, nil + } + if status.Method == "marker" { + return filePath, true, nil + } + + if shell == Fish { + return installFish(filePath, shell) + } + return installRC(filePath, shell) +} + +// installFish handles the file-drop model for fish completions. +// The caller must check Status before calling this — existence checks are not +// repeated here. +func installFish(filePath string, shell Shell) (string, bool, error) { + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return filePath, false, err + } + + return filePath, false, os.WriteFile(filePath, []byte(ShimContent(shell)), 0o644) +} + +// installRC handles the RC file model for bash, zsh, and powershell. +// The caller must check Status before calling this — marker checks are not +// repeated here. +func installRC(filePath string, shell Shell) (string, bool, error) { + var content []byte + var perm os.FileMode = 0o644 + + if info, err := os.Stat(filePath); err == nil { + perm = info.Mode() + content, err = os.ReadFile(filePath) + if err != nil { + return filePath, false, err + } + } + + // Create parent directory if needed (e.g. for PowerShell profiles). + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return filePath, false, err + } + + // Ensure a leading newline before the block if the file doesn't end with one. + shim := ShimContent(shell) + if len(content) > 0 && content[len(content)-1] != '\n' { + shim = "\n" + shim + } + + f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, perm) + if err != nil { + return filePath, false, err + } + defer f.Close() + + if _, err := f.WriteString(shim); err != nil { + return filePath, false, err + } + + return filePath, false, nil +} diff --git a/libs/completion/install_test.go b/libs/completion/install_test.go new file mode 100644 index 0000000000..f3c044d149 --- /dev/null +++ b/libs/completion/install_test.go @@ -0,0 +1,184 @@ +package completion + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallFreshZsh(t *testing.T) { + home := t.TempDir() + + filePath, alreadyInstalled, err := Install(Zsh, home) + require.NoError(t, err) + assert.False(t, alreadyInstalled) + assert.Equal(t, filepath.Join(home, ".zshrc"), filePath) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), BeginMarker) + assert.Contains(t, string(content), EndMarker) + assert.Contains(t, string(content), `eval "$(databricks completion zsh)"`) +} + +func TestInstallIdempotent(t *testing.T) { + home := t.TempDir() + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + filePath, alreadyInstalled, err := Install(Zsh, home) + require.NoError(t, err) + assert.True(t, alreadyInstalled) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(content), BeginMarker)) +} + +func TestInstallAppendsToExistingFile(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte("# existing config\n"), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(content), "# existing config\n")) + assert.Contains(t, string(content), BeginMarker) +} + +func TestInstallAddsNewlineIfMissing(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte("# no trailing newline"), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.Contains(t, string(content), "# no trailing newline\n"+BeginMarker) +} + +func TestInstallPreservesPermissions(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(""), 0o600)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + info, err := os.Stat(rcPath) + require.NoError(t, err) + + if runtime.GOOS != "windows" { + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } else { + // Windows has different permission semantics; verify file remains writable. + err = os.WriteFile(rcPath, []byte("# writable"), 0o600) + assert.NoError(t, err) + } +} + +func TestInstallFish(t *testing.T) { + home := t.TempDir() + + filePath, alreadyInstalled, err := Install(Fish, home) + require.NoError(t, err) + assert.False(t, alreadyInstalled) + assert.Equal(t, filepath.Join(home, ".config", "fish", "completions", "databricks.fish"), filePath) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "databricks completion fish | source") +} + +func TestInstallFishForeignFilePreserved(t *testing.T) { + home := t.TempDir() + filePath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o755)) + + original := "# fish completion from package manager\n" + require.NoError(t, os.WriteFile(filePath, []byte(original), 0o644)) + + gotPath, alreadyInstalled, err := Install(Fish, home) + require.NoError(t, err) + assert.True(t, alreadyInstalled) + assert.Equal(t, filePath, gotPath) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, original, string(content)) +} + +func TestInstallFishIdempotent(t *testing.T) { + home := t.TempDir() + + _, _, err := Install(Fish, home) + require.NoError(t, err) + + _, alreadyInstalled, err := Install(Fish, home) + require.NoError(t, err) + assert.True(t, alreadyInstalled) +} + +func TestInstallFishCreatesDirectory(t *testing.T) { + home := t.TempDir() + fishDir := filepath.Join(home, ".config", "fish", "completions") + + _, err := os.Stat(fishDir) + assert.True(t, os.IsNotExist(err)) + + _, _, err = Install(Fish, home) + require.NoError(t, err) + + _, err = os.Stat(fishDir) + assert.NoError(t, err) +} + +func TestInstallPowerShellCreatesDirectory(t *testing.T) { + home := t.TempDir() + + filePath, _, err := Install(PowerShell, home) + require.NoError(t, err) + + _, err = os.Stat(filepath.Dir(filePath)) + assert.NoError(t, err) + + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), "databricks completion powershell | Out-String | Invoke-Expression") +} + +func TestInstallBashShimContent(t *testing.T) { + home := t.TempDir() + + _, _, err := Install(Bash, home) + require.NoError(t, err) + + filePath := TargetFilePath(Bash, home) + content, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Contains(t, string(content), `eval "$(databricks completion bash)"`) +} + +func TestInstallEmptyFile(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(""), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.Contains(t, string(content), BeginMarker) +} diff --git a/libs/completion/shell.go b/libs/completion/shell.go new file mode 100644 index 0000000000..b7336e45f2 --- /dev/null +++ b/libs/completion/shell.go @@ -0,0 +1,206 @@ +package completion + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Shell represents a supported shell type. +type Shell string + +const ( + Bash Shell = "bash" + Zsh Shell = "zsh" + Fish Shell = "fish" + PowerShell Shell = "powershell" + PowerShell5 Shell = "powershell5" +) + +const ( + // BeginMarker is the start of the completion block in RC files. + BeginMarker = "# BEGIN databricks-cli completion" + // EndMarker is the end of the completion block in RC files. + EndMarker = "# END databricks-cli completion" +) + +// DisplayName returns a human-readable name for the shell. +func (s Shell) DisplayName() string { + switch s { + case PowerShell: + return "powershell (pwsh 7+)" + case PowerShell5: + return "powershell5 (Windows PowerShell 5.1)" + default: + return string(s) + } +} + +// DetectShell returns the shell to use. If flagValue is non-empty, it validates +// and returns it. Otherwise it auto-detects from the environment. +func DetectShell(flagValue string) (Shell, error) { + if flagValue != "" { + return validateShellFlag(flagValue) + } + + shellEnv := os.Getenv("SHELL") + if shellEnv != "" { + return shellFromPath(shellEnv) + } + + if runtime.GOOS == "windows" { + return detectWindowsShell() + } + + return "", errors.New("could not detect shell: $SHELL is not set. Use --shell to specify your shell") +} + +// validateShellFlag validates a user-provided --shell flag value. +func validateShellFlag(value string) (Shell, error) { + shell := Shell(strings.ToLower(value)) + + switch shell { + case Bash, Zsh, Fish, PowerShell, PowerShell5: + default: + return "", fmt.Errorf("unsupported shell %q: supported shells are bash, zsh, fish, powershell, powershell5", value) + } + + if shell == PowerShell5 && runtime.GOOS != "windows" { + return "", errors.New("--shell powershell5 is only supported on Windows") + } + + if shell == PowerShell && runtime.GOOS == "windows" { + if _, err := exec.LookPath("pwsh"); err != nil { + return "", errors.New("PowerShell 7+ (pwsh) was not found on PATH. Use --shell powershell5 for Windows PowerShell 5.1, or install pwsh from https://aka.ms/powershell") + } + } + + return shell, nil +} + +// shellFromPath extracts the shell name from a path like /bin/bash or /usr/bin/zsh. +func shellFromPath(path string) (Shell, error) { + name := strings.ToLower(filepath.Base(path)) + + switch { + case strings.Contains(name, "bash"): + return Bash, nil + case strings.Contains(name, "zsh"): + return Zsh, nil + case strings.Contains(name, "fish"): + return Fish, nil + case name == "pwsh", name == "pwsh.exe": + return PowerShell, nil + case name == "powershell", name == "powershell.exe": + if runtime.GOOS == "windows" { + return PowerShell5, nil + } + } + + return "", fmt.Errorf("unsupported shell %q: supported shells are bash, zsh, fish, powershell, powershell5", name) +} + +// detectWindowsShell attempts to find PowerShell on Windows. +func detectWindowsShell() (Shell, error) { + if _, err := exec.LookPath("pwsh"); err == nil { + return PowerShell, nil + } + if _, err := exec.LookPath("powershell.exe"); err == nil { + return PowerShell5, nil + } + return "", errors.New("could not detect shell: no supported shell found on PATH. Use --shell to specify your shell") +} + +// TargetFilePath returns the file that will be modified for the given shell. +func TargetFilePath(shell Shell, homeDir string) string { + switch shell { + case Bash: + return bashProfilePath(homeDir) + case Zsh: + return filepath.Join(homeDir, ".zshrc") + case Fish: + return filepath.Join(homeDir, ".config", "fish", "completions", "databricks.fish") + case PowerShell: + return powershellProfilePath(homeDir) + case PowerShell5: + return filepath.Join(homeDir, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1") + default: + return "" + } +} + +// bashProfilePath returns the appropriate bash profile path for the current OS. +// On macOS, Terminal.app and iTerm2 launch login shells that read ~/.bash_profile. +// On Linux, interactive shells read ~/.bashrc. +// Falls back to the other if the primary choice doesn't exist. +func bashProfilePath(homeDir string) string { + primary := ".bashrc" + fallback := ".bash_profile" + if runtime.GOOS == "darwin" { + primary = ".bash_profile" + fallback = ".bashrc" + } + + primaryPath := filepath.Join(homeDir, primary) + if _, err := os.Stat(primaryPath); err == nil { + return primaryPath + } + + fallbackPath := filepath.Join(homeDir, fallback) + if _, err := os.Stat(fallbackPath); err == nil { + return fallbackPath + } + + // Neither exists; return the primary (it will be created). + return primaryPath +} + +// powershellProfilePath returns the pwsh 7+ profile path. +// See: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_profiles +func powershellProfilePath(homeDir string) string { + if runtime.GOOS == "windows" { + return filepath.Join(homeDir, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1") + } + return filepath.Join(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1") +} + +// ShimContent returns the completion shim block for the given shell, including markers. +func ShimContent(shell Shell) string { + var evalLine string + switch shell { + case Bash: + evalLine = `eval "$(databricks completion bash)"` + case Zsh: + evalLine = `eval "$(databricks completion zsh)"` + case Fish: + evalLine = "databricks completion fish | source" + case PowerShell, PowerShell5: + evalLine = "databricks completion powershell | Out-String | Invoke-Expression" + } + + return BeginMarker + "\n" + evalLine + "\n" + EndMarker + "\n" +} + +// homebrewCompletionPath returns the path to Homebrew-installed zsh completions +// for databricks, or empty string if not found. +func homebrewCompletionPath() string { + prefix := os.Getenv("HOMEBREW_PREFIX") + if prefix == "" { + // Check common defaults. + // See: https://docs.brew.sh/Installation + for _, p := range []string{"/opt/homebrew", "/usr/local"} { + if _, err := os.Stat(filepath.Join(p, "bin/brew")); err == nil { + prefix = p + break + } + } + } + if prefix == "" { + return "" + } + return filepath.Join(prefix, "share/zsh/site-functions/_databricks") +} diff --git a/libs/completion/shell_test.go b/libs/completion/shell_test.go new file mode 100644 index 0000000000..0319d24db2 --- /dev/null +++ b/libs/completion/shell_test.go @@ -0,0 +1,192 @@ +package completion + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectShellFromEnv(t *testing.T) { + tests := []struct { + name string + envShell string + expected Shell + }{ + {"bash from /bin/bash", "/bin/bash", Bash}, + {"bash from /usr/bin/bash", "/usr/bin/bash", Bash}, + {"zsh from /bin/zsh", "/bin/zsh", Zsh}, + {"zsh from /usr/bin/zsh", "/usr/bin/zsh", Zsh}, + {"fish from /usr/bin/fish", "/usr/bin/fish", Fish}, + {"pwsh from path", "/usr/local/bin/pwsh", PowerShell}, + {"pwsh.exe from path", "/usr/local/bin/pwsh.exe", PowerShell}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("SHELL", tt.envShell) + got, err := DetectShell("") + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestDetectShellUnsupported(t *testing.T) { + t.Setenv("SHELL", "/bin/csh") + _, err := DetectShell("") + assert.ErrorContains(t, err, "unsupported shell") + assert.ErrorContains(t, err, "supported shells are") +} + +func TestDetectShellPowershellExeNonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("non-windows test") + } + // On non-Windows, powershell.exe in $SHELL is unrecognized and should error. + t.Setenv("SHELL", "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + _, err := DetectShell("") + assert.ErrorContains(t, err, "unsupported shell") +} + +func TestDetectShellPowershellExeWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("windows-only test") + } + t.Setenv("SHELL", `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`) + got, err := DetectShell("") + require.NoError(t, err) + assert.Equal(t, PowerShell5, got) +} + +func TestDetectShellEmptyOnUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix-only test") + } + t.Setenv("SHELL", "") + _, err := DetectShell("") + assert.ErrorContains(t, err, "$SHELL is not set") +} + +func TestDetectShellFlagOverride(t *testing.T) { + t.Setenv("SHELL", "/bin/zsh") + + got, err := DetectShell("bash") + require.NoError(t, err) + assert.Equal(t, Bash, got) +} + +func TestDetectShellFlagInvalid(t *testing.T) { + _, err := DetectShell("tcsh") + assert.ErrorContains(t, err, "unsupported shell") +} + +func TestDetectShellFlagPowerShell5NonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("non-windows test") + } + _, err := DetectShell("powershell5") + assert.ErrorContains(t, err, "only supported on Windows") +} + +func TestTargetFilePathBashDarwin(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("darwin-only test") + } + + home := t.TempDir() + // Neither file exists — should return primary (bash_profile on darwin). + got := TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bash_profile"), got) + + // Create .bashrc — should fall back to it since .bash_profile doesn't exist. + require.NoError(t, os.WriteFile(filepath.Join(home, ".bashrc"), nil, 0o644)) + got = TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bashrc"), got) + + // Create .bash_profile — should prefer it. + require.NoError(t, os.WriteFile(filepath.Join(home, ".bash_profile"), nil, 0o644)) + got = TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bash_profile"), got) +} + +func TestTargetFilePathBashLinux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("linux-only test") + } + + home := t.TempDir() + got := TargetFilePath(Bash, home) + assert.Equal(t, filepath.Join(home, ".bashrc"), got) +} + +func TestTargetFilePathZsh(t *testing.T) { + home := t.TempDir() + got := TargetFilePath(Zsh, home) + assert.Equal(t, filepath.Join(home, ".zshrc"), got) +} + +func TestTargetFilePathFish(t *testing.T) { + home := t.TempDir() + got := TargetFilePath(Fish, home) + assert.Equal(t, filepath.Join(home, ".config", "fish", "completions", "databricks.fish"), got) +} + +func TestTargetFilePathPowerShellUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix-only test") + } + home := t.TempDir() + got := TargetFilePath(PowerShell, home) + assert.Equal(t, filepath.Join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"), got) +} + +func TestTargetFilePathPowerShell5(t *testing.T) { + home := t.TempDir() + got := TargetFilePath(PowerShell5, home) + assert.Equal(t, filepath.Join(home, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"), got) +} + +func TestShimContent(t *testing.T) { + tests := []struct { + shell Shell + contains string + }{ + {Bash, `eval "$(databricks completion bash)"`}, + {Zsh, `eval "$(databricks completion zsh)"`}, + {Fish, "databricks completion fish | source"}, + {PowerShell, "databricks completion powershell | Out-String | Invoke-Expression"}, + {PowerShell5, "databricks completion powershell | Out-String | Invoke-Expression"}, + } + + for _, tt := range tests { + t.Run(string(tt.shell), func(t *testing.T) { + content := ShimContent(tt.shell) + assert.Contains(t, content, BeginMarker) + assert.Contains(t, content, EndMarker) + assert.Contains(t, content, tt.contains) + }) + } +} + +func TestDisplayName(t *testing.T) { + tests := []struct { + shell Shell + expected string + }{ + {Bash, "bash"}, + {Zsh, "zsh"}, + {Fish, "fish"}, + {PowerShell, "powershell (pwsh 7+)"}, + {PowerShell5, "powershell5 (Windows PowerShell 5.1)"}, + } + + for _, tt := range tests { + t.Run(string(tt.shell), func(t *testing.T) { + assert.Equal(t, tt.expected, tt.shell.DisplayName()) + }) + } +} diff --git a/libs/completion/status.go b/libs/completion/status.go new file mode 100644 index 0000000000..dc7f81b796 --- /dev/null +++ b/libs/completion/status.go @@ -0,0 +1,50 @@ +package completion + +import ( + "os" + "strings" +) + +// StatusResult describes the current completion installation state. +type StatusResult struct { + Installed bool // true if completions are available by any method + Method string // "marker" | "homebrew" | "file" | "" + FilePath string // the file that is/would be modified +} + +// Status checks whether shell completion is currently available. +func Status(shell Shell, homeDir string) (*StatusResult, error) { + filePath := TargetFilePath(shell, homeDir) + result := &StatusResult{FilePath: filePath} + + // Check for our marker block in the target file. + if content, err := os.ReadFile(filePath); err == nil { + if strings.Contains(string(content), BeginMarker) { + result.Installed = true + result.Method = "marker" + return result, nil + } + } + + // For fish: check if the file exists at all (could be installed by a package manager). + if shell == Fish { + if _, err := os.Stat(filePath); err == nil { + result.Installed = true + result.Method = "file" + return result, nil + } + } + + // For zsh: check Homebrew completions. + if shell == Zsh { + if p := homebrewCompletionPath(); p != "" { + if _, err := os.Stat(p); err == nil { + result.Installed = true + result.Method = "homebrew" + return result, nil + } + } + } + + return result, nil +} diff --git a/libs/completion/status_test.go b/libs/completion/status_test.go new file mode 100644 index 0000000000..ca7d867fba --- /dev/null +++ b/libs/completion/status_test.go @@ -0,0 +1,124 @@ +package completion + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatusNotInstalled(t *testing.T) { + home := t.TempDir() + // Override HOMEBREW_PREFIX so the real system Homebrew isn't detected. + t.Setenv("HOMEBREW_PREFIX", t.TempDir()) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.False(t, result.Installed) + assert.Empty(t, result.Method) + assert.Equal(t, filepath.Join(home, ".zshrc"), result.FilePath) +} + +func TestStatusInstalledViaMarker(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(ShimContent(Zsh)), 0o644)) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusFishFileExists(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + // Write a file without our markers (simulating package manager install). + require.NoError(t, os.WriteFile(fishPath, []byte("# package manager completions\n"), 0o644)) + + result, err := Status(Fish, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "file", result.Method) +} + +func TestStatusFishWithMarker(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + require.NoError(t, os.WriteFile(fishPath, []byte(ShimContent(Fish)), 0o644)) + + result, err := Status(Fish, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusHomebrewZsh(t *testing.T) { + home := t.TempDir() + brewPrefix := t.TempDir() + + // Create a fake brew binary so detection works. + require.NoError(t, os.MkdirAll(filepath.Join(brewPrefix, "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(brewPrefix, "bin", "brew"), nil, 0o755)) + + // Create the homebrew completion file. + completionDir := filepath.Join(brewPrefix, "share", "zsh", "site-functions") + require.NoError(t, os.MkdirAll(completionDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(completionDir, "_databricks"), []byte("#compdef databricks\n"), 0o644)) + + t.Setenv("HOMEBREW_PREFIX", brewPrefix) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "homebrew", result.Method) +} + +func TestStatusMarkerTakesPrecedenceOverHomebrew(t *testing.T) { + home := t.TempDir() + brewPrefix := t.TempDir() + + // Set up homebrew. + require.NoError(t, os.MkdirAll(filepath.Join(brewPrefix, "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(brewPrefix, "bin", "brew"), nil, 0o755)) + completionDir := filepath.Join(brewPrefix, "share", "zsh", "site-functions") + require.NoError(t, os.MkdirAll(completionDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(completionDir, "_databricks"), nil, 0o644)) + t.Setenv("HOMEBREW_PREFIX", brewPrefix) + + // Also install via marker. + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(ShimContent(Zsh)), 0o644)) + + result, err := Status(Zsh, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusBash(t *testing.T) { + home := t.TempDir() + filePath := TargetFilePath(Bash, home) + require.NoError(t, os.WriteFile(filePath, []byte(ShimContent(Bash)), 0o644)) + + result, err := Status(Bash, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} + +func TestStatusPowerShell(t *testing.T) { + home := t.TempDir() + filePath := TargetFilePath(PowerShell, home) + require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o755)) + require.NoError(t, os.WriteFile(filePath, []byte(ShimContent(PowerShell)), 0o644)) + + result, err := Status(PowerShell, home) + require.NoError(t, err) + assert.True(t, result.Installed) + assert.Equal(t, "marker", result.Method) +} diff --git a/libs/completion/uninstall.go b/libs/completion/uninstall.go new file mode 100644 index 0000000000..07ad6181d5 --- /dev/null +++ b/libs/completion/uninstall.go @@ -0,0 +1,91 @@ +package completion + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +var multiBlankLine = regexp.MustCompile(`\n{3,}`) + +// Uninstall removes shell completion config. Returns the file path that was +// modified and whether it was actually installed. +func Uninstall(shell Shell, homeDir string) (filePath string, wasInstalled bool, err error) { + filePath = TargetFilePath(shell, homeDir) + + if shell == Fish { + return uninstallFish(filePath) + } + return uninstallRC(filePath) +} + +// uninstallFish handles the file-drop model: remove the file only if it +// contains our marker. This avoids deleting completions installed by a package +// manager or created by the user. +func uninstallFish(filePath string) (string, bool, error) { + content, err := os.ReadFile(filePath) + if os.IsNotExist(err) { + return filePath, false, nil + } + if err != nil { + return filePath, false, err + } + + if !strings.Contains(string(content), BeginMarker) { + return filePath, false, nil + } + + if err := os.Remove(filePath); err != nil { + return filePath, false, err + } + return filePath, true, nil +} + +// uninstallRC handles the RC file model: find and remove the marker block. +func uninstallRC(filePath string) (string, bool, error) { + info, err := os.Stat(filePath) + if os.IsNotExist(err) { + return filePath, false, nil + } + if err != nil { + return filePath, false, err + } + + content, err := os.ReadFile(filePath) + if err != nil { + return filePath, false, err + } + + text := string(content) + beginIdx := strings.Index(text, BeginMarker) + if beginIdx == -1 { + return filePath, false, nil + } + + // Find the line number of BEGIN for error reporting. + beginLine := strings.Count(text[:beginIdx], "\n") + 1 + + // Look for END marker after BEGIN. + afterBegin := text[beginIdx:] + endIdx := strings.Index(afterBegin, EndMarker) + if endIdx == -1 { + return filePath, false, fmt.Errorf( + "found corrupted completion block in %s: missing end marker. Please remove the block starting at line %d manually", + filePath, beginLine, + ) + } + + // Calculate absolute end position (after the END marker line including newline). + blockEnd := beginIdx + endIdx + len(EndMarker) + if blockEnd < len(text) && text[blockEnd] == '\n' { + blockEnd++ + } + + result := text[:beginIdx] + text[blockEnd:] + + // Collapse double blank lines left by removal. + result = multiBlankLine.ReplaceAllString(result, "\n\n") + + return filePath, true, os.WriteFile(filePath, []byte(result), info.Mode()) +} diff --git a/libs/completion/uninstall_test.go b/libs/completion/uninstall_test.go new file mode 100644 index 0000000000..9c9032f575 --- /dev/null +++ b/libs/completion/uninstall_test.go @@ -0,0 +1,159 @@ +package completion + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUninstallRemovesBlock(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + content := "# before\n" + ShimContent(Zsh) + "# after\n" + require.NoError(t, os.WriteFile(rcPath, []byte(content), 0o644)) + + filePath, wasInstalled, err := Uninstall(Zsh, home) + require.NoError(t, err) + assert.True(t, wasInstalled) + assert.Equal(t, rcPath, filePath) + + result, err := os.ReadFile(rcPath) + require.NoError(t, err) + // Assert exact content to verify line boundaries are preserved. + assert.Equal(t, "# before\n# after\n", string(result)) +} + +func TestUninstallNotInstalled(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte("# no completion here\n"), 0o644)) + + _, wasInstalled, err := Uninstall(Zsh, home) + require.NoError(t, err) + assert.False(t, wasInstalled) +} + +func TestUninstallFileDoesNotExist(t *testing.T) { + home := t.TempDir() + + _, wasInstalled, err := Uninstall(Zsh, home) + require.NoError(t, err) + assert.False(t, wasInstalled) +} + +func TestUninstallCorruptedMissingEnd(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + content := "# before\n" + BeginMarker + "\neval something\n" + require.NoError(t, os.WriteFile(rcPath, []byte(content), 0o644)) + + _, _, err := Uninstall(Zsh, home) + require.Error(t, err) + assert.ErrorContains(t, err, "corrupted completion block") + assert.ErrorContains(t, err, "missing end marker") + assert.ErrorContains(t, err, "line 2") + + // Verify file is unchanged. + result, readErr := os.ReadFile(rcPath) + require.NoError(t, readErr) + assert.Equal(t, content, string(result)) +} + +func TestUninstallCollapsesDoubleBlankLines(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + content := "# before\n\n" + ShimContent(Zsh) + "\n# after\n" + require.NoError(t, os.WriteFile(rcPath, []byte(content), 0o644)) + + _, _, err := Uninstall(Zsh, home) + require.NoError(t, err) + + result, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.NotContains(t, string(result), "\n\n\n") +} + +func TestUninstallPreservesPermissions(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(rcPath, []byte(ShimContent(Zsh)), 0o600)) + + _, _, err := Uninstall(Zsh, home) + require.NoError(t, err) + + info, err := os.Stat(rcPath) + require.NoError(t, err) + + if runtime.GOOS != "windows" { + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } else { + // Windows has different permission semantics; verify file remains writable. + err = os.WriteFile(rcPath, []byte("# writable"), 0o600) + assert.NoError(t, err) + } +} + +func TestUninstallFish(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + // Write content that includes our marker (simulating a CLI-managed file). + require.NoError(t, os.WriteFile(fishPath, []byte(ShimContent(Fish)), 0o644)) + + filePath, wasInstalled, err := Uninstall(Fish, home) + require.NoError(t, err) + assert.True(t, wasInstalled) + assert.Equal(t, fishPath, filePath) + + _, err = os.Stat(fishPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestUninstallFishForeignFile(t *testing.T) { + home := t.TempDir() + fishPath := filepath.Join(home, ".config", "fish", "completions", "databricks.fish") + require.NoError(t, os.MkdirAll(filepath.Dir(fishPath), 0o755)) + // Write content without our marker (e.g. installed by a package manager). + require.NoError(t, os.WriteFile(fishPath, []byte("# fish completions from homebrew\n"), 0o644)) + + _, wasInstalled, err := Uninstall(Fish, home) + require.NoError(t, err) + assert.False(t, wasInstalled) + + // File must be preserved. + _, err = os.Stat(fishPath) + assert.NoError(t, err) +} + +func TestUninstallFishNotPresent(t *testing.T) { + home := t.TempDir() + + _, wasInstalled, err := Uninstall(Fish, home) + require.NoError(t, err) + assert.False(t, wasInstalled) +} + +func TestInstallThenUninstallRoundTrip(t *testing.T) { + home := t.TempDir() + rcPath := filepath.Join(home, ".zshrc") + original := "# my zsh config\nexport FOO=bar\n" + require.NoError(t, os.WriteFile(rcPath, []byte(original), 0o644)) + + _, _, err := Install(Zsh, home) + require.NoError(t, err) + + content, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.Contains(t, string(content), BeginMarker) + + _, _, err = Uninstall(Zsh, home) + require.NoError(t, err) + + result, err := os.ReadFile(rcPath) + require.NoError(t, err) + assert.Equal(t, original, string(result)) +}