diff --git a/pkg/ansi/diff.go b/pkg/ansi/diff.go new file mode 100644 index 000000000..364690fac --- /dev/null +++ b/pkg/ansi/diff.go @@ -0,0 +1,233 @@ +package ansi + +import ( + "fmt" + "io" + "strings" + + "github.com/logrusorgru/aurora" +) + +// RenderDiff prints a colorized, line-numbered unified diff to w. +// path is shown as a header. oldContent/newContent are full file contents. +// Colors respect --color flag, CLICOLOR, and TTY detection via Color(w). +func RenderDiff(w io.Writer, path, oldContent, newContent string) { + if oldContent == newContent { + return + } + + color := Color(w) + + oldLines := splitLines(oldContent) + newLines := splitLines(newContent) + + // Print header + if oldContent == "" && newContent != "" { + fmt.Fprintf(w, "\n%s %s:\n\n", path, color.Sprintf(color.Faint("(new file)"))) + } else { + fmt.Fprintf(w, "\n%s:\n\n", path) + } + + // Find common prefix (identical lines from start) + prefixLen := 0 + for prefixLen < len(oldLines) && prefixLen < len(newLines) && oldLines[prefixLen] == newLines[prefixLen] { + prefixLen++ + } + + // Find common suffix (identical lines from end, not overlapping prefix) + suffixLen := 0 + for suffixLen < len(oldLines)-prefixLen && suffixLen < len(newLines)-prefixLen && + oldLines[len(oldLines)-1-suffixLen] == newLines[len(newLines)-1-suffixLen] { + suffixLen++ + } + + oldMiddleStart := prefixLen + oldMiddleEnd := len(oldLines) - suffixLen + newMiddleStart := prefixLen + newMiddleEnd := len(newLines) - suffixLen + + hunks := buildHunks(oldLines, newLines, oldMiddleStart, oldMiddleEnd, newMiddleStart, newMiddleEnd) + + useColors := shouldUseColors(w) + + for i, h := range hunks { + if i > 0 { + fmt.Fprintf(w, "%s\n", color.Sprintf(color.Faint(" ···"))) + } + renderHunk(w, color, useColors, oldLines, newLines, h, 3) + } + + fmt.Fprintln(w) +} + +// diffHunk represents a contiguous changed region. +type diffHunk struct { + oldStart int // index into oldLines where removed lines start + oldEnd int // exclusive end + newStart int // index into newLines where added lines start + newEnd int // exclusive end +} + +// buildHunks identifies individual hunks within the middle changed region. +// When the middle contains runs of 7+ identical lines, the region is split +// into separate hunks so the output shows ··· between distant changes. +func buildHunks(oldLines, newLines []string, oldStart, oldEnd, newStart, newEnd int) []diffHunk { + oldMiddle := oldLines[oldStart:oldEnd] + newMiddle := newLines[newStart:newEnd] + + if len(oldMiddle) == 0 && len(newMiddle) == 0 { + return nil + } + + // For purely added or purely removed, there are no common inner lines to split on. + if len(oldMiddle) == 0 || len(newMiddle) == 0 { + return []diffHunk{{oldStart: oldStart, oldEnd: oldEnd, newStart: newStart, newEnd: newEnd}} + } + + // Look for runs of common lines within the middle region that are long + // enough (7+) to justify splitting into separate hunks. + // Use a simple LCS-like scan: match identical lines at the same offset. + type matchRun struct { + oldIdx, newIdx, length int + } + + var runs []matchRun + oi, ni := 0, 0 + for oi < len(oldMiddle) && ni < len(newMiddle) { + if oldMiddle[oi] == newMiddle[ni] { + start := oi + nStart := ni + for oi < len(oldMiddle) && ni < len(newMiddle) && oldMiddle[oi] == newMiddle[ni] { + oi++ + ni++ + } + if oi-start >= 7 { + runs = append(runs, matchRun{oldIdx: start, newIdx: nStart, length: oi - start}) + } + } else { + // Advance whichever side is "behind" + if oi < ni { + oi++ + } else { + ni++ + } + } + } + + if len(runs) == 0 { + return []diffHunk{{oldStart: oldStart, oldEnd: oldEnd, newStart: newStart, newEnd: newEnd}} + } + + // Split the middle region around the runs + var hunks []diffHunk + curOldStart, curNewStart := 0, 0 + + for _, run := range runs { + // Everything before this run is a hunk + if curOldStart < run.oldIdx || curNewStart < run.newIdx { + hunks = append(hunks, diffHunk{ + oldStart: oldStart + curOldStart, + oldEnd: oldStart + run.oldIdx, + newStart: newStart + curNewStart, + newEnd: newStart + run.newIdx, + }) + } + curOldStart = run.oldIdx + run.length + curNewStart = run.newIdx + run.length + } + + // Remaining after last run + if curOldStart < len(oldMiddle) || curNewStart < len(newMiddle) { + hunks = append(hunks, diffHunk{ + oldStart: oldStart + curOldStart, + oldEnd: oldEnd, + newStart: newStart + curNewStart, + newEnd: newEnd, + }) + } + + return hunks +} + +// renderHunk renders a single hunk with surrounding context lines. +func renderHunk(w io.Writer, color aurora.Aurora, useColors bool, oldLines, newLines []string, h diffHunk, contextSize int) { + // Leading context: use lines from whichever side has them at the common prefix position + contextStart := max(0, min(h.oldStart, h.newStart)-contextSize) + + // Use newLines for context when available (they represent the final state), + // falling back to oldLines for the leading context region. + contextSource := newLines + if len(newLines) == 0 { + contextSource = oldLines + } + + // Format: left-aligned number in a fixed-width gutter, then prefix+text. + // The +/- prefix replaces the second space in context lines, keeping + // the text aligned while visually marking the change. + // Context: " 123 line text" + // Removed: " 123 -line text" (dark red background) + // Added: " 123 +line text" (dark green background) + + changeStart := min(h.oldStart, h.newStart) + for i := contextStart; i < changeStart && i < len(contextSource); i++ { + fmt.Fprintf(w, "%5d %s\n", i+1, contextSource[i]) + } + + // Removed lines — very dark red background covering full line including gutter, + // desaturated red line number (index 131: #af5f5f) + for i := h.oldStart; i < h.oldEnd; i++ { + renderColoredLine(w, useColors, i+1, "-", oldLines[i], diffColorRemoved, 131) + } + + // Added lines — very dark green background covering full line including gutter, + // bright green line number (index 34: #00af00) + for i := h.newStart; i < h.newEnd; i++ { + renderColoredLine(w, useColors, i+1, "+", newLines[i], diffColorAdded, 34) + } + + // Trailing context (use new file lines and line numbers) + trailStart := h.newEnd + trailEnd := min(len(newLines), trailStart+contextSize) + for i := trailStart; i < trailEnd; i++ { + fmt.Fprintf(w, "%5d %s\n", i+1, newLines[i]) + } +} + +// diffColor holds the true-color RGB for a diff line background. +type diffColor struct { + r, g, b uint8 +} + +var ( + // Very dark green (#002200) and very dark red (#220000) for diff backgrounds. + // True-color (24-bit) ANSI escapes, darker than the 256-color palette allows. + diffColorAdded = diffColor{0x00, 0x22, 0x00} + diffColorRemoved = diffColor{0x22, 0x00, 0x00} +) + +// renderColoredLine writes a full-width background-colored diff line using +// raw ANSI escapes. The background covers the entire line including the gutter. +// The line number foreground is set without resetting, so the background persists. +func renderColoredLine(w io.Writer, useColors bool, lineNum int, prefix, text string, bg diffColor, fgIndex uint8) { + if useColors { + // Set background (24-bit), then foreground (256-color) for line number, + // print number, reset foreground to default, print prefix+text, then full reset. + fmt.Fprintf(w, "\x1b[48;2;%d;%d;%dm\x1b[38;5;%dm%5d %s\x1b[39m%s\x1b[0m\n", + bg.r, bg.g, bg.b, fgIndex, lineNum, prefix, text) + } else { + fmt.Fprintf(w, "%5d %s%s\n", lineNum, prefix, text) + } +} + +// splitLines splits a string into lines, removing a trailing empty element +// caused by a final newline. +func splitLines(s string) []string { + if s == "" { + return nil + } + lines := strings.Split(s, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} diff --git a/pkg/ansi/diff_test.go b/pkg/ansi/diff_test.go new file mode 100644 index 000000000..22a36b5f4 --- /dev/null +++ b/pkg/ansi/diff_test.go @@ -0,0 +1,284 @@ +package ansi_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stripe/stripe-cli/pkg/ansi" +) + +func disableColors(t *testing.T) { + t.Helper() + ansi.DisableColors = true + t.Cleanup(func() { ansi.DisableColors = false }) +} + +func TestRenderDiff_NewFile(t *testing.T) { + disableColors(t) + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "~/.zshrc", "", "line one\nline two\nline three\n") + + output := buf.String() + assert.Contains(t, output, "~/.zshrc (new file):") + assert.Contains(t, output, " 1 +line one") + assert.Contains(t, output, " 2 +line two") + assert.Contains(t, output, " 3 +line three") + assert.True(t, strings.HasSuffix(output, "\n"), "output should end with newline") +} + +func TestRenderDiff_LinesAddedAtEnd(t *testing.T) { + disableColors(t) + + oldContent := "line 1\nline 2\nline 3\nline 4\nline 5\n" + newContent := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + assert.Contains(t, output, "file.txt:") + assert.NotContains(t, output, "(new file)") + // Should show 3 lines of context before the additions + assert.Contains(t, output, " 3 line 3") + assert.Contains(t, output, " 4 line 4") + assert.Contains(t, output, " 5 line 5") + // Then the added lines (number, space, +, text) + assert.Contains(t, output, " 6 +line 6") + assert.Contains(t, output, " 7 +line 7") +} + +func TestRenderDiff_LinesRemoved(t *testing.T) { + disableColors(t) + + oldContent := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\n" + newContent := "line 1\nline 2\nline 3\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + assert.Contains(t, output, "file.txt:") + // Should show context before removed lines + assert.Contains(t, output, " 1 line 1") + assert.Contains(t, output, " 2 line 2") + assert.Contains(t, output, " 3 line 3") + // Then the removed lines (number, space, -, text) + assert.Contains(t, output, " 4 -line 4") + assert.Contains(t, output, " 5 -line 5") + assert.Contains(t, output, " 6 -line 6") +} + +func TestRenderDiff_LinesRemovedFromMiddle(t *testing.T) { + disableColors(t) + + oldContent := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n" + newContent := "line 1\nline 2\nline 3\nline 6\nline 7\nline 8\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Should show 3 lines of context before + assert.Contains(t, output, " 1 line 1") + assert.Contains(t, output, " 2 line 2") + assert.Contains(t, output, " 3 line 3") + // Then removed lines + assert.Contains(t, output, " 4 -line 4") + assert.Contains(t, output, " 5 -line 5") + // Then 3 lines of context after (new-file line numbers) + assert.Contains(t, output, " 4 line 6") + assert.Contains(t, output, " 5 line 7") + assert.Contains(t, output, " 6 line 8") +} + +func TestRenderDiff_Replacement(t *testing.T) { + disableColors(t) + + oldContent := "line 1\nline 2\nold line 3\nold line 4\nline 5\nline 6\n" + newContent := "line 1\nline 2\nnew line 3\nnew line 4\nline 5\nline 6\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Should show removal of old lines + assert.Contains(t, output, " 3 -old line 3") + assert.Contains(t, output, " 4 -old line 4") + // And addition of new lines + assert.Contains(t, output, " 3 +new line 3") + assert.Contains(t, output, " 4 +new line 4") + // With proper context + assert.Contains(t, output, " 2 line 2") + assert.Contains(t, output, " 5 line 5") +} + +func TestRenderDiff_MultipleHunks(t *testing.T) { + disableColors(t) + + // Create content with changes at start and end, separated by 7+ unchanged lines + oldContent := "old line 1\nold line 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nold line 11\nold line 12\n" + newContent := "new line 1\nnew line 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nnew line 11\nnew line 12\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Should show first hunk + assert.Contains(t, output, " 1 -old line 1") + assert.Contains(t, output, " 2 -old line 2") + assert.Contains(t, output, " 1 +new line 1") + assert.Contains(t, output, " 2 +new line 2") + // Should show separator + assert.Contains(t, output, " ···") + // Should show second hunk + assert.Contains(t, output, " 11 -old line 11") + assert.Contains(t, output, " 12 -old line 12") + assert.Contains(t, output, " 11 +new line 11") + assert.Contains(t, output, " 12 +new line 12") +} + +func TestRenderDiff_NoChange(t *testing.T) { + disableColors(t) + + content := "line 1\nline 2\nline 3\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", content, content) + + output := buf.String() + // When there's no change, should output nothing + assert.Empty(t, output) +} + +func TestRenderDiff_EmptyToEmpty(t *testing.T) { + disableColors(t) + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", "", "") + + output := buf.String() + // Both empty means no change + assert.Empty(t, output) +} + +func TestRenderDiff_ContextCappingAtStart(t *testing.T) { + disableColors(t) + + // Change on line 1 - should show only available context (none before) + oldContent := "old line 1\nline 2\nline 3\nline 4\nline 5\n" + newContent := "new line 1\nline 2\nline 3\nline 4\nline 5\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Should show the change immediately without 3 lines of context before + assert.Contains(t, output, " 1 -old line 1") + assert.Contains(t, output, " 1 +new line 1") + // Should show 3 lines of context after + assert.Contains(t, output, " 2 line 2") + assert.Contains(t, output, " 3 line 3") + assert.Contains(t, output, " 4 line 4") +} + +func TestRenderDiff_ContextCappingAtStartWithTwoLines(t *testing.T) { + disableColors(t) + + // Change on line 3 - should show only 2 lines of context before + oldContent := "line 1\nline 2\nold line 3\nline 4\nline 5\nline 6\n" + newContent := "line 1\nline 2\nnew line 3\nline 4\nline 5\nline 6\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Should show 2 lines of context before (not 3, because we're at line 3) + assert.Contains(t, output, " 1 line 1") + assert.Contains(t, output, " 2 line 2") + assert.Contains(t, output, " 3 -old line 3") + assert.Contains(t, output, " 3 +new line 3") + // Should show 3 lines of context after + assert.Contains(t, output, " 4 line 4") + assert.Contains(t, output, " 5 line 5") + assert.Contains(t, output, " 6 line 6") +} + +func TestRenderDiff_ContextCappingAtEnd(t *testing.T) { + disableColors(t) + + // Change on last line - should show only available context (none after) + oldContent := "line 1\nline 2\nline 3\nline 4\nold line 5\n" + newContent := "line 1\nline 2\nline 3\nline 4\nnew line 5\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Should show 3 lines of context before + assert.Contains(t, output, " 2 line 2") + assert.Contains(t, output, " 3 line 3") + assert.Contains(t, output, " 4 line 4") + // Then the change + assert.Contains(t, output, " 5 -old line 5") + assert.Contains(t, output, " 5 +new line 5") + // No context after (it's the last line) +} + +func TestRenderDiff_LineNumberAlignment(t *testing.T) { + disableColors(t) + + // Test that line numbers are right-aligned in a 5-char gutter + oldContent := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n" + newContent := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Single-digit line numbers should be right-aligned + assert.Contains(t, output, " 8 line 8") + assert.Contains(t, output, " 9 line 9") + // Double-digit line numbers should be right-aligned + assert.Contains(t, output, " 10 line 10") + assert.Contains(t, output, " 11 +line 11") +} + +func TestRenderDiff_TrailingNewline(t *testing.T) { + disableColors(t) + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", "", "line 1\n") + + output := buf.String() + // Should end with trailing newline + assert.True(t, strings.HasSuffix(output, "\n\n"), "output should end with two newlines (one from last line, one trailing)") +} + +func TestRenderDiff_ComplexReplacement(t *testing.T) { + disableColors(t) + + // Replace multiple lines with different number of lines + oldContent := "line 1\nline 2\nold line 3\nold line 4\nold line 5\nline 6\nline 7\n" + newContent := "line 1\nline 2\nnew line 3\nnew line 4\nline 6\nline 7\n" + + var buf bytes.Buffer + ansi.RenderDiff(&buf, "file.txt", oldContent, newContent) + + output := buf.String() + // Should show context + assert.Contains(t, output, " 2 line 2") + // Should show all removed lines + assert.Contains(t, output, " 3 -old line 3") + assert.Contains(t, output, " 4 -old line 4") + assert.Contains(t, output, " 5 -old line 5") + // Should show all added lines + assert.Contains(t, output, " 3 +new line 3") + assert.Contains(t, output, " 4 +new line 4") + // Should show context after (new-file line numbers: line 6 is now at position 5) + assert.Contains(t, output, " 5 line 6") + assert.Contains(t, output, " 6 line 7") +} diff --git a/pkg/cmd/completion.go b/pkg/cmd/completion.go index 29a1cb78e..0615445a9 100644 --- a/pkg/cmd/completion.go +++ b/pkg/cmd/completion.go @@ -1,7 +1,10 @@ package cmd import ( + "bufio" + "bytes" "fmt" + "io" "os" "path/filepath" "runtime" @@ -9,14 +12,25 @@ import ( "github.com/spf13/cobra" + "github.com/stripe/stripe-cli/pkg/ansi" "github.com/stripe/stripe-cli/pkg/validators" ) +// sentinelBegin and sentinelEnd mark the completion configuration block +// in shell config files (~/.zshrc, ~/.bashrc, ~/.bash_profile). This allows +// safe idempotent install/uninstall without corrupting the user's existing config. +const ( + sentinelBegin = "# begin stripe-completion" + sentinelEnd = "# end stripe-completion" +) + type completionCmd struct { cmd *cobra.Command shell string writeToStdout bool + install bool + uninstall bool } func newCompletionCmd() *completionCmd { @@ -25,14 +39,47 @@ func newCompletionCmd() *completionCmd { cc.cmd = &cobra.Command{ Use: "completion", Short: "Generate bash, zsh, and fish completion scripts", - Args: validators.NoArgs, + Long: "Generate shell completion scripts. Use --install to automatically configure your shell profile, or run without flags to generate a script file manually.", + Example: ` # Auto-install completions (detects your shell) + stripe completion --install + + # Install for a specific shell + stripe completion --install --shell zsh + + # Remove installed completions + stripe completion --uninstall + + # Generate completion script to stdout + stripe completion --shell bash --write-to-stdout`, + Args: validators.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return selectShell(cc.shell, cc.writeToStdout) + shell := cc.shell + if shell == "" { + shell = detectShell() + } + + if cc.install || cc.uninstall { + if shell == "" { + return fmt.Errorf("could not automatically detect your shell. Please run the command with the `--shell` flag for bash, zsh, or fish") + } + if shell != "bash" && shell != "zsh" && shell != "fish" { + return fmt.Errorf("unsupported shell %q. Supported shells: bash, zsh, fish", shell) + } + if cc.install { + return installCompletion(shell, os.UserHomeDir) + } + return uninstallCompletion(shell, os.UserHomeDir) + } + + return selectShell(shell, cc.writeToStdout) }, } cc.cmd.Flags().StringVar(&cc.shell, "shell", "", "Shell to generate completions for: bash, zsh, or fish (auto-detected if omitted)") cc.cmd.Flags().BoolVar(&cc.writeToStdout, "write-to-stdout", false, "Print completion script to stdout rather than creating a new file.") + cc.cmd.Flags().BoolVar(&cc.install, "install", false, "Install completion script to ~/.stripe and configure your shell profile automatically") + cc.cmd.Flags().BoolVar(&cc.uninstall, "uninstall", false, "Remove installed completion script and configuration from your shell profile") + cc.cmd.MarkFlagsMutuallyExclusive("install", "uninstall") _ = cc.cmd.RegisterFlagCompletionFunc("shell", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"bash", "zsh", "fish"}, cobra.ShellCompDirectiveNoFileComp @@ -197,100 +244,279 @@ func detectShell() string { } } -// sentinelBegin and sentinelEnd mark the completion configuration block -// in shell config files (~/.zshrc, ~/.bashrc, ~/.bash_profile). This allows -// safe idempotent install/uninstall without corrupting the user's existing config. -const ( - sentinelBegin = "# begin stripe-completion — managed by stripe cli, do not edit" - sentinelEnd = "# end stripe-completion" -) +// --------------------------------------------------------------------------- +// Auto-install/uninstall support +// --------------------------------------------------------------------------- -// addSentinelBlock adds or replaces a sentinel-delimited block in the given -// config file. If the file does not exist, it is created with mode 0644. -// Existing file permissions are preserved. The operation is idempotent: -// calling it twice with the same line produces the same result as calling -// it once. If the file contains orphaned or reversed markers, a new block -// is appended rather than attempting to repair the malformed state. -func addSentinelBlock(configPath, line string) error { - block := fmt.Sprintf("%s\n%s\n%s", sentinelBegin, line, sentinelEnd) +// getCompletionScriptDir returns the directory where completion scripts are stored. +func getCompletionScriptDir(homeDir string) string { + return filepath.Join(homeDir, ".stripe") +} - data, err := os.ReadFile(configPath) +// getShellConfigFile returns the path to the shell's configuration file. +// For fish, returns "" because fish auto-loads completions from a directory +// (~/.config/fish/completions/) and does not require a config file entry. +func getShellConfigFile(shell, homeDir string) string { + switch shell { + case "bash": + if runtime.GOOS == "darwin" { + return filepath.Join(homeDir, ".bash_profile") + } + return filepath.Join(homeDir, ".bashrc") + case "zsh": + return filepath.Join(homeDir, ".zshrc") + default: + return "" + } +} + +// getFishCompletionsDir returns the directory where fish completions are stored. +func getFishCompletionsDir(homeDir string) string { + return filepath.Join(homeDir, ".config", "fish", "completions") +} + +// completionScriptFilename returns the filename for the completion script. +// Fish uses "stripe.fish" (matching the command name) rather than +// "stripe-completion.fish" because fish auto-loads completions from +// ~/.config/fish/completions/ based on command name. +func completionScriptFilename(shell string) string { + switch shell { + case "bash": + return "stripe-completion.bash" + case "zsh": + return "stripe-completion.zsh" + case "fish": + return "stripe.fish" + default: + return "" + } +} + +// generateCompletionScript writes the completion script for the given shell into buf. +func generateCompletionScript(shell string, buf *bytes.Buffer) error { + switch shell { + case "bash": + return rootCmd.GenBashCompletionV2(buf, true) + case "zsh": + return rootCmd.GenZshCompletion(buf) + case "fish": + return rootCmd.GenFishCompletion(buf, true) + default: + return fmt.Errorf("unsupported shell: %s", shell) + } +} + +// sourceLine returns the shell-specific line that loads the completion script. +func sourceLine(shell, scriptPath string) string { + switch shell { + case "bash", "zsh": + return fmt.Sprintf("source \"%s\"", scriptPath) + default: + return "" + } +} + +// homeDirFunc is a function type that returns the user's home directory. +// Enables dependency injection during testing (see completion_test.go). +type homeDirFunc func() (string, error) + +func installCompletion(shell string, getHomeDir homeDirFunc) error { + homeDir, err := getHomeDir() + if err != nil { + return fmt.Errorf("could not determine home directory: %w", err) + } + + // Determine script destination + var scriptDir string + if shell == "fish" { + scriptDir = getFishCompletionsDir(homeDir) + } else { + scriptDir = getCompletionScriptDir(homeDir) + } + + // Create directory + if err := os.MkdirAll(scriptDir, 0755); err != nil { + return fmt.Errorf("could not create directory %s: %w", scriptDir, err) + } + + // Generate completion script + var buf bytes.Buffer + if err := generateCompletionScript(shell, &buf); err != nil { + return fmt.Errorf("could not generate %s completion script: %w", shell, err) + } + + // Write script file + scriptPath := filepath.Join(scriptDir, completionScriptFilename(shell)) + if err := os.WriteFile(scriptPath, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("could not write completion script to %s: %w", scriptPath, err) + } + + // For bash/zsh, add source line to shell config (with diff preview + confirmation) + if shell != "fish" { + configPath := getShellConfigFile(shell, homeDir) + line := sourceLine(shell, scriptPath) + + oldContent, perm, err := readConfigFile(configPath) + if err != nil { + return fmt.Errorf("could not read %s: %w", configPath, err) + } + + newContent := computeAddSentinel(oldContent, line) + + if newContent != oldContent { + ansi.RenderDiff(os.Stdout, configPath, oldContent, newContent) + + if !confirm(installConfirmFn, "Apply changes?") { + fmt.Printf("Aborted. Completion script was written to %s but your shell config was not modified.\nTo activate manually, add this line to %s:\n %s\n", scriptPath, configPath, line) + return nil + } + + if err := os.WriteFile(configPath, []byte(newContent), perm); err != nil { + return fmt.Errorf("could not update %s: %w", configPath, err) + } + + fmt.Printf("Completion installed for %s.\nScript written to: %s\nShell config updated: %s\nRestart your shell or run: %s\n", shell, scriptPath, configPath, line) + } else { + fmt.Printf("Completion already configured in %s.\nScript updated: %s\n", configPath, scriptPath) + } + + // Warn about manually-added lines outside our sentinel block + remnants := findManualRemnants(configPath, completionScriptFilename(shell)) + warnManualRemnants(configPath, remnants) + } else { + fmt.Printf("Completion installed for fish.\nScript written to: %s\nRestart your shell or open a new terminal session.\n", scriptPath) + } + + return nil +} + +func uninstallCompletion(shell string, getHomeDir homeDirFunc) error { + homeDir, err := getHomeDir() + if err != nil { + return fmt.Errorf("could not determine home directory: %w", err) + } + + // Determine script location + var scriptPath string + if shell == "fish" { + scriptPath = filepath.Join(getFishCompletionsDir(homeDir), completionScriptFilename(shell)) + } else { + scriptPath = filepath.Join(getCompletionScriptDir(homeDir), completionScriptFilename(shell)) + } + + // Remove script file (ignore if doesn't exist) + if err := os.Remove(scriptPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("could not remove completion script %s: %w", scriptPath, err) + } + + // For bash/zsh, remove sentinel block from shell config (with diff preview + confirmation) + if shell != "fish" { + configPath := getShellConfigFile(shell, homeDir) + + oldContent, perm, err := readConfigFile(configPath) + if err != nil { + return fmt.Errorf("could not read %s: %w", configPath, err) + } + + newContent, found := computeRemoveSentinel(oldContent) + + if found { + ansi.RenderDiff(os.Stdout, configPath, oldContent, newContent) + + if !confirm(uninstallConfirmFn, "Apply changes?") { + fmt.Printf("Aborted. Completion script was removed but your shell config was not modified.\nTo clean up manually, remove the block between \"%s\" and \"%s\" in %s.\n", sentinelBegin, sentinelEnd, configPath) + return nil + } + + if err := os.WriteFile(configPath, []byte(newContent), perm); err != nil { + return fmt.Errorf("could not update %s: %w", configPath, err) + } + } + + fmt.Printf("Completion uninstalled for %s.\n", shell) + + // Warn about manually-added lines that survive uninstall + remnants := findManualRemnants(configPath, completionScriptFilename(shell)) + if len(remnants) > 0 { + fmt.Printf("\nWarning: your shell config file %s still references the completion script outside the managed block:\n", configPath) + for _, r := range remnants { + fmt.Printf(" line %d: %s\n", r.lineNumber, r.lineText) + } + fmt.Printf("Remove %s manually to fully disable shell completion.\n", pluralize(len(remnants), "this line", "these lines")) + } + } else { + fmt.Printf("Completion uninstalled for %s.\n", shell) + } + + return nil +} + +// readConfigFile reads a shell config file and returns its content and +// permissions. If the file does not exist, returns ("", 0644, nil). +// Uses Open+Fstat to read content and permissions atomically from the +// same file descriptor. +func readConfigFile(path string) (string, os.FileMode, error) { + f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { - // Create new file with just the sentinel block - return os.WriteFile(configPath, []byte(block+"\n"), 0644) + return "", 0644, nil } - return err + return "", 0, err } + defer f.Close() - // Preserve existing file permissions - perm := os.FileMode(0644) - if info, statErr := os.Stat(configPath); statErr == nil { - perm = info.Mode().Perm() + info, err := f.Stat() + if err != nil { + return "", 0, err } - content := string(data) + data, err := io.ReadAll(f) + if err != nil { + return "", 0, err + } + + return string(data), info.Mode().Perm(), nil +} + +// computeAddSentinel returns the content with a sentinel block added or +// replaced. Pure function — no I/O. If the file contains orphaned or reversed +// markers, a new block is appended rather than attempting to repair. +func computeAddSentinel(content, line string) string { + block := fmt.Sprintf("%s\n%s\n%s", sentinelBegin, line, sentinelEnd) - // Replace existing block if both markers are present in the correct order. - // Orphaned or reversed markers are left untouched — we append instead. beginIdx := strings.Index(content, sentinelBegin) endIdx := strings.Index(content, sentinelEnd) if beginIdx >= 0 && endIdx >= 0 && endIdx > beginIdx { endIdx += len(sentinelEnd) - // Include trailing newline if present if endIdx < len(content) && content[endIdx] == '\n' { endIdx++ } - content = content[:beginIdx] + block + "\n" + content[endIdx:] - return os.WriteFile(configPath, []byte(content), perm) + return content[:beginIdx] + block + "\n" + content[endIdx:] } - // Append sentinel block if len(content) > 0 && !strings.HasSuffix(content, "\n") { content += "\n" } - content += block + "\n" - return os.WriteFile(configPath, []byte(content), perm) + return content + block + "\n" } -// removeSentinelBlock removes the sentinel-delimited block from the given -// config file. If the file does not exist, this is a no-op. If the markers -// are orphaned or reversed, the file is left unchanged. Existing file -// permissions are preserved. -func removeSentinelBlock(configPath string) error { - data, err := os.ReadFile(configPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - content := string(data) - +// computeRemoveSentinel returns the content with the sentinel block removed. +// Pure function — no I/O. Returns (result, true) if a block was found and +// removed, or ("", false) if no valid block exists. +func computeRemoveSentinel(content string) (string, bool) { beginIdx := strings.Index(content, sentinelBegin) endIdx := strings.Index(content, sentinelEnd) if beginIdx < 0 || endIdx < 0 || endIdx <= beginIdx { - // No valid sentinel block found, nothing to do - return nil - } - - // Preserve existing file permissions - perm := os.FileMode(0644) - if info, statErr := os.Stat(configPath); statErr == nil { - perm = info.Mode().Perm() + return "", false } endIdx += len(sentinelEnd) - // Include trailing newline if present if endIdx < len(content) && content[endIdx] == '\n' { endIdx++ } - content = content[:beginIdx] + content[endIdx:] - - return os.WriteFile(configPath, []byte(content), perm) + return content[:beginIdx] + content[endIdx:], true } // manualRemnant represents a line in a shell config file that references the @@ -347,3 +573,53 @@ func findManualRemnants(configPath, scriptFilename string) []manualRemnant { return remnants } + +// warnManualRemnants prints a warning about manually-added completion lines +// found outside the sentinel block. Does nothing if remnants is empty. +func warnManualRemnants(configPath string, remnants []manualRemnant) { + if len(remnants) == 0 { + return + } + + fmt.Printf("\nWarning: found a manually-added completion reference outside the managed block in %s:\n", configPath) + for _, r := range remnants { + fmt.Printf(" line %d: %s\n", r.lineNumber, r.lineText) + } + fmt.Printf("You may want to remove %s manually to avoid loading completions twice.\n", pluralize(len(remnants), "this line", "these lines")) +} + +// installConfirmFn and uninstallConfirmFn override the confirm prompt used +// during install/uninstall. nil means use the default stdin prompt. +// Override in tests to avoid blocking on stdin. +var ( + installConfirmFn func(string) bool + uninstallConfirmFn func(string) bool +) + +// confirm calls fn if non-nil, otherwise falls back to defaultConfirm. +func confirm(fn func(string) bool, question string) bool { + if fn != nil { + return fn(question) + } + return defaultConfirm(question) +} + +// defaultConfirm asks a yes/no question on stdout/stdin. +func defaultConfirm(question string) bool { + fmt.Printf("%s [y/N] ", question) + + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + return answer == "y" || answer == "yes" + } + + return false +} + +func pluralize(n int, singular, plural string) string { + if n == 1 { + return singular + } + return plural +} diff --git a/pkg/cmd/completion_test.go b/pkg/cmd/completion_test.go index f9dca993b..b5e458dd2 100644 --- a/pkg/cmd/completion_test.go +++ b/pkg/cmd/completion_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "fmt" "os" "path/filepath" @@ -10,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stripe/stripe-cli/pkg/ansi" ) // --------------------------------------------------------------------------- @@ -95,10 +98,7 @@ func TestSelectShellErrors(t *testing.T) { } func TestSelectShellWriteToStdout(t *testing.T) { - // rootCmd must be initialized for Cobra's completion generation to work. - // The init() function in root.go sets this up. shells := []string{"bash", "zsh", "fish"} - for _, shell := range shells { t.Run(shell, func(t *testing.T) { err := selectShell(shell, true) @@ -174,326 +174,164 @@ func TestGenShellCreatesFile(t *testing.T) { } // --------------------------------------------------------------------------- -// addSentinelBlock / removeSentinelBlock +// computeAddSentinel (pure function — no I/O) // --------------------------------------------------------------------------- -func TestAddSentinelBlockToNewFile(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - - err := addSentinelBlock(configPath, "source /home/user/.stripe/stripe-completion.zsh") - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - - content := string(data) - assert.Contains(t, content, sentinelBegin) - assert.Contains(t, content, "source /home/user/.stripe/stripe-completion.zsh") - assert.Contains(t, content, sentinelEnd) +func TestComputeAddSentinelToEmptyContent(t *testing.T) { + result := computeAddSentinel("", "source /home/user/.stripe/stripe-completion.zsh") + assert.Contains(t, result, sentinelBegin) + assert.Contains(t, result, "source /home/user/.stripe/stripe-completion.zsh") + assert.Contains(t, result, sentinelEnd) } -func TestAddSentinelBlockPreservesExistingContent(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - +func TestComputeAddSentinelPreservesExistingContent(t *testing.T) { existing := "export PATH=/usr/local/bin:$PATH\nalias ll='ls -la'\n" - require.NoError(t, os.WriteFile(configPath, []byte(existing), 0644)) - - err := addSentinelBlock(configPath, "source /home/user/.stripe/stripe-completion.zsh") - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - - content := string(data) - assert.True(t, strings.HasPrefix(content, existing), "existing content should be preserved at the start") - assert.Contains(t, content, sentinelBegin) - assert.Contains(t, content, sentinelEnd) + result := computeAddSentinel(existing, "source /home/user/.stripe/stripe-completion.zsh") + assert.True(t, strings.HasPrefix(result, existing), "existing content should be preserved at the start") + assert.Contains(t, result, sentinelBegin) + assert.Contains(t, result, sentinelEnd) } -func TestAddSentinelBlockReplaceExisting(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - - initial := fmt.Sprintf("before\n%s\nold source line\n%s\nafter\n", sentinelBegin, sentinelEnd) - require.NoError(t, os.WriteFile(configPath, []byte(initial), 0644)) - - err := addSentinelBlock(configPath, "new source line") - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - - content := string(data) - assert.Contains(t, content, "before\n") - assert.Contains(t, content, "new source line") - assert.NotContains(t, content, "old source line") - assert.Contains(t, content, "after\n") - - assert.Equal(t, 1, strings.Count(content, sentinelBegin)) - assert.Equal(t, 1, strings.Count(content, sentinelEnd)) +func TestComputeAddSentinelReplacesExisting(t *testing.T) { + content := fmt.Sprintf("before\n%s\nold source line\n%s\nafter\n", sentinelBegin, sentinelEnd) + result := computeAddSentinel(content, "new source line") + assert.Contains(t, result, "before\n") + assert.Contains(t, result, "new source line") + assert.NotContains(t, result, "old source line") + assert.Contains(t, result, "after\n") + assert.Equal(t, 1, strings.Count(result, sentinelBegin)) + assert.Equal(t, 1, strings.Count(result, sentinelEnd)) } -func TestAddSentinelBlockAppendsNewlineIfMissing(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - - require.NoError(t, os.WriteFile(configPath, []byte("no trailing newline"), 0644)) - - err := addSentinelBlock(configPath, "source line") - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - - content := string(data) - assert.True(t, strings.Contains(content, "no trailing newline\n"+sentinelBegin)) +func TestComputeAddSentinelAppendsNewlineIfMissing(t *testing.T) { + result := computeAddSentinel("no trailing newline", "source line") + assert.Contains(t, result, "no trailing newline\n"+sentinelBegin) } -func TestAddSentinelBlockOrphanedBeginOnly(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - +func TestComputeAddSentinelOrphanedBeginOnly(t *testing.T) { content := fmt.Sprintf("before\n%s\norphaned source line\nafter\n", sentinelBegin) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - - err := addSentinelBlock(configPath, "new source line") - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - result := string(data) + result := computeAddSentinel(content, "new source line") assert.Contains(t, result, "new source line") assert.Contains(t, result, sentinelEnd) } -func TestAddSentinelBlockOrphanedEndOnly(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - +func TestComputeAddSentinelOrphanedEndOnly(t *testing.T) { content := fmt.Sprintf("before\n%s\nafter\n", sentinelEnd) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - - err := addSentinelBlock(configPath, "new source line") - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - result := string(data) + result := computeAddSentinel(content, "new source line") assert.Contains(t, result, "new source line") - assert.Equal(t, 1, strings.Count(result, sentinelBegin), "should have exactly one begin marker") - assert.GreaterOrEqual(t, strings.Count(result, sentinelEnd), 1, "should have at least one end marker") + assert.Equal(t, 1, strings.Count(result, sentinelBegin)) } -func TestAddSentinelBlockReversedMarkers(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - +func TestComputeAddSentinelReversedMarkers(t *testing.T) { content := fmt.Sprintf("before\n%s\norphaned\n%s\nafter\n", sentinelEnd, sentinelBegin) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - - err := addSentinelBlock(configPath, "new source line") - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - result := string(data) + result := computeAddSentinel(content, "new source line") assert.Contains(t, result, "new source line") } -func TestRemoveSentinelBlockPreservesOtherContent(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - - content := fmt.Sprintf("before\n%s\nsource line\n%s\nafter\n", sentinelBegin, sentinelEnd) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) +func TestComputeAddSentinelIdempotent(t *testing.T) { + line := "source /home/user/.stripe/stripe-completion.zsh" + once := computeAddSentinel("", line) + twice := computeAddSentinel(once, line) + assert.Equal(t, once, twice, "applying computeAddSentinel twice should produce the same result") +} - err := removeSentinelBlock(configPath) - require.NoError(t, err) +// --------------------------------------------------------------------------- +// computeRemoveSentinel (pure function — no I/O) +// --------------------------------------------------------------------------- - data, err := os.ReadFile(configPath) - require.NoError(t, err) +func TestComputeRemoveSentinelOnlyBlock(t *testing.T) { + content := fmt.Sprintf("%s\nsource line\n%s\n", sentinelBegin, sentinelEnd) + result, found := computeRemoveSentinel(content) + require.True(t, found) + assert.Equal(t, "", result, "removing the only content should yield empty string") +} - result := string(data) +func TestComputeRemoveSentinelPreservesOtherContent(t *testing.T) { + content := fmt.Sprintf("before\n%s\nsource line\n%s\nafter\n", sentinelBegin, sentinelEnd) + result, found := computeRemoveSentinel(content) + require.True(t, found) assert.Contains(t, result, "before\n") assert.Contains(t, result, "after\n") assert.NotContains(t, result, sentinelBegin) - assert.NotContains(t, result, sentinelEnd) assert.NotContains(t, result, "source line") } -func TestRemoveSentinelBlockNoBlockPresent(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - - original := "export FOO=bar\n" - require.NoError(t, os.WriteFile(configPath, []byte(original), 0644)) - - err := removeSentinelBlock(configPath) - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - assert.Equal(t, original, string(data)) +func TestComputeRemoveSentinelNoBlockPresent(t *testing.T) { + _, found := computeRemoveSentinel("export FOO=bar\n") + assert.False(t, found) } -func TestRemoveSentinelBlockFileMissing(t *testing.T) { - err := removeSentinelBlock(filepath.Join(t.TempDir(), "nonexistent")) - assert.NoError(t, err) +func TestComputeRemoveSentinelEmptyContent(t *testing.T) { + _, found := computeRemoveSentinel("") + assert.False(t, found) } -func TestRemoveSentinelBlockOrphanedBeginOnly(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - +func TestComputeRemoveSentinelOrphanedBeginOnly(t *testing.T) { content := fmt.Sprintf("before\n%s\norphaned\nafter\n", sentinelBegin) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - - err := removeSentinelBlock(configPath) - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - assert.Equal(t, content, string(data)) + _, found := computeRemoveSentinel(content) + assert.False(t, found) } -func TestRemoveSentinelBlockOrphanedEndOnly(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - +func TestComputeRemoveSentinelOrphanedEndOnly(t *testing.T) { content := fmt.Sprintf("before\n%s\nafter\n", sentinelEnd) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - - err := removeSentinelBlock(configPath) - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - assert.Equal(t, content, string(data)) + _, found := computeRemoveSentinel(content) + assert.False(t, found) } -func TestRemoveSentinelBlockReversedMarkers(t *testing.T) { - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - - // End marker appears before begin — should be a no-op +func TestComputeRemoveSentinelReversedMarkers(t *testing.T) { content := fmt.Sprintf("before\n%s\norphaned\n%s\nafter\n", sentinelEnd, sentinelBegin) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - - err := removeSentinelBlock(configPath) - require.NoError(t, err) - - data, err := os.ReadFile(configPath) - require.NoError(t, err) - assert.Equal(t, content, string(data), "reversed markers should be left untouched") + _, found := computeRemoveSentinel(content) + assert.False(t, found, "reversed markers should not be treated as a valid block") } -func TestAddSentinelBlockReadPermissionDenied(t *testing.T) { - if runtime.GOOS == "windows" || os.Getuid() == 0 { - t.Skip("Cannot test Unix file permissions on Windows or as root") - } - - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - require.NoError(t, os.WriteFile(configPath, []byte("content"), 0644)) - require.NoError(t, os.Chmod(configPath, 0000)) - t.Cleanup(func() { os.Chmod(configPath, 0644) }) - - err := addSentinelBlock(configPath, "source line") - assert.Error(t, err) -} - -func TestAddSentinelBlockWritePermissionDenied(t *testing.T) { - if runtime.GOOS == "windows" || os.Getuid() == 0 { - t.Skip("Cannot test Unix file permissions on Windows or as root") - } - - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - require.NoError(t, os.WriteFile(configPath, []byte("existing\n"), 0644)) - require.NoError(t, os.Chmod(configPath, 0444)) - t.Cleanup(func() { os.Chmod(configPath, 0644) }) - - err := addSentinelBlock(configPath, "source line") - assert.Error(t, err) -} - -func TestRemoveSentinelBlockReadPermissionDenied(t *testing.T) { - if runtime.GOOS == "windows" || os.Getuid() == 0 { - t.Skip("Cannot test Unix file permissions on Windows or as root") - } - - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - content := fmt.Sprintf("%s\nline\n%s\n", sentinelBegin, sentinelEnd) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - require.NoError(t, os.Chmod(configPath, 0000)) - t.Cleanup(func() { os.Chmod(configPath, 0644) }) - - err := removeSentinelBlock(configPath) - assert.Error(t, err) -} - -func TestRemoveSentinelBlockWritePermissionDenied(t *testing.T) { - if runtime.GOOS == "windows" || os.Getuid() == 0 { - t.Skip("Cannot test Unix file permissions on Windows or as root") - } - - dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - content := fmt.Sprintf("%s\nline\n%s\n", sentinelBegin, sentinelEnd) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) - require.NoError(t, os.Chmod(configPath, 0444)) - t.Cleanup(func() { os.Chmod(configPath, 0644) }) +// --------------------------------------------------------------------------- +// readConfigFile +// --------------------------------------------------------------------------- - err := removeSentinelBlock(configPath) - assert.Error(t, err) +func TestReadConfigFileMissing(t *testing.T) { + content, perm, err := readConfigFile(filepath.Join(t.TempDir(), "nonexistent")) + require.NoError(t, err) + assert.Equal(t, "", content) + assert.Equal(t, os.FileMode(0644), perm) } -func TestAddSentinelBlockPreservesFilePermissions(t *testing.T) { +func TestReadConfigFilePreservesPermissions(t *testing.T) { if runtime.GOOS == "windows" || os.Getuid() == 0 { t.Skip("Cannot test Unix file permissions on Windows or as root") } dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - require.NoError(t, os.WriteFile(configPath, []byte("existing\n"), 0600)) - - err := addSentinelBlock(configPath, "source line") - require.NoError(t, err) + path := filepath.Join(dir, ".zshrc") + require.NoError(t, os.WriteFile(path, []byte("content\n"), 0600)) - info, err := os.Stat(configPath) + content, perm, err := readConfigFile(path) require.NoError(t, err) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file permissions should be preserved") + assert.Equal(t, "content\n", content) + assert.Equal(t, os.FileMode(0600), perm) } -func TestRemoveSentinelBlockPreservesFilePermissions(t *testing.T) { +func TestReadConfigFilePermissionDenied(t *testing.T) { if runtime.GOOS == "windows" || os.Getuid() == 0 { t.Skip("Cannot test Unix file permissions on Windows or as root") } dir := t.TempDir() - configPath := filepath.Join(dir, ".zshrc") - content := fmt.Sprintf("before\n%s\nline\n%s\nafter\n", sentinelBegin, sentinelEnd) - require.NoError(t, os.WriteFile(configPath, []byte(content), 0600)) + path := filepath.Join(dir, ".zshrc") + require.NoError(t, os.WriteFile(path, []byte("content"), 0644)) + require.NoError(t, os.Chmod(path, 0000)) + t.Cleanup(func() { os.Chmod(path, 0644) }) - err := removeSentinelBlock(configPath) - require.NoError(t, err) - - info, err := os.Stat(configPath) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file permissions should be preserved") + _, _, err := readConfigFile(path) + assert.Error(t, err) } // --------------------------------------------------------------------------- -// findManualRemnants +// findManualRemnants (from sentinel-block-management branch, preserved) // --------------------------------------------------------------------------- func TestFindManualRemnantsDetectsManualSourceLine(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - content := "export PATH=/usr/local/bin:$PATH\nsource ~/.stripe/stripe-completion.zsh\nalias ls='ls -G'\n" require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) @@ -506,9 +344,7 @@ func TestFindManualRemnantsDetectsManualSourceLine(t *testing.T) { func TestFindManualRemnantsDetectsDotSourceSyntax(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".bashrc") - - content := ". /some/custom/path/stripe-completion.bash\n" - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + require.NoError(t, os.WriteFile(configPath, []byte(". /some/custom/path/stripe-completion.bash\n"), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.bash") require.Len(t, remnants, 1) @@ -518,21 +354,16 @@ func TestFindManualRemnantsDetectsDotSourceSyntax(t *testing.T) { func TestFindManualRemnantsDetectsLineWithOtherCommands(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := "[ -f ~/.stripe/stripe-completion.zsh ] && source ~/.stripe/stripe-completion.zsh\n" - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + require.NoError(t, os.WriteFile(configPath, []byte("[ -f ~/.stripe/stripe-completion.zsh ] && source ~/.stripe/stripe-completion.zsh\n"), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") require.Len(t, remnants, 1) - assert.Equal(t, 1, remnants[0].lineNumber) } func TestFindManualRemnantsDetectsCustomPath(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := "source /opt/completions/stripe-completion.zsh\n" - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + require.NoError(t, os.WriteFile(configPath, []byte("source /opt/completions/stripe-completion.zsh\n"), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") require.Len(t, remnants, 1) @@ -541,9 +372,7 @@ func TestFindManualRemnantsDetectsCustomPath(t *testing.T) { func TestFindManualRemnantsIgnoresSentinelBlock(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := fmt.Sprintf("before\n%s\nsource ~/.stripe/stripe-completion.zsh\n%s\nafter\n", - sentinelBegin, sentinelEnd) + content := fmt.Sprintf("before\n%s\nsource ~/.stripe/stripe-completion.zsh\n%s\nafter\n", sentinelBegin, sentinelEnd) require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") @@ -553,9 +382,7 @@ func TestFindManualRemnantsIgnoresSentinelBlock(t *testing.T) { func TestFindManualRemnantsIgnoresComments(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := "# source ~/.stripe/stripe-completion.zsh\n" - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + require.NoError(t, os.WriteFile(configPath, []byte("# source ~/.stripe/stripe-completion.zsh\n"), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") assert.Empty(t, remnants) @@ -569,9 +396,7 @@ func TestFindManualRemnantsReturnsNilForMissingFile(t *testing.T) { func TestFindManualRemnantsNoMatchReturnsNil(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := "export PATH=/usr/local/bin:$PATH\nalias ls='ls -G'\n" - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + require.NoError(t, os.WriteFile(configPath, []byte("export PATH=/usr/local/bin:$PATH\n"), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") assert.Nil(t, remnants) @@ -580,9 +405,7 @@ func TestFindManualRemnantsNoMatchReturnsNil(t *testing.T) { func TestFindManualRemnantsMultipleMatches(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := "source ~/.stripe/stripe-completion.zsh\nexport FOO=bar\n. /other/stripe-completion.zsh\n" - require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) + require.NoError(t, os.WriteFile(configPath, []byte("source ~/.stripe/stripe-completion.zsh\nexport FOO=bar\n. /other/stripe-completion.zsh\n"), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") require.Len(t, remnants, 2) @@ -593,9 +416,7 @@ func TestFindManualRemnantsMultipleMatches(t *testing.T) { func TestFindManualRemnantsOutsideSentinelWithManualBefore(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := fmt.Sprintf("source ~/my/stripe-completion.zsh\n%s\nsource ~/.stripe/stripe-completion.zsh\n%s\n", - sentinelBegin, sentinelEnd) + content := fmt.Sprintf("source ~/my/stripe-completion.zsh\n%s\nsource ~/.stripe/stripe-completion.zsh\n%s\n", sentinelBegin, sentinelEnd) require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") @@ -606,12 +427,414 @@ func TestFindManualRemnantsOutsideSentinelWithManualBefore(t *testing.T) { func TestFindManualRemnantsOutsideSentinelWithManualAfter(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, ".zshrc") - - content := fmt.Sprintf("%s\nsource ~/.stripe/stripe-completion.zsh\n%s\nsource ~/custom/stripe-completion.zsh\n", - sentinelBegin, sentinelEnd) + content := fmt.Sprintf("%s\nsource ~/.stripe/stripe-completion.zsh\n%s\nsource ~/custom/stripe-completion.zsh\n", sentinelBegin, sentinelEnd) require.NoError(t, os.WriteFile(configPath, []byte(content), 0644)) remnants := findManualRemnants(configPath, "stripe-completion.zsh") require.Len(t, remnants, 1) assert.Equal(t, 4, remnants[0].lineNumber) } + +// --------------------------------------------------------------------------- +// Test helpers for install/uninstall +// --------------------------------------------------------------------------- + +func fakeHomeDir(dir string) homeDirFunc { + return func() (string, error) { return dir, nil } +} + +func failingHomeDir() homeDirFunc { + return func() (string, error) { return "", fmt.Errorf("no home directory") } +} + +// disableColors suppresses ANSI color output during tests. +func disableColors(t *testing.T) { + t.Helper() + ansi.DisableColors = true + t.Cleanup(func() { ansi.DisableColors = false }) +} + +// alwaysConfirm overrides installConfirmFn and uninstallConfirmFn to always +// return true (auto-accept). Restores originals on cleanup. +func alwaysConfirm(t *testing.T) { + t.Helper() + disableColors(t) + origInstall := installConfirmFn + origUninstall := uninstallConfirmFn + accept := func(_ string) bool { return true } + installConfirmFn = accept + uninstallConfirmFn = accept + t.Cleanup(func() { + installConfirmFn = origInstall + uninstallConfirmFn = origUninstall + }) +} + +// neverConfirm overrides installConfirmFn and uninstallConfirmFn to always +// return false (auto-decline). +func neverConfirm(t *testing.T) { + t.Helper() + disableColors(t) + origInstall := installConfirmFn + origUninstall := uninstallConfirmFn + decline := func(_ string) bool { return false } + installConfirmFn = decline + uninstallConfirmFn = decline + t.Cleanup(func() { + installConfirmFn = origInstall + uninstallConfirmFn = origUninstall + }) +} + +// --------------------------------------------------------------------------- +// generateCompletionScript +// --------------------------------------------------------------------------- + +func TestGenerateCompletionScriptBash(t *testing.T) { + var buf bytes.Buffer + err := generateCompletionScript("bash", &buf) + require.NoError(t, err) + assert.Contains(t, buf.String(), "__complete") +} + +func TestGenerateCompletionScriptZsh(t *testing.T) { + var buf bytes.Buffer + err := generateCompletionScript("zsh", &buf) + require.NoError(t, err) + assert.NotEmpty(t, buf.String()) +} + +func TestGenerateCompletionScriptFish(t *testing.T) { + var buf bytes.Buffer + err := generateCompletionScript("fish", &buf) + require.NoError(t, err) + assert.NotEmpty(t, buf.String()) +} + +func TestGenerateCompletionScriptUnsupported(t *testing.T) { + var buf bytes.Buffer + err := generateCompletionScript("powershell", &buf) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported shell") +} + +// --------------------------------------------------------------------------- +// installCompletion +// --------------------------------------------------------------------------- + +func TestInstallCompletionZsh(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + err := installCompletion("zsh", fakeHomeDir(home)) + require.NoError(t, err) + + scriptPath := filepath.Join(home, ".stripe", "stripe-completion.zsh") + data, err := os.ReadFile(scriptPath) + require.NoError(t, err) + assert.NotEmpty(t, data) + + configPath := filepath.Join(home, ".zshrc") + configData, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(configData) + assert.Contains(t, content, sentinelBegin) + assert.Contains(t, content, fmt.Sprintf("source \"%s\"", scriptPath)) + assert.Contains(t, content, sentinelEnd) +} + +func TestInstallCompletionBash(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + err := installCompletion("bash", fakeHomeDir(home)) + require.NoError(t, err) + + scriptPath := filepath.Join(home, ".stripe", "stripe-completion.bash") + data, err := os.ReadFile(scriptPath) + require.NoError(t, err) + assert.NotEmpty(t, data) + + var configPath string + if runtime.GOOS == "darwin" { + configPath = filepath.Join(home, ".bash_profile") + } else { + configPath = filepath.Join(home, ".bashrc") + } + configData, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(configData), sentinelBegin) + assert.Contains(t, string(configData), fmt.Sprintf("source \"%s\"", scriptPath)) +} + +func TestInstallCompletionFish(t *testing.T) { + home := t.TempDir() + err := installCompletion("fish", fakeHomeDir(home)) + require.NoError(t, err) + + scriptPath := filepath.Join(home, ".config", "fish", "completions", "stripe.fish") + data, err := os.ReadFile(scriptPath) + require.NoError(t, err) + assert.NotEmpty(t, data) + + // Fish should not modify any shell config files + for _, f := range []string{".zshrc", ".bashrc", ".bash_profile"} { + _, err := os.Stat(filepath.Join(home, f)) + assert.True(t, os.IsNotExist(err), "fish install should not create %s", f) + } +} + +func TestInstallCompletionIdempotent(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + + configPath := filepath.Join(home, ".zshrc") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(data) + assert.Equal(t, 1, strings.Count(content, sentinelBegin), "sentinel begin should appear exactly once") + assert.Equal(t, 1, strings.Count(content, sentinelEnd), "sentinel end should appear exactly once") +} + +func TestInstallCompletionHomeDirError(t *testing.T) { + err := installCompletion("zsh", failingHomeDir()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not determine home directory") +} + +func TestInstallCompletionWritePermissionDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("Cannot test permission errors as root") + } + + home := t.TempDir() + scriptDir := filepath.Join(home, ".stripe") + require.NoError(t, os.MkdirAll(scriptDir, 0755)) + require.NoError(t, os.Chmod(scriptDir, 0555)) + t.Cleanup(func() { os.Chmod(scriptDir, 0755) }) + + err := installCompletion("zsh", fakeHomeDir(home)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not write completion script") +} + +func TestInstallUnsupportedShell(t *testing.T) { + cc := newCompletionCmd() + cc.cmd.SetArgs([]string{"--install", "--shell", "powershell"}) + + err := cc.cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported shell") + assert.Contains(t, err.Error(), "powershell") +} + +func TestInstallMutuallyExclusiveFlags(t *testing.T) { + cc := newCompletionCmd() + cc.cmd.SetArgs([]string{"--install", "--uninstall"}) + + err := cc.cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "if any flags in the group [install uninstall] are set none of the others can be") +} + +// --------------------------------------------------------------------------- +// confirmation prompt +// --------------------------------------------------------------------------- + +func TestInstallDeclinedDoesNotModifyConfig(t *testing.T) { + neverConfirm(t) + home := t.TempDir() + + err := installCompletion("zsh", fakeHomeDir(home)) + require.NoError(t, err) // declining is not an error + + // Script file should still be written (it's written before the prompt) + scriptPath := filepath.Join(home, ".stripe", "stripe-completion.zsh") + _, err = os.Stat(scriptPath) + assert.NoError(t, err, "script file should exist even when config change is declined") + + // Config file should NOT have been created or modified + configPath := filepath.Join(home, ".zshrc") + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err), "config file should not exist when user declines") +} + +func TestUninstallDeclinedDoesNotModifyConfig(t *testing.T) { + // First install with confirmation + alwaysConfirm(t) + home := t.TempDir() + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + + // Now decline the uninstall + neverConfirm(t) + require.NoError(t, uninstallCompletion("zsh", fakeHomeDir(home))) + + // Script file should be removed (removed before the prompt) + scriptPath := filepath.Join(home, ".stripe", "stripe-completion.zsh") + _, err := os.Stat(scriptPath) + assert.True(t, os.IsNotExist(err), "script file should be removed regardless of config prompt") + + // Config file should still have the sentinel block + configPath := filepath.Join(home, ".zshrc") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), sentinelBegin, "config should be unchanged when user declines") +} + +func TestInstallFishSkipsConfirmation(t *testing.T) { + // Use neverConfirm to prove fish doesn't hit the prompt + neverConfirm(t) + home := t.TempDir() + + err := installCompletion("fish", fakeHomeDir(home)) + require.NoError(t, err) + + scriptPath := filepath.Join(home, ".config", "fish", "completions", "stripe.fish") + _, err = os.Stat(scriptPath) + assert.NoError(t, err, "fish install should succeed without confirmation") +} + +// --------------------------------------------------------------------------- +// uninstallCompletion +// --------------------------------------------------------------------------- + +func TestUninstallCompletion(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + + scriptPath := filepath.Join(home, ".stripe", "stripe-completion.zsh") + _, err := os.Stat(scriptPath) + require.NoError(t, err) + + require.NoError(t, uninstallCompletion("zsh", fakeHomeDir(home))) + + _, err = os.Stat(scriptPath) + assert.True(t, os.IsNotExist(err)) + + configPath := filepath.Join(home, ".zshrc") + data, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(data) + assert.NotContains(t, content, sentinelBegin) + assert.NotContains(t, content, sentinelEnd) +} + +func TestUninstallCompletionFish(t *testing.T) { + home := t.TempDir() + require.NoError(t, installCompletion("fish", fakeHomeDir(home))) + + scriptPath := filepath.Join(home, ".config", "fish", "completions", "stripe.fish") + _, err := os.Stat(scriptPath) + require.NoError(t, err) + + require.NoError(t, uninstallCompletion("fish", fakeHomeDir(home))) + _, err = os.Stat(scriptPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestUninstallWhenNotInstalled(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + err := uninstallCompletion("zsh", fakeHomeDir(home)) + assert.NoError(t, err) +} + +func TestUninstallPreservesExistingConfigContent(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + configPath := filepath.Join(home, ".zshrc") + existing := "export PATH=/usr/local/bin:$PATH\nalias ll='ls -la'\n" + require.NoError(t, os.WriteFile(configPath, []byte(existing), 0644)) + + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + require.NoError(t, uninstallCompletion("zsh", fakeHomeDir(home))) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, "export PATH=/usr/local/bin:$PATH") + assert.Contains(t, content, "alias ll='ls -la'") + assert.NotContains(t, content, sentinelBegin) +} + +func TestUninstallCompletionHomeDirError(t *testing.T) { + err := uninstallCompletion("zsh", failingHomeDir()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not determine home directory") +} + +// --------------------------------------------------------------------------- +// install/uninstall with manual remnant warnings +// --------------------------------------------------------------------------- + +func TestInstallWarnsAboutManualRemnants(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + configPath := filepath.Join(home, ".zshrc") + existing := "export PATH=/usr/local/bin:$PATH\nsource ~/.stripe/stripe-completion.zsh\nalias ll='ls -la'\n" + require.NoError(t, os.WriteFile(configPath, []byte(existing), 0644)) + + err := installCompletion("zsh", fakeHomeDir(home)) + require.NoError(t, err) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(data) + assert.Contains(t, content, sentinelBegin) + assert.Contains(t, content, "source ~/.stripe/stripe-completion.zsh") + + remnants := findManualRemnants(configPath, "stripe-completion.zsh") + require.Len(t, remnants, 1, "the pre-existing manual line should be detected as a remnant") + assert.Equal(t, 2, remnants[0].lineNumber) +} + +func TestUninstallWarnsAboutManualRemnants(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + configPath := filepath.Join(home, ".zshrc") + require.NoError(t, os.WriteFile(configPath, []byte("source ~/.stripe/stripe-completion.zsh\n"), 0644)) + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + require.NoError(t, uninstallCompletion("zsh", fakeHomeDir(home))) + + data, err := os.ReadFile(configPath) + require.NoError(t, err) + content := string(data) + assert.NotContains(t, content, sentinelBegin) + assert.Contains(t, content, "source ~/.stripe/stripe-completion.zsh") + + remnants := findManualRemnants(configPath, "stripe-completion.zsh") + require.Len(t, remnants, 1, "the pre-existing manual line should survive uninstall") +} + +func TestInstallNoWarningWhenClean(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + + configPath := filepath.Join(home, ".zshrc") + remnants := findManualRemnants(configPath, "stripe-completion.zsh") + assert.Empty(t, remnants) +} + +func TestUninstallNoWarningWhenClean(t *testing.T) { + alwaysConfirm(t) + home := t.TempDir() + require.NoError(t, installCompletion("zsh", fakeHomeDir(home))) + require.NoError(t, uninstallCompletion("zsh", fakeHomeDir(home))) + + configPath := filepath.Join(home, ".zshrc") + remnants := findManualRemnants(configPath, "stripe-completion.zsh") + assert.Empty(t, remnants) +} + +// --------------------------------------------------------------------------- +// completionScriptFilename +// --------------------------------------------------------------------------- + +func TestCompletionScriptFilename(t *testing.T) { + assert.Equal(t, "stripe-completion.bash", completionScriptFilename("bash")) + assert.Equal(t, "stripe-completion.zsh", completionScriptFilename("zsh")) + assert.Equal(t, "stripe.fish", completionScriptFilename("fish")) + assert.Equal(t, "", completionScriptFilename("powershell")) +}