diff --git a/cmd/add.go b/cmd/add.go index 1f777b2..c3d3839 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -9,30 +9,60 @@ import ( func newAddCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "add ", - Short: "✨ Add a file to lnk management", - Long: "Moves a file to the lnk repository and creates a symlink in its place.", - Args: cobra.ExactArgs(1), + Use: "add ...", + Short: "✨ Add files to lnk management", + Long: "Moves files to the lnk repository and creates symlinks in their place. Supports multiple files.", + Args: cobra.MinimumNArgs(1), SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] host, _ := cmd.Flags().GetString("host") - lnk := core.NewLnk(core.WithHost(host)) - if err := lnk.Add(filePath); err != nil { - return err + // Use appropriate method based on number of files + if len(args) == 1 { + // Single file - use existing Add method for backward compatibility + if err := lnk.Add(args[0]); err != nil { + return err + } + } else { + // Multiple files - use AddMultiple for atomic operation + if err := lnk.AddMultiple(args); err != nil { + return err + } } - basename := filepath.Base(filePath) - if host != "" { - printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host) - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath) + // Display results + if len(args) == 1 { + // Single file - maintain existing output format for backward compatibility + filePath := args[0] + basename := filepath.Base(filePath) + if host != "" { + printf(cmd, "✨ \033[1mAdded %s to lnk (host: %s)\033[0m\n", basename, host) + printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", filePath, host, filePath) + } else { + printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename) + printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath) + } } else { - printf(cmd, "✨ \033[1mAdded %s to lnk\033[0m\n", basename) - printf(cmd, " 🔗 \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, filePath) + // Multiple files - show summary + if host != "" { + printf(cmd, "✨ \033[1mAdded %d items to lnk (host: %s)\033[0m\n", len(args), host) + } else { + printf(cmd, "✨ \033[1mAdded %d items to lnk\033[0m\n", len(args)) + } + + // List each added file + for _, filePath := range args { + basename := filepath.Base(filePath) + 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) + } + } } + printf(cmd, " 📝 Use \033[1mlnk push\033[0m to sync to remote\n") return nil }, diff --git a/cmd/root_test.go b/cmd/root_test.go index 96ba940..9f24026 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -277,7 +277,7 @@ func (suite *CLITestSuite) TestErrorHandling() { name: "add help", args: []string{"add", "--help"}, wantErr: false, - outContains: "Moves a file to the lnk repository", + outContains: "Moves files to the lnk repository", }, { name: "list help", @@ -790,8 +790,8 @@ func (suite *CLITestSuite) TestInitWithBootstrap() { err := os.MkdirAll(remoteDir, 0755) suite.Require().NoError(err) - // Initialize git repo in remote - cmd := exec.Command("git", "init", "--bare") + // Initialize git repo in remote with main branch + cmd := exec.Command("git", "init", "--bare", "--initial-branch=main") cmd.Dir = remoteDir err = cmd.Run() suite.Require().NoError(err) @@ -835,7 +835,7 @@ touch remote-bootstrap-ran.txt err = cmd.Run() suite.Require().NoError(err) - cmd = exec.Command("git", "push", "origin", "master") + cmd = exec.Command("git", "push", "origin", "main") cmd.Dir = workingDir err = cmd.Run() suite.Require().NoError(err) @@ -863,8 +863,8 @@ func (suite *CLITestSuite) TestInitWithBootstrapDisabled() { err := os.MkdirAll(remoteDir, 0755) suite.Require().NoError(err) - // Initialize git repo in remote - cmd := exec.Command("git", "init", "--bare") + // Initialize git repo in remote with main branch + cmd := exec.Command("git", "init", "--bare", "--initial-branch=main") cmd.Dir = remoteDir err = cmd.Run() suite.Require().NoError(err) @@ -898,7 +898,7 @@ touch should-not-exist.txt err = cmd.Run() suite.Require().NoError(err) - cmd = exec.Command("git", "push", "origin", "master") + cmd = exec.Command("git", "push", "origin", "main") cmd.Dir = workingDir err = cmd.Run() suite.Require().NoError(err) @@ -917,6 +917,101 @@ touch should-not-exist.txt suite.NoFileExists(markerFile) } +func (suite *CLITestSuite) TestAddCommandMultipleFiles() { + // Initialize repository + err := suite.runCommand("init") + suite.Require().NoError(err) + suite.stdout.Reset() + + // Create multiple test files + testFile1 := filepath.Join(suite.tempDir, ".bashrc") + err = os.WriteFile(testFile1, []byte("export PATH1"), 0644) + suite.Require().NoError(err) + + testFile2 := filepath.Join(suite.tempDir, ".vimrc") + err = os.WriteFile(testFile2, []byte("set number"), 0644) + suite.Require().NoError(err) + + testFile3 := filepath.Join(suite.tempDir, ".gitconfig") + err = os.WriteFile(testFile3, []byte("[user]\n name = test"), 0644) + suite.Require().NoError(err) + + // Test add command with multiple files - should succeed + err = suite.runCommand("add", testFile1, testFile2, testFile3) + suite.NoError(err, "Adding multiple files should succeed") + + // Check output shows all files were added + output := suite.stdout.String() + suite.Contains(output, "Added 3 items to lnk") + suite.Contains(output, ".bashrc") + suite.Contains(output, ".vimrc") + suite.Contains(output, ".gitconfig") + + // Verify all files are now symlinks + for _, file := range []string{testFile1, testFile2, testFile3} { + info, err := os.Lstat(file) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) + } + + // Verify all files exist in storage + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + suite.FileExists(filepath.Join(lnkDir, ".bashrc")) + suite.FileExists(filepath.Join(lnkDir, ".vimrc")) + suite.FileExists(filepath.Join(lnkDir, ".gitconfig")) + + // Verify .lnk file contains all entries + lnkFile := filepath.Join(lnkDir, ".lnk") + lnkContent, err := os.ReadFile(lnkFile) + suite.NoError(err) + suite.Equal(".bashrc\n.gitconfig\n.vimrc\n", string(lnkContent)) +} + +func (suite *CLITestSuite) TestAddCommandMixedTypes() { + // Initialize repository + err := suite.runCommand("init") + suite.Require().NoError(err) + suite.stdout.Reset() + + // Create a file + testFile := filepath.Join(suite.tempDir, ".vimrc") + err = os.WriteFile(testFile, []byte("set number"), 0644) + suite.Require().NoError(err) + + // Create a directory with content + testDir := filepath.Join(suite.tempDir, ".config", "git") + err = os.MkdirAll(testDir, 0755) + suite.Require().NoError(err) + configFile := filepath.Join(testDir, "config") + err = os.WriteFile(configFile, []byte("[user]"), 0644) + suite.Require().NoError(err) + + // Test add command with mixed files and directories - should succeed + err = suite.runCommand("add", testFile, testDir) + suite.NoError(err, "Adding mixed files and directories should succeed") + + // Check output shows both items were added + output := suite.stdout.String() + suite.Contains(output, "Added 2 items to lnk") + suite.Contains(output, ".vimrc") + suite.Contains(output, "git") + + // Verify both are now symlinks + info1, err := os.Lstat(testFile) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink) + + info2, err := os.Lstat(testDir) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink) + + // Verify storage + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + suite.FileExists(filepath.Join(lnkDir, ".vimrc")) + suite.DirExists(filepath.Join(lnkDir, ".config", "git")) + suite.FileExists(filepath.Join(lnkDir, ".config", "git", "config")) +} + func TestCLISuite(t *testing.T) { suite.Run(t, new(CLITestSuite)) } diff --git a/internal/core/lnk.go b/internal/core/lnk.go index 0496b4f..cbab637 100644 --- a/internal/core/lnk.go +++ b/internal/core/lnk.go @@ -257,6 +257,153 @@ func (l *Lnk) Add(filePath string) error { return nil } +// AddMultiple adds multiple files or directories to the repository in a single transaction +func (l *Lnk) AddMultiple(paths []string) error { + if len(paths) == 0 { + return nil + } + + // Phase 1: Validate all paths first + var relativePaths []string + var absolutePaths []string + var infos []os.FileInfo + + for _, filePath := range paths { + // Validate the file or directory + if err := l.fs.ValidateFileForAdd(filePath); err != nil { + return fmt.Errorf("validation failed for %s: %w", filePath, err) + } + + // Get absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + return fmt.Errorf("failed to get absolute path for %s: %w", filePath, err) + } + + // Get relative path for tracking + relativePath, err := getRelativePath(absPath) + if err != nil { + return 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 fmt.Errorf("failed to get managed items: %w", err) + } + for _, item := range managedItems { + if item == relativePath { + return fmt.Errorf("❌ File is already managed by lnk: \033[31m%s\033[0m", relativePath) + } + } + + // Get file info + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("failed to stat path %s: %w", filePath, err) + } + + relativePaths = append(relativePaths, relativePath) + absolutePaths = append(absolutePaths, absPath) + infos = append(infos, info) + } + + // Phase 2: Process all files - move to repository and create symlinks + var rollbackActions []func() error + + for i, absPath := range absolutePaths { + relativePath := relativePaths[i] + info := infos[i] + + // Generate repository path from relative path + storagePath := l.getHostStoragePath() + destPath := filepath.Join(storagePath, relativePath) + + // Ensure destination directory exists + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + // Rollback previous operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Move to repository + if err := l.fs.Move(absPath, destPath, info); err != nil { + // Rollback previous operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to move %s: %w", absPath, err) + } + + // Create symlink + if err := l.fs.CreateSymlink(destPath, absPath); err != nil { + // Try to restore the file we just moved, then rollback others + _ = l.fs.Move(destPath, absPath, info) + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to create symlink for %s: %w", absPath, err) + } + + // Add to tracking + if err := l.addManagedItem(relativePath); err != nil { + // Restore this file and rollback others + _ = os.Remove(absPath) + _ = l.fs.Move(destPath, absPath, info) + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to update tracking file for %s: %w", absPath, err) + } + + // Add rollback action for this file + rollbackAction := l.createRollbackAction(absPath, destPath, relativePath, info) + rollbackActions = append(rollbackActions, rollbackAction) + } + + // Phase 3: Git operations - add all files and create single commit + for i, relativePath := range relativePaths { + // For host-specific files, we need to add the relative path from repo root + gitPath := relativePath + if l.host != "" { + gitPath = filepath.Join(l.host+".lnk", relativePath) + } + if err := l.git.Add(gitPath); err != nil { + // Rollback all operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to add %s to git: %w", absolutePaths[i], err) + } + } + + // Add .lnk file to the same commit + if err := l.git.Add(l.getLnkFileName()); err != nil { + // Rollback all operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to add tracking file to git: %w", err) + } + + // Commit all changes together + commitMessage := fmt.Sprintf("lnk: added %d files", len(paths)) + if err := l.git.Commit(commitMessage); err != nil { + // Rollback all operations + l.rollbackOperations(rollbackActions) + return fmt.Errorf("failed to commit changes: %w", err) + } + + return nil +} + +// createRollbackAction creates a rollback function for a single file operation +func (l *Lnk) createRollbackAction(absPath, destPath, relativePath string, info os.FileInfo) func() error { + return func() error { + _ = os.Remove(absPath) + _ = l.removeManagedItem(relativePath) + return l.fs.Move(destPath, absPath, info) + } +} + +// rollbackOperations executes rollback actions in reverse order +func (l *Lnk) rollbackOperations(rollbackActions []func() error) { + for i := len(rollbackActions) - 1; i >= 0; i-- { + _ = rollbackActions[i]() + } +} + // Remove removes a symlink and restores the original file or directory func (l *Lnk) Remove(filePath string) error { // Get absolute path diff --git a/internal/core/lnk_test.go b/internal/core/lnk_test.go index c9d8718..6d6ba00 100644 --- a/internal/core/lnk_test.go +++ b/internal/core/lnk_test.go @@ -31,6 +31,9 @@ func (suite *CoreTestSuite) SetupTest() { err = os.Chdir(tempDir) suite.Require().NoError(err) + // Set HOME to temp directory for consistent relative path calculation + suite.T().Setenv("HOME", tempDir) + // Set XDG_CONFIG_HOME to temp directory suite.T().Setenv("XDG_CONFIG_HOME", tempDir) @@ -86,8 +89,8 @@ func (suite *CoreTestSuite) TestCoreFileOperations() { // The repository file will preserve the directory structure lnkDir := filepath.Join(suite.tempDir, "lnk") - // Find the .bashrc file in the repository (it should be at the relative path) - repoFile := filepath.Join(lnkDir, suite.tempDir, ".bashrc") + // Find the .bashrc file in the repository (it should be at the relative path from HOME) + repoFile := filepath.Join(lnkDir, ".bashrc") suite.FileExists(repoFile) // Verify content is preserved @@ -137,8 +140,8 @@ func (suite *CoreTestSuite) TestCoreDirectoryOperations() { // Check that the repository directory preserves the structure lnkDir := filepath.Join(suite.tempDir, "lnk") - // The directory should be at the relative path - repoDir := filepath.Join(lnkDir, suite.tempDir, "testdir") + // The directory should be at the relative path from HOME + repoDir := filepath.Join(lnkDir, "testdir") suite.DirExists(repoDir) // Remove the directory @@ -820,6 +823,273 @@ func (suite *CoreTestSuite) TestRunBootstrapScriptNotFound() { suite.Contains(err.Error(), "Bootstrap script not found") } +func (suite *CoreTestSuite) TestAddMultiple() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create multiple test files + file1 := filepath.Join(suite.tempDir, "file1.txt") + file2 := filepath.Join(suite.tempDir, "file2.txt") + file3 := filepath.Join(suite.tempDir, "file3.txt") + + content1 := "content1" + content2 := "content2" + content3 := "content3" + + err = os.WriteFile(file1, []byte(content1), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file2, []byte(content2), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file3, []byte(content3), 0644) + suite.Require().NoError(err) + + // Test AddMultiple method - should succeed + paths := []string{file1, file2, file3} + err = suite.lnk.AddMultiple(paths) + suite.NoError(err, "AddMultiple should succeed") + + // Verify all files are now symlinks + for _, file := range paths { + info, err := os.Lstat(file) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink, "File should be a symlink: %s", file) + } + + // Verify all files exist in storage + lnkDir := filepath.Join(suite.tempDir, "lnk") + suite.FileExists(filepath.Join(lnkDir, "file1.txt")) + suite.FileExists(filepath.Join(lnkDir, "file2.txt")) + suite.FileExists(filepath.Join(lnkDir, "file3.txt")) + + // Verify .lnk file contains all entries + lnkFile := filepath.Join(lnkDir, ".lnk") + lnkContent, err := os.ReadFile(lnkFile) + suite.NoError(err) + suite.Equal("file1.txt\nfile2.txt\nfile3.txt\n", string(lnkContent)) + + // Verify Git commit was created + commits, err := suite.lnk.GetCommits() + suite.NoError(err) + suite.T().Logf("Commits: %v", commits) + // Should have at least 1 commit for the batch add + suite.GreaterOrEqual(len(commits), 1) + // The most recent commit should mention multiple files + suite.Contains(commits[0], "added 3 files") +} + +func (suite *CoreTestSuite) TestAddMultipleWithConflicts() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create test files + file1 := filepath.Join(suite.tempDir, "file1.txt") + file2 := filepath.Join(suite.tempDir, "file2.txt") + file3 := filepath.Join(suite.tempDir, "file3.txt") + + err = os.WriteFile(file1, []byte("content1"), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file2, []byte("content2"), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file3, []byte("content3"), 0644) + suite.Require().NoError(err) + + // Add file2 individually first + err = suite.lnk.Add(file2) + suite.Require().NoError(err) + + // Now try to add all three - should fail due to conflict with file2 + paths := []string{file1, file2, file3} + err = suite.lnk.AddMultiple(paths) + suite.Error(err, "AddMultiple should fail due to conflict") + suite.Contains(err.Error(), "already managed") + + // Verify no partial changes were made + // file1 and file3 should still be regular files, not symlinks + info1, err := os.Lstat(file1) + suite.NoError(err) + suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink") + + info3, err := os.Lstat(file3) + suite.NoError(err) + suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink") + + // file2 should still be managed (was added before) + info2, err := os.Lstat(file2) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink") +} + +func (suite *CoreTestSuite) TestAddMultipleRollback() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create test files - one will be invalid to force rollback + file1 := filepath.Join(suite.tempDir, "file1.txt") + file2 := filepath.Join(suite.tempDir, "nonexistent.txt") // This doesn't exist + file3 := filepath.Join(suite.tempDir, "file3.txt") + + err = os.WriteFile(file1, []byte("content1"), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file3, []byte("content3"), 0644) + suite.Require().NoError(err) + // Note: file2 is intentionally not created + + // Try to add all files - should fail and rollback + paths := []string{file1, file2, file3} + err = suite.lnk.AddMultiple(paths) + suite.Error(err, "AddMultiple should fail due to nonexistent file") + + // Verify rollback - no files should be symlinks + info1, err := os.Lstat(file1) + suite.NoError(err) + suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink, "file1 should not be a symlink after rollback") + + info3, err := os.Lstat(file3) + suite.NoError(err) + suite.Equal(os.FileMode(0), info3.Mode()&os.ModeSymlink, "file3 should not be a symlink after rollback") + + // Verify no files in storage + lnkDir := filepath.Join(suite.tempDir, "lnk") + suite.NoFileExists(filepath.Join(lnkDir, "file1.txt")) + suite.NoFileExists(filepath.Join(lnkDir, "file3.txt")) + + // Verify .lnk file is empty or doesn't contain these files + lnkFile := filepath.Join(lnkDir, ".lnk") + if _, err := os.Stat(lnkFile); err == nil { + lnkContent, err := os.ReadFile(lnkFile) + suite.NoError(err) + content := string(lnkContent) + suite.NotContains(content, "file1.txt") + suite.NotContains(content, "file3.txt") + } +} + +func (suite *CoreTestSuite) TestValidateMultiplePaths() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create a mix of valid and invalid paths + validFile := filepath.Join(suite.tempDir, "valid.txt") + err = os.WriteFile(validFile, []byte("content"), 0644) + suite.Require().NoError(err) + + nonexistentFile := filepath.Join(suite.tempDir, "nonexistent.txt") + // Don't create this file + + // Create a valid directory + validDir := filepath.Join(suite.tempDir, "validdir") + err = os.MkdirAll(validDir, 0755) + suite.Require().NoError(err) + + // Test validation fails early with detailed error + paths := []string{validFile, nonexistentFile, validDir} + err = suite.lnk.AddMultiple(paths) + suite.Error(err, "Should fail due to nonexistent file") + suite.Contains(err.Error(), "validation failed") + suite.Contains(err.Error(), "nonexistent.txt") + + // Verify no partial changes were made + info, err := os.Lstat(validFile) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid file should not be a symlink") + + info, err = os.Lstat(validDir) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink, "valid directory should not be a symlink") +} + +func (suite *CoreTestSuite) TestAtomicRollbackOnFailure() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Create test files + file1 := filepath.Join(suite.tempDir, "file1.txt") + file2 := filepath.Join(suite.tempDir, "file2.txt") + file3 := filepath.Join(suite.tempDir, "file3.txt") + + content1 := "original content 1" + content2 := "original content 2" + content3 := "original content 3" + + err = os.WriteFile(file1, []byte(content1), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file2, []byte(content2), 0644) + suite.Require().NoError(err) + err = os.WriteFile(file3, []byte(content3), 0644) + suite.Require().NoError(err) + + // Add file2 individually first to create a conflict + err = suite.lnk.Add(file2) + suite.Require().NoError(err) + + // Store original states + info1Before, err := os.Lstat(file1) + suite.Require().NoError(err) + info3Before, err := os.Lstat(file3) + suite.Require().NoError(err) + + // Try to add all files - should fail and rollback completely + paths := []string{file1, file2, file3} + err = suite.lnk.AddMultiple(paths) + suite.Error(err, "Should fail due to conflict with file2") + + // Verify complete rollback + info1After, err := os.Lstat(file1) + suite.NoError(err) + suite.Equal(info1Before.Mode(), info1After.Mode(), "file1 mode should be unchanged") + + info3After, err := os.Lstat(file3) + suite.NoError(err) + suite.Equal(info3Before.Mode(), info3After.Mode(), "file3 mode should be unchanged") + + // Verify original contents are preserved + content1After, err := os.ReadFile(file1) + suite.NoError(err) + suite.Equal(content1, string(content1After), "file1 content should be preserved") + + content3After, err := os.ReadFile(file3) + suite.NoError(err) + suite.Equal(content3, string(content3After), "file3 content should be preserved") + + // file2 should still be managed (was added before) + info2, err := os.Lstat(file2) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink, "file2 should remain a symlink") +} + +func (suite *CoreTestSuite) TestDetailedErrorMessages() { + err := suite.lnk.Init() + suite.Require().NoError(err) + + // Test with multiple types of errors + validFile := filepath.Join(suite.tempDir, "valid.txt") + err = os.WriteFile(validFile, []byte("content"), 0644) + suite.Require().NoError(err) + + nonexistentFile := filepath.Join(suite.tempDir, "does-not-exist.txt") + alreadyManagedFile := filepath.Join(suite.tempDir, "already-managed.txt") + err = os.WriteFile(alreadyManagedFile, []byte("managed"), 0644) + suite.Require().NoError(err) + + // Add one file first to create conflict + err = suite.lnk.Add(alreadyManagedFile) + suite.Require().NoError(err) + + // Test with nonexistent file + paths := []string{validFile, nonexistentFile} + err = suite.lnk.AddMultiple(paths) + suite.Error(err, "Should fail due to nonexistent file") + suite.Contains(err.Error(), "validation failed", "Error should mention validation failure") + suite.Contains(err.Error(), "does-not-exist.txt", "Error should include specific filename") + + // Test with already managed file + paths = []string{validFile, alreadyManagedFile} + err = suite.lnk.AddMultiple(paths) + suite.Error(err, "Should fail due to already managed file") + suite.Contains(err.Error(), "already managed", "Error should mention already managed") + suite.Contains(err.Error(), "already-managed.txt", "Error should include specific filename") +} + func TestCoreSuite(t *testing.T) { suite.Run(t, new(CoreTestSuite)) }