From 8a29b7fe4360f357845ff90760a574393f7916e2 Mon Sep 17 00:00:00 2001 From: Yar Kravtsov Date: Tue, 29 Jul 2025 08:56:33 +0300 Subject: [PATCH] feat(add): implement dry-run mode and enhance output formatting --- cmd/add.go | 75 ++++++++++++- cmd/root.go | 20 ++-- cmd/root_test.go | 219 ++++++++++++++++++++++++++++++++++++++ internal/core/lnk.go | 61 +++++++++++ internal/core/lnk_test.go | 94 ++++++++++++++++ 5 files changed, 456 insertions(+), 13 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index fdbebd6..9df4741 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -11,17 +11,61 @@ func newAddCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add ...", Short: "✨ Add files to lnk management", - Long: "Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.", + Long: `Moves files to the lnk repository and creates symlinks in their place. Supports multiple files. + +Examples: + lnk add ~/.bashrc ~/.vimrc # Add multiple files at once + lnk add --recursive ~/.config/nvim # Add directory contents individually + lnk add --dry-run ~/.gitconfig # Preview what would be added + lnk add --host work ~/.ssh/config # Add host-specific configuration + +The --recursive flag processes directory contents individually instead of treating +the directory as a single unit. This is useful for configuration directories where +you want each file managed separately. + +The --dry-run flag shows you exactly what files would be added without making any +changes to your system - perfect for verification before bulk operations.`, Args: cobra.MinimumNArgs(1), SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { host, _ := cmd.Flags().GetString("host") recursive, _ := cmd.Flags().GetBool("recursive") + dryRun, _ := cmd.Flags().GetBool("dry-run") lnk := core.NewLnk(core.WithHost(host)) + // Handle dry-run mode + if dryRun { + files, err := lnk.PreviewAdd(args, recursive) + if err != nil { + return err + } + + // Display preview output + if recursive { + printf(cmd, "🔍 \033[1mWould add %d files recursively:\033[0m\n", len(files)) + } else { + printf(cmd, "🔍 \033[1mWould add %d files:\033[0m\n", len(files)) + } + + // List files that would be added + for _, file := range files { + basename := filepath.Base(file) + printf(cmd, " 📄 \033[90m%s\033[0m\n", basename) + } + + printf(cmd, "\n💡 \033[33mTo proceed:\033[0m run without --dry-run flag\n") + return nil + } + // Handle recursive mode if recursive { + // Get preview to count files first for better output + previewFiles, err := lnk.PreviewAdd(args, recursive) + if err != nil { + return err + } + // Create progress callback for CLI display progressCallback := func(current, total int, currentFile string) { printf(cmd, "\r⏳ Processing %d/%d: %s", current, total, currentFile) @@ -33,6 +77,9 @@ func newAddCmd() *cobra.Command { // Clear progress line and show completion printf(cmd, "\r") + + // Store processed file count for display + args = previewFiles // Replace args with actual files for display } else { // Use appropriate method based on number of files if len(args) == 1 { @@ -50,11 +97,30 @@ func newAddCmd() *cobra.Command { // Display results if recursive { - // Recursive mode - show different message + // Recursive mode - show enhanced message with count if host != "" { - printf(cmd, "✨ \033[1mAdded files recursively to lnk (host: %s)\033[0m\n", host) + printf(cmd, "✨ \033[1mAdded %d files recursively to lnk (host: %s)\033[0m\n", len(args), host) } else { - printf(cmd, "✨ \033[1mAdded files recursively to lnk\033[0m\n") + printf(cmd, "✨ \033[1mAdded %d files recursively to lnk\033[0m\n", len(args)) + } + + // Show some of the files that were added (limit to first few for readability) + filesToShow := len(args) + if filesToShow > 5 { + filesToShow = 5 + } + + for i := 0; i < filesToShow; i++ { + basename := filepath.Base(args[i]) + if host != "" { + printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/...\033[0m\n", basename, host) + } else { + printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/...\033[0m\n", basename) + } + } + + if len(args) > 5 { + printf(cmd, " \033[90m... and %d more files\033[0m\n", len(args)-5) } } else if len(args) == 1 { // Single file - maintain existing output format for backward compatibility @@ -93,5 +159,6 @@ func newAddCmd() *cobra.Command { cmd.Flags().StringP("host", "H", "", "Manage file for specific host (default: common configuration)") cmd.Flags().BoolP("recursive", "r", false, "Add directory contents individually instead of the directory as a whole") + cmd.Flags().BoolP("dry-run", "n", false, "Show what would be added without making changes") return cmd } diff --git a/cmd/root.go b/cmd/root.go index fffd9c2..a5a5257 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,17 +20,19 @@ func NewRootCommand() *cobra.Command { Long: `🔗 Lnk - Git-native dotfiles management that doesn't suck. Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal. -Supports both common configurations and host-specific setups. +Supports both common configurations, host-specific setups, and bulk operations for multiple files. ✨ Examples: - lnk init # Fresh start - lnk init -r # Clone existing dotfiles (runs bootstrap automatically) - lnk add ~/.vimrc ~/.bashrc # Start managing common files - lnk add --host work ~/.ssh/config # Manage host-specific files - lnk list --all # Show all configurations - lnk pull --host work # Pull host-specific changes - lnk push "setup complete" # Sync to remote - lnk bootstrap # Run bootstrap script manually + lnk init # Fresh start + lnk init -r # Clone existing dotfiles (runs bootstrap automatically) + lnk add ~/.vimrc ~/.bashrc # Start managing common files + lnk add --recursive ~/.config/nvim # Add directory contents individually + lnk add --dry-run ~/.gitconfig # Preview changes without applying + lnk add --host work ~/.ssh/config # Manage host-specific files + lnk list --all # Show all configurations + lnk pull --host work # Pull host-specific changes + lnk push "setup complete" # Sync to remote + lnk bootstrap # Run bootstrap script manually 🚀 Bootstrap Support: Automatically runs bootstrap.sh when cloning a repository. diff --git a/cmd/root_test.go b/cmd/root_test.go index e738a07..d4c0a37 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "fmt" "os" "os/exec" "path/filepath" @@ -1119,6 +1120,224 @@ func (suite *CLITestSuite) TestAddCommandRecursiveMultipleDirs() { suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "dir2 should not be a symlink") } +// Task 3.1: Dry-Run Mode Tests + +func (suite *CLITestSuite) TestDryRunFlag() { + // Initialize repository + err := suite.runCommand("init") + suite.NoError(err) + initOutput := suite.stdout.String() + suite.Contains(initOutput, "Initialized") + suite.stdout.Reset() + + // Create test files + testFile1 := filepath.Join(suite.tempDir, "test1.txt") + testFile2 := filepath.Join(suite.tempDir, "test2.txt") + suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644)) + suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644)) + + // Run add with dry-run flag (should not exist yet) + err = suite.runCommand("add", "--dry-run", testFile1, testFile2) + suite.NoError(err, "Dry-run command should succeed") + output := suite.stdout.String() + + // Basic check that some output was produced (flag exists but behavior TBD) + suite.NotEmpty(output, "Should produce some output") + + // Verify files were NOT actually added (no symlinks created) + info, err := os.Lstat(testFile1) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run") + + info, err = os.Lstat(testFile2) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be a symlink in dry-run") + + // Verify lnk list shows no managed files + suite.stdout.Reset() + err = suite.runCommand("list") + suite.NoError(err) + listOutput := suite.stdout.String() + suite.NotContains(listOutput, "test1.txt", "Files should not be managed after dry-run") + suite.NotContains(listOutput, "test2.txt", "Files should not be managed after dry-run") +} + +func (suite *CLITestSuite) TestDryRunOutput() { + // Initialize repository + err := suite.runCommand("init") + suite.NoError(err) + initOutput := suite.stdout.String() + suite.Contains(initOutput, "Initialized") + suite.stdout.Reset() + + // Create test files + testFile1 := filepath.Join(suite.tempDir, "test1.txt") + testFile2 := filepath.Join(suite.tempDir, "test2.txt") + suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644)) + suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644)) + + // Run add with dry-run flag + err = suite.runCommand("add", "--dry-run", testFile1, testFile2) + suite.NoError(err, "Dry-run command should succeed") + output := suite.stdout.String() + + // Verify dry-run shows preview of what would be added + suite.Contains(output, "Would add", "Should show dry-run preview") + suite.Contains(output, "test1.txt", "Should show first file") + suite.Contains(output, "test2.txt", "Should show second file") + suite.Contains(output, "2 files", "Should show file count") + + // Should contain helpful instructions + suite.Contains(output, "run without --dry-run", "Should provide next steps") +} + +func (suite *CLITestSuite) TestDryRunRecursive() { + // Initialize repository + err := suite.runCommand("init") + suite.NoError(err) + initOutput := suite.stdout.String() + suite.Contains(initOutput, "Initialized") + suite.stdout.Reset() + + // Create directory structure with multiple files + configDir := filepath.Join(suite.tempDir, ".config", "test-app") + err = os.MkdirAll(configDir, 0755) + suite.Require().NoError(err) + + // Create files in directory + for i := 1; i <= 15; i++ { + file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i)) + suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("config %d", i)), 0644)) + } + + // Run recursive add with dry-run + err = suite.runCommand("add", "--dry-run", "--recursive", configDir) + suite.NoError(err, "Dry-run recursive command should succeed") + output := suite.stdout.String() + + // Verify dry-run shows all files that would be added + suite.Contains(output, "Would add", "Should show dry-run preview") + suite.Contains(output, "15 files", "Should show correct file count") + suite.Contains(output, "recursively", "Should indicate recursive mode") + + // Should show some of the files + suite.Contains(output, "config1.json", "Should show first file") + suite.Contains(output, "config15.json", "Should show last file") + + // Verify no actual changes were made + for i := 1; i <= 15; i++ { + file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i)) + info, err := os.Lstat(file) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after dry-run") + } +} + +// Task 3.2: Enhanced Output and Messaging Tests + +func (suite *CLITestSuite) TestEnhancedSuccessOutput() { + // Initialize repository + err := suite.runCommand("init") + suite.NoError(err) + suite.stdout.Reset() + + // Create multiple test files + testFiles := []string{ + filepath.Join(suite.tempDir, "config1.txt"), + filepath.Join(suite.tempDir, "config2.txt"), + filepath.Join(suite.tempDir, "config3.txt"), + } + + for i, file := range testFiles { + suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i+1)), 0644)) + } + + // Add multiple files + args := append([]string{"add"}, testFiles...) + err = suite.runCommand(args...) + suite.NoError(err) + output := suite.stdout.String() + + // Should have enhanced formatting with consistent indentation + suite.Contains(output, "🔗", "Should use link icons") + suite.Contains(output, "config1.txt", "Should show first file") + suite.Contains(output, "config2.txt", "Should show second file") + suite.Contains(output, "config3.txt", "Should show third file") + + // Should show organized file list + suite.Contains(output, " ", "Should have consistent indentation") + + // Should include summary information + suite.Contains(output, "3 items", "Should show total count") +} + +func (suite *CLITestSuite) TestOperationSummary() { + // Initialize repository + err := suite.runCommand("init") + suite.NoError(err) + suite.stdout.Reset() + + // Create directory with files for recursive operation + configDir := filepath.Join(suite.tempDir, ".config", "test-app") + err = os.MkdirAll(configDir, 0755) + suite.Require().NoError(err) + + // Create files in directory + for i := 1; i <= 5; i++ { + file := filepath.Join(configDir, fmt.Sprintf("file%d.json", i)) + suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("content %d", i)), 0644)) + } + + // Add recursively + err = suite.runCommand("add", "--recursive", configDir) + suite.NoError(err) + output := suite.stdout.String() + + // Should show operation summary + suite.Contains(output, "recursively", "Should indicate operation type") + suite.Contains(output, "5", "Should show correct file count") + + // Should include contextual help message + suite.Contains(output, "lnk push", "Should suggest next steps") + suite.Contains(output, "sync to remote", "Should explain next step purpose") + + // Should show operation completion confirmation + suite.Contains(output, "✨", "Should use success emoji") + suite.Contains(output, "Added", "Should confirm operation completed") +} + +// Task 3.3: Documentation and Help Updates Tests + +func (suite *CLITestSuite) TestUpdatedHelpText() { + // Test main help + err := suite.runCommand("help") + suite.NoError(err) + helpOutput := suite.stdout.String() + suite.stdout.Reset() + + // Should mention bulk operations + suite.Contains(helpOutput, "multiple files", "Help should mention multiple file support") + + // Test add command help + err = suite.runCommand("add", "--help") + suite.NoError(err) + addHelpOutput := suite.stdout.String() + + // Should include new flags + suite.Contains(addHelpOutput, "--recursive", "Help should include recursive flag") + suite.Contains(addHelpOutput, "--dry-run", "Help should include dry-run flag") + + // Should include examples + suite.Contains(addHelpOutput, "Examples:", "Help should include usage examples") + suite.Contains(addHelpOutput, "lnk add ~/.bashrc ~/.vimrc", "Help should show multiple file example") + suite.Contains(addHelpOutput, "lnk add --recursive ~/.config", "Help should show recursive example") + suite.Contains(addHelpOutput, "lnk add --dry-run", "Help should show dry-run example") + + // Should describe what each flag does + suite.Contains(addHelpOutput, "directory contents individually", "Should explain recursive flag") + suite.Contains(addHelpOutput, "without making changes", "Should explain dry-run flag") +} + func TestCLISuite(t *testing.T) { suite.Run(t, new(CLITestSuite)) } diff --git a/internal/core/lnk.go b/internal/core/lnk.go index a49ca86..24e5bb4 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -1087,3 +1087,64 @@ func (l *Lnk) AddRecursive(paths []string) error { return l.AddMultiple(allFiles) } + +// PreviewAdd simulates an add operation and returns files that would be affected +func (l *Lnk) PreviewAdd(paths []string, recursive bool) ([]string, error) { + var allFiles []string + + for _, path := range paths { + // Get absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for %s: %w", path, err) + } + + // Check if it's a directory + info, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("failed to stat %s: %w", path, err) + } + + if info.IsDir() && recursive { + // Walk directory to get all files (same logic as AddRecursive) + files, err := l.walkDirectory(absPath) + if err != nil { + return nil, fmt.Errorf("failed to walk directory %s: %w", path, err) + } + allFiles = append(allFiles, files...) + } else { + // It's a regular file or non-recursive directory, add it directly + allFiles = append(allFiles, absPath) + } + } + + // Validate files (same validation as AddMultiple but without making changes) + var validFiles []string + for _, filePath := range allFiles { + // Validate the file or directory + if err := l.fs.ValidateFileForAdd(filePath); err != nil { + return nil, fmt.Errorf("validation failed for %s: %w", filePath, err) + } + + // Get relative path for tracking + relativePath, err := getRelativePath(filePath) + if err != nil { + return nil, fmt.Errorf("failed to get relative path for %s: %w", filePath, err) + } + + // Check if this relative path is already managed + managedItems, err := l.getManagedItems() + if err != nil { + return nil, fmt.Errorf("failed to get managed items: %w", err) + } + for _, item := range managedItems { + if item == relativePath { + return nil, fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath) + } + } + + validFiles = append(validFiles, filePath) + } + + return validFiles, nil +} diff --git a/internal/core/lnk_test.go b/internal/core/lnk_test.go index 046f891..f9212dd 100644 --- a/internal/core/lnk_test.go +++ b/internal/core/lnk_test.go @@ -1338,6 +1338,100 @@ func (suite *CoreTestSuite) TestProgressThreshold() { suite.Equal(15, largeProgressCalls, "Progress should be called for operations over threshold") } +// Task 3.1: Dry-Run Mode Core Tests + +func (suite *CoreTestSuite) TestPreviewAdd() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create test files + testFile1 := filepath.Join(suite.tempDir, "test1.txt") + testFile2 := filepath.Join(suite.tempDir, "test2.txt") + suite.Require().NoError(os.WriteFile(testFile1, []byte("content1"), 0644)) + suite.Require().NoError(os.WriteFile(testFile2, []byte("content2"), 0644)) + + // Test PreviewAdd for multiple files + files, err := suite.lnk.PreviewAdd([]string{testFile1, testFile2}, false) + suite.Require().NoError(err, "PreviewAdd should succeed") + + // Should return both files + suite.Len(files, 2, "Should preview both files") + suite.Contains(files, testFile1, "Should include first file") + suite.Contains(files, testFile2, "Should include second file") + + // Verify no actual changes were made (files should still be regular files) + info, err := os.Lstat(testFile1) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview") + + info, err = os.Lstat(testFile2) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview") +} + +func (suite *CoreTestSuite) TestPreviewAddRecursive() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create directory structure + configDir := filepath.Join(suite.tempDir, ".config", "test-app") + err = os.MkdirAll(configDir, 0755) + suite.Require().NoError(err) + + // Create files in directory + expectedFiles := 5 + var createdFiles []string + for i := 1; i <= expectedFiles; i++ { + file := filepath.Join(configDir, fmt.Sprintf("config%d.json", i)) + suite.Require().NoError(os.WriteFile(file, []byte(fmt.Sprintf("config %d", i)), 0644)) + createdFiles = append(createdFiles, file) + } + + // Test PreviewAdd with recursive + files, err := suite.lnk.PreviewAdd([]string{configDir}, true) + suite.Require().NoError(err, "PreviewAdd recursive should succeed") + + // Should return all files in directory + suite.Len(files, expectedFiles, "Should preview all files in directory") + + // Check that all created files are included + for _, createdFile := range createdFiles { + suite.Contains(files, createdFile, "Should include file %s", createdFile) + } + + // Verify no actual changes were made + for _, createdFile := range createdFiles { + info, err := os.Lstat(createdFile) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "File should not be symlink after preview") + } +} + +func (suite *CoreTestSuite) TestPreviewAddValidation() { + // Initialize lnk repository + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Test with nonexistent file + nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt") + _, err = suite.lnk.PreviewAdd([]string{nonexistentFile}, false) + suite.Error(err, "PreviewAdd should fail for nonexistent file") + suite.Contains(err.Error(), "failed to stat", "Error should mention stat failure") + + // Create and add a file first + testFile := filepath.Join(suite.tempDir, "test.txt") + suite.Require().NoError(os.WriteFile(testFile, []byte("content"), 0644)) + err = suite.lnk.Add(testFile) + suite.Require().NoError(err) + + // Test preview with already managed file + _, err = suite.lnk.PreviewAdd([]string{testFile}, false) + suite.Error(err, "PreviewAdd should fail for already managed file") + suite.Contains(err.Error(), "already managed", "Error should mention already managed") +} + func TestCoreSuite(t *testing.T) { suite.Run(t, new(CoreTestSuite)) }