diff --git a/cmd/clean.go b/cmd/clean.go index 9dada0dd6..3ae45a9d2 100644 --- a/cmd/clean.go +++ b/cmd/clean.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "fmt" + "io" "os" "os/exec" "path" @@ -14,11 +15,24 @@ import ( "github.com/bruin-data/bruin/pkg/git" "github.com/bruin-data/bruin/pkg/telemetry" "github.com/bruin-data/bruin/pkg/user" + "github.com/fatih/color" "github.com/pkg/errors" "github.com/spf13/afero" "github.com/urfave/cli/v3" ) +type colorPrinter struct { + c *color.Color +} + +func (p *colorPrinter) Printf(format string, args ...interface{}) { + p.c.Printf(format, args...) // ignore return values +} + +func (p *colorPrinter) Println(args ...interface{}) { + p.c.Println(args...) +} + func CleanCmd() *cli.Command { return &cli.Command{ Name: "clean", @@ -37,71 +51,118 @@ func CleanCmd() *cli.Command { inputPath = "." } - r := CleanCommand{ - infoPrinter: infoPrinter, - errorPrinter: errorPrinter, + r := NewCleanCommand( + user.NewConfigManager(afero.NewOsFs()), // cm + &git.RepoFinder{}, // gitFinder + afero.NewOsFs(), // fs + &colorPrinter{c: color.New(color.FgGreen)}, // infoPrinter + &colorPrinter{c: color.New(color.FgRed)}, // errorPrinter + ) + + err := r.Run(inputPath, c.Bool("uv-cache")) + if err != nil { + return cli.Exit("", 1) } - - return r.Run(inputPath, c.Bool("uv-cache")) + return nil }, Before: telemetry.BeforeCommand, After: telemetry.AfterCommand, } } +type ConfigManager interface { + EnsureAndGetBruinHomeDir() (string, error) + RecreateHomeDir() error +} + +type GitFinder interface { + Repo(path string) (*git.Repo, error) +} + +type Printer interface { + Printf(format string, a ...interface{}) + Println(a ...interface{}) +} + type CleanCommand struct { - infoPrinter printer - errorPrinter printer + cm ConfigManager + gitFinder GitFinder + fs afero.Fs + infoPrinter Printer + errorPrinter Printer +} + +func NewCleanCommand(cm ConfigManager, gitFinder GitFinder, fs afero.Fs, info Printer, errPrinter Printer) *CleanCommand { + return &CleanCommand{ + cm: cm, + gitFinder: gitFinder, + fs: fs, + infoPrinter: info, + errorPrinter: errPrinter, + } } func (r *CleanCommand) Run(inputPath string, cleanUvCache bool) error { - cm := user.NewConfigManager(afero.NewOsFs()) - bruinHomeDirAbsPath, err := cm.EnsureAndGetBruinHomeDir() + bruinHomeDir, err := r.cm.EnsureAndGetBruinHomeDir() if err != nil { return errors.Wrap(err, "failed to get bruin home directory") } - // Clean uv caches if requested if cleanUvCache { - if err := r.cleanUvCache(bruinHomeDirAbsPath); err != nil { + if err := r.cleanUvCache(bruinHomeDir); err != nil { return err } } - err = cm.RecreateHomeDir() - if err != nil { + if err := r.cm.RecreateHomeDir(); err != nil { return errors.Wrap(err, "failed to recreate the home directory") } - repoRoot, err := git.FindRepoFromPath(inputPath) + repoRoot, err := r.gitFinder.Repo(inputPath) if err != nil { - errorPrinter.Printf("Failed to find the git repository root: %v\n", err) - return cli.Exit("", 1) + r.errorPrinter.Printf("Failed to find the git repository root: %v\n", err) + return errors.Wrap(err, "failed to find the git repository root") } logsFolder := path.Join(repoRoot.Path, LogsFolder) - contents, err := filepath.Glob(logsFolder + "/*.log") + // Check if logs folder exists + exists, err := afero.Exists(r.fs, logsFolder) if err != nil { - return errors.Wrap(err, "failed to find the logs folder") + return errors.Wrap(err, "failed to check logs folder") } - if len(contents) == 0 { - infoPrinter.Println("No log files found, nothing to clean up...") + if !exists { + r.infoPrinter.Println("No log files found, nothing to clean up...") return nil } - infoPrinter.Printf("Found %d log files, cleaning them up...\n", len(contents)) + // Read directory contents + entries, err := afero.ReadDir(r.fs, logsFolder) + if err != nil { + return errors.Wrap(err, "failed to read logs folder") + } - for _, f := range contents { - err := os.Remove(f) - if err != nil { - return errors.Wrapf(err, "failed to remove file: %s", f) + // Filter for .log files + var logFiles []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".log") { + logFiles = append(logFiles, path.Join(logsFolder, entry.Name())) } } - infoPrinter.Printf("Successfully removed %d log files.\n", len(contents)) + if len(logFiles) == 0 { + r.infoPrinter.Println("No log files found, nothing to clean up...") + return nil + } + r.infoPrinter.Printf("Found %d log files, cleaning them up...\n", len(logFiles)) + for _, f := range logFiles { + if err := r.fs.Remove(f); err != nil { + return errors.Wrapf(err, "failed to remove file: %s", f) + } + } + r.infoPrinter.Printf("Successfully removed %d log files.\n", len(logFiles)) return nil } @@ -116,7 +177,7 @@ func (r *CleanCommand) cleanUvCache(bruinHomeDirAbsPath string) error { // Check if uv binary exists if _, err := os.Stat(uvBinaryPath); os.IsNotExist(err) { - infoPrinter.Println("UV is not installed yet. Nothing to clean.") + r.infoPrinter.Println("UV is not installed yet. Nothing to clean.") return nil } @@ -127,12 +188,12 @@ func (r *CleanCommand) cleanUvCache(bruinHomeDirAbsPath string) error { } // Prompt user for confirmation - if !r.confirmUvCacheClean() { - infoPrinter.Println("UV cache cleaning cancelled by user.") + if !r.confirmUvCacheClean(os.Stdin) { + r.infoPrinter.Println("UV cache cleaning cancelled by user.") return nil } - infoPrinter.Println("Cleaning uv caches...") + r.infoPrinter.Println("Cleaning uv caches...") cleanCmd := exec.Command(uvBinaryPath, "cache", "clean") output, err := cleanCmd.CombinedOutput() @@ -140,17 +201,17 @@ func (r *CleanCommand) cleanUvCache(bruinHomeDirAbsPath string) error { return errors.Wrapf(err, "failed to clean uv cache: %s", string(output)) } - infoPrinter.Println("Successfully cleaned uv caches.") + r.infoPrinter.Println("Successfully cleaned uv caches.") return nil } -func (r *CleanCommand) confirmUvCacheClean() bool { - reader := bufio.NewReader(os.Stdin) +func (r *CleanCommand) confirmUvCacheClean(reader io.Reader) bool { + bufReader := bufio.NewReader(reader) fmt.Print("Are you sure you want to clean uv cache? (y/N): ") - response, err := reader.ReadString('\n') + response, err := bufReader.ReadString('\n') if err != nil { - errorPrinter.Printf("Error reading input: %v\n", err) + r.errorPrinter.Printf("Error reading input: %v\n", err) return false } diff --git a/cmd/clean_test.go b/cmd/clean_test.go new file mode 100644 index 000000000..c1bea2d15 --- /dev/null +++ b/cmd/clean_test.go @@ -0,0 +1,295 @@ +package cmd + +import ( + "errors" + "fmt" + "path" + "strings" + "testing" + "testing/iotest" + + "github.com/bruin-data/bruin/pkg/git" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testRepoRoot = "/test-repo" + +// --- Mock ConfigManager ---. +type mockConfigManager struct { + bruinHomeDir string + homeDirErr error + recreateErr error +} + +func (m *mockConfigManager) EnsureAndGetBruinHomeDir() (string, error) { + return m.bruinHomeDir, m.homeDirErr +} + +func (m *mockConfigManager) RecreateHomeDir() error { + return m.recreateErr +} + +// --- Mock GitFinder ---. +type mockGitFinder struct { + repo *git.Repo + err error +} + +func (m *mockGitFinder) Repo(path string) (*git.Repo, error) { + return m.repo, m.err +} + +// --- Mock filesystem that fails on Remove ---. +type mockFailingFs struct { + afero.Fs + removeErr error +} + +func (m *mockFailingFs) Remove(name string) error { + return m.removeErr +} + +// --- Mock printer to capture output ---. +type mockOutputPrinter struct { + output *[]string +} + +func (m *mockOutputPrinter) Printf(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + *m.output = append(*m.output, msg) +} + +func (m *mockOutputPrinter) Println(args ...interface{}) { + msg := fmt.Sprint(args...) + *m.output = append(*m.output, msg) +} + +func TestCleanCommand_Run(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputPath string + cleanUvCache bool + setupMocks func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) + expectedErr string + expectedOutput []string + }{ + { + name: "config manager error - failed to get bruin home dir", + inputPath: ".", + setupMocks: func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) { + return afero.NewMemMapFs(), &mockConfigManager{ + homeDirErr: errors.New("failed to get bruin home directory"), + }, &mockGitFinder{}, "." + }, + expectedErr: "failed to get bruin home directory", + }, + { + name: "config manager error - failed to recreate home dir", + inputPath: ".", + setupMocks: func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) { + fs := afero.NewMemMapFs() + repoRoot := testRepoRoot + require.NoError(t, fs.MkdirAll(path.Join(repoRoot, LogsFolder), 0o755)) + return fs, &mockConfigManager{ + bruinHomeDir: "/test-bruin-home", + recreateErr: errors.New("failed to recreate the home directory"), + }, &mockGitFinder{}, repoRoot + }, + expectedErr: "failed to recreate the home directory", + }, + { + name: "git repo not found", + inputPath: "/nonexistent-path", + setupMocks: func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) { + return afero.NewMemMapFs(), &mockConfigManager{ + bruinHomeDir: "/test-bruin-home", + }, &mockGitFinder{ + err: errors.New("no git repository found"), + }, "/nonexistent-path" + }, + expectedErr: "failed to find the git repository root", + }, + { + name: "no log files found", + inputPath: ".", + setupMocks: func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) { + fs := afero.NewMemMapFs() + repoRoot := testRepoRoot + require.NoError(t, fs.MkdirAll(path.Join(repoRoot, LogsFolder), 0o755)) + return fs, &mockConfigManager{ + bruinHomeDir: "/test-bruin-home", + }, &mockGitFinder{ + repo: &git.Repo{Path: repoRoot}, + }, repoRoot + }, + expectedOutput: []string{"No log files found, nothing to clean up..."}, + }, + { + name: "successful cleanup with log files", + inputPath: ".", + setupMocks: func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) { + fs := afero.NewMemMapFs() + repoRoot := testRepoRoot + logsFolder := path.Join(repoRoot, LogsFolder) + require.NoError(t, fs.MkdirAll(logsFolder, 0o755)) + logFiles := []string{"test1.log", "test2.log", "test3.log"} + for _, f := range logFiles { + file, _ := fs.Create(path.Join(logsFolder, f)) + _, err := file.WriteString("test log content") + require.NoError(t, err) + file.Close() + } + return fs, &mockConfigManager{ + bruinHomeDir: "/test-bruin-home", + }, &mockGitFinder{ + repo: &git.Repo{Path: repoRoot}, + }, repoRoot + }, + expectedOutput: []string{ + "Found 3 log files, cleaning them up...\n", + "Successfully removed 3 log files.\n", + }, + }, + { + name: "file removal error", + inputPath: ".", + setupMocks: func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) { + fs := &mockFailingFs{ + Fs: afero.NewMemMapFs(), + removeErr: errors.New("permission denied"), + } + repoRoot := testRepoRoot + logsFolder := path.Join(repoRoot, LogsFolder) + require.NoError(t, fs.MkdirAll(logsFolder, 0o755)) + file, _ := fs.Create(path.Join(logsFolder, "test1.log")) + _, err := file.WriteString("test log content") + require.NoError(t, err) + file.Close() + return fs, &mockConfigManager{ + bruinHomeDir: "/test-bruin-home", + }, &mockGitFinder{ + repo: &git.Repo{Path: repoRoot}, + }, repoRoot + }, + expectedErr: "failed to remove file", + }, + { + name: "successful cleanup with uv cache", + inputPath: ".", + cleanUvCache: true, + setupMocks: func(t *testing.T) (afero.Fs, *mockConfigManager, *mockGitFinder, string) { + fs := afero.NewMemMapFs() + repoRoot := testRepoRoot + require.NoError(t, fs.MkdirAll(path.Join(repoRoot, LogsFolder), 0o755)) + return fs, &mockConfigManager{ + bruinHomeDir: "/test-bruin-home", + }, &mockGitFinder{ + repo: &git.Repo{Path: repoRoot}, + }, repoRoot + }, + expectedOutput: []string{"No log files found, nothing to clean up..."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fs, mockCM, mockGF, repoRoot := tt.setupMocks(t) + + output := []string{} + mockPrinter := &mockOutputPrinter{output: &output} + cmd := NewCleanCommand(mockCM, mockGF, fs, mockPrinter, mockPrinter) + + err := cmd.Run(repoRoot, tt.cleanUvCache) + + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + } else { + require.NoError(t, err) + for _, expected := range tt.expectedOutput { + assert.Contains(t, output, expected) + } + } + }) + } +} + +func TestCleanCommand_cleanUvCache(t *testing.T) { + t.Parallel() + + // Test case: uv binary does not exist + t.Run("uv binary does not exist", func(t *testing.T) { + t.Parallel() + + // Create a temporary directory that doesn't contain uv + tempDir := t.TempDir() + output := []string{} + printer := &mockOutputPrinter{output: &output} + + cmd := &CleanCommand{ + infoPrinter: printer, + errorPrinter: printer, + } + + err := cmd.cleanUvCache(tempDir) + + require.NoError(t, err) + assert.Contains(t, *printer.output, "UV is not installed yet. Nothing to clean.") + }) +} + +func TestCleanCommand_confirmUvCacheClean(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expectedResult bool + }{ + {"user confirms with 'y'", "y\n", true}, + {"user confirms with 'yes'", "yes\n", true}, + {"user confirms with 'Y'", "Y\n", true}, + {"user cancels with 'n'", "n\n", false}, + {"user cancels with 'no'", "no\n", false}, + {"user cancels with empty input", "\n", false}, + {"user cancels with random text", "maybe\n", false}, + {"user cancels with whitespace", " n \n", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + output := []string{} + cmd := &CleanCommand{ + errorPrinter: &mockOutputPrinter{output: &output}, + } + + reader := strings.NewReader(tt.input) + result := cmd.confirmUvCacheClean(reader) + + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestCleanCommand_confirmUvCacheClean_Error(t *testing.T) { + t.Parallel() + + output := []string{} + cmd := &CleanCommand{ + errorPrinter: &mockOutputPrinter{output: &output}, + } + + // Broken reader that always errors + brokenReader := iotest.ErrReader(errors.New("simulated read error")) + + result := cmd.confirmUvCacheClean(brokenReader) + + assert.False(t, result) + assert.Len(t, output, 1) + assert.Contains(t, output[0], "Error reading input") +}