From c718055f2666c6bc7fd0111f4be91c803d73a7b7 Mon Sep 17 00:00:00 2001 From: Yar Kravtsov Date: Sun, 1 Jun 2025 08:43:36 +0300 Subject: [PATCH] feat(core): refactor to clean architecture and improve error handling --- cmd/add.go | 26 +- cmd/init.go | 19 +- cmd/list.go | 102 +-- cmd/pull.go | 22 +- cmd/push.go | 18 +- cmd/rm.go | 20 +- cmd/root.go | 9 +- cmd/root_test.go | 52 +- cmd/status.go | 41 +- cmd/utils.go | 163 ++++ internal/config/config.go | 232 +++++ internal/config/config_test.go | 278 ++++++ internal/core/lnk.go | 674 --------------- internal/core/lnk_test.go | 754 ----------------- internal/errors/errors.go | 241 ++++++ internal/errors/errors_test.go | 126 +++ internal/fs/filemanager.go | 254 ++++++ internal/fs/filemanager_test.go | 261 ++++++ internal/fs/filesystem.go | 133 --- internal/git/git.go | 508 ----------- internal/git/gitmanager.go | 547 ++++++++++++ internal/git/gitmanager_test.go | 307 +++++++ internal/models/models.go | 108 +++ internal/models/models_test.go | 185 ++++ internal/pathresolver/resolver.go | 153 ++++ internal/pathresolver/resolver_test.go | 250 ++++++ internal/service/service.go | 823 ++++++++++++++++++ internal/service/service_test.go | 1080 ++++++++++++++++++++++++ 28 files changed, 5210 insertions(+), 2176 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go delete mode 100644 internal/core/lnk.go delete mode 100644 internal/core/lnk_test.go create mode 100644 internal/errors/errors.go create mode 100644 internal/errors/errors_test.go create mode 100644 internal/fs/filemanager.go create mode 100644 internal/fs/filemanager_test.go delete mode 100644 internal/fs/filesystem.go delete mode 100644 internal/git/git.go create mode 100644 internal/git/gitmanager.go create mode 100644 internal/git/gitmanager_test.go create mode 100644 internal/models/models.go create mode 100644 internal/models/models_test.go create mode 100644 internal/pathresolver/resolver.go create mode 100644 internal/pathresolver/resolver_test.go create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go diff --git a/cmd/add.go b/cmd/add.go index b2f30d3..b49be4e 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -1,11 +1,12 @@ package cmd import ( - "fmt" + "context" "path/filepath" "github.com/spf13/cobra" - "github.com/yarlson/lnk/internal/core" + + "github.com/yarlson/lnk/internal/service" ) func newAddCmd() *cobra.Command { @@ -19,24 +20,27 @@ func newAddCmd() *cobra.Command { filePath := args[0] host, _ := cmd.Flags().GetString("host") - var lnk *core.Lnk - if host != "" { - lnk = core.NewLnkWithHost(host) - } else { - lnk = core.NewLnk() + // Create service instance + lnkService, err := service.New() + if err != nil { + return wrapServiceError("initialize lnk service", err) } - if err := lnk.Add(filePath); err != nil { - return fmt.Errorf("failed to add file: %w", err) + // Add file using service layer + ctx := context.Background() + managedFile, err := lnkService.AddFile(ctx, filePath, host) + if err != nil { + return formatError(err) } + // Display success message 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) + printf(cmd, " šŸ”— \033[90m%s\033[0m → \033[36m~/.config/lnk/%s.lnk/%s\033[0m\n", managedFile.OriginalPath, host, managedFile.RelativePath) } 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) + printf(cmd, " šŸ”— \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", managedFile.OriginalPath, managedFile.RelativePath) } printf(cmd, " šŸ“ Use \033[1mlnk push\033[0m to sync to remote\n") return nil diff --git a/cmd/init.go b/cmd/init.go index 08d9d6f..86e3256 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,10 +1,11 @@ package cmd import ( - "fmt" + "context" "github.com/spf13/cobra" - "github.com/yarlson/lnk/internal/core" + + "github.com/yarlson/lnk/internal/service" ) func newInitCmd() *cobra.Command { @@ -16,11 +17,19 @@ func newInitCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { remote, _ := cmd.Flags().GetString("remote") - lnk := core.NewLnk() - if err := lnk.InitWithRemote(remote); err != nil { - return fmt.Errorf("failed to initialize lnk: %w", err) + // Create service instance + lnkService, err := service.New() + if err != nil { + return wrapServiceError("initialize lnk service", err) } + // Initialize repository using service layer + ctx := context.Background() + if err := lnkService.InitializeRepository(ctx, remote); err != nil { + return formatError(err) + } + + // Display success message if remote != "" { printf(cmd, "šŸŽÆ \033[1mInitialized lnk repository\033[0m\n") printf(cmd, " šŸ“¦ Cloned from: \033[36m%s\033[0m\n", remote) diff --git a/cmd/list.go b/cmd/list.go index 5c58898..e4beec0 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -1,13 +1,14 @@ package cmd import ( + "context" "fmt" "os" - "path/filepath" "strings" "github.com/spf13/cobra" - "github.com/yarlson/lnk/internal/core" + + "github.com/yarlson/lnk/internal/service" ) func newListCmd() *cobra.Command { @@ -41,26 +42,31 @@ func newListCmd() *cobra.Command { } func listCommonConfig(cmd *cobra.Command) error { - lnk := core.NewLnk() - managedItems, err := lnk.List() + ctx := context.Background() + lnkService, err := service.New() if err != nil { - return fmt.Errorf("failed to list managed items: %w", err) + return wrapServiceError("initialize lnk service", err) } - if len(managedItems) == 0 { + managedFiles, err := lnkService.ListManagedFiles(ctx, "") + if err != nil { + return formatError(err) + } + + if len(managedFiles) == 0 { printf(cmd, "šŸ“‹ \033[1mNo files currently managed by lnk (common)\033[0m\n") printf(cmd, " šŸ’” Use \033[1mlnk add \033[0m to start managing files\n") return nil } - printf(cmd, "šŸ“‹ \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedItems)) - if len(managedItems) > 1 { + printf(cmd, "šŸ“‹ \033[1mFiles managed by lnk (common)\033[0m (\033[36m%d item", len(managedFiles)) + if len(managedFiles) > 1 { printf(cmd, "s") } printf(cmd, "\033[0m):\n\n") - for _, item := range managedItems { - printf(cmd, " šŸ”— \033[36m%s\033[0m\n", item) + for _, file := range managedFiles { + printf(cmd, " šŸ”— \033[36m%s\033[0m\n", file.RelativePath) } printf(cmd, "\nšŸ’” Use \033[1mlnk status\033[0m to check sync status\n") @@ -68,26 +74,31 @@ func listCommonConfig(cmd *cobra.Command) error { } func listHostConfig(cmd *cobra.Command, host string) error { - lnk := core.NewLnkWithHost(host) - managedItems, err := lnk.List() + ctx := context.Background() + lnkService, err := service.New() if err != nil { - return fmt.Errorf("failed to list managed items for host %s: %w", host, err) + return wrapServiceError("initialize lnk service", err) } - if len(managedItems) == 0 { + managedFiles, err := lnkService.ListManagedFiles(ctx, host) + if err != nil { + return formatError(err) + } + + if len(managedFiles) == 0 { printf(cmd, "šŸ“‹ \033[1mNo files currently managed by lnk (host: %s)\033[0m\n", host) printf(cmd, " šŸ’” Use \033[1mlnk add --host %s \033[0m to start managing files\n", host) return nil } - printf(cmd, "šŸ“‹ \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedItems)) - if len(managedItems) > 1 { + printf(cmd, "šŸ“‹ \033[1mFiles managed by lnk (host: %s)\033[0m (\033[36m%d item", host, len(managedFiles)) + if len(managedFiles) > 1 { printf(cmd, "s") } printf(cmd, "\033[0m):\n\n") - for _, item := range managedItems { - printf(cmd, " šŸ”— \033[36m%s\033[0m\n", item) + for _, file := range managedFiles { + printf(cmd, " šŸ”— \033[36m%s\033[0m\n", file.RelativePath) } printf(cmd, "\nšŸ’” Use \033[1mlnk status\033[0m to check sync status\n") @@ -95,56 +106,60 @@ func listHostConfig(cmd *cobra.Command, host string) error { } func listAllConfigs(cmd *cobra.Command) error { + ctx := context.Background() + lnkService, err := service.New() + if err != nil { + return wrapServiceError("initialize lnk service", err) + } + // List common configuration printf(cmd, "šŸ“‹ \033[1mAll configurations managed by lnk\033[0m\n\n") - lnk := core.NewLnk() - commonItems, err := lnk.List() + commonFiles, err := lnkService.ListManagedFiles(ctx, "") if err != nil { - return fmt.Errorf("failed to list common managed items: %w", err) + return formatError(err) } - printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonItems)) - if len(commonItems) > 1 { + printf(cmd, "🌐 \033[1mCommon configuration\033[0m (\033[36m%d item", len(commonFiles)) + if len(commonFiles) > 1 { printf(cmd, "s") } printf(cmd, "\033[0m):\n") - if len(commonItems) == 0 { + if len(commonFiles) == 0 { printf(cmd, " \033[90m(no files)\033[0m\n") } else { - for _, item := range commonItems { - printf(cmd, " šŸ”— \033[36m%s\033[0m\n", item) + for _, file := range commonFiles { + printf(cmd, " šŸ”— \033[36m%s\033[0m\n", file.RelativePath) } } // Find all host-specific configurations - hosts, err := findHostConfigs() + hosts, err := findHostConfigs(lnkService) if err != nil { - return fmt.Errorf("failed to find host configurations: %w", err) + return formatError(err) } for _, host := range hosts { printf(cmd, "\nšŸ–„ļø \033[1mHost: %s\033[0m", host) - hostLnk := core.NewLnkWithHost(host) - hostItems, err := hostLnk.List() + hostFiles, err := lnkService.ListManagedFiles(ctx, host) if err != nil { printf(cmd, " \033[31m(error: %v)\033[0m\n", err) continue } - printf(cmd, " (\033[36m%d item", len(hostItems)) - if len(hostItems) > 1 { + printf(cmd, " (\033[36m%d item", len(hostFiles)) + if len(hostFiles) > 1 { printf(cmd, "s") } printf(cmd, "\033[0m):\n") - if len(hostItems) == 0 { + if len(hostFiles) == 0 { printf(cmd, " \033[90m(no files)\033[0m\n") } else { - for _, item := range hostItems { - printf(cmd, " šŸ”— \033[36m%s\033[0m\n", item) + for _, file := range hostFiles { + printf(cmd, " šŸ”— \033[36m%s\033[0m\n", file.RelativePath) } } } @@ -153,8 +168,8 @@ func listAllConfigs(cmd *cobra.Command) error { return nil } -func findHostConfigs() ([]string, error) { - repoPath := getRepoPath() +func findHostConfigs(service *service.Service) ([]string, error) { + repoPath := service.GetRepoPath() // Check if repo exists if _, err := os.Stat(repoPath); os.IsNotExist(err) { @@ -178,16 +193,3 @@ func findHostConfigs() ([]string, error) { return hosts, nil } - -func getRepoPath() string { - xdgConfig := os.Getenv("XDG_CONFIG_HOME") - if xdgConfig == "" { - homeDir, err := os.UserHomeDir() - if err != nil { - xdgConfig = "." - } else { - xdgConfig = filepath.Join(homeDir, ".config") - } - } - return filepath.Join(xdgConfig, "lnk") -} diff --git a/cmd/pull.go b/cmd/pull.go index f5cdc9c..9bac2ab 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -1,10 +1,11 @@ package cmd import ( - "fmt" + "context" "github.com/spf13/cobra" - "github.com/yarlson/lnk/internal/core" + + "github.com/yarlson/lnk/internal/service" ) func newPullCmd() *cobra.Command { @@ -16,16 +17,17 @@ func newPullCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { host, _ := cmd.Flags().GetString("host") - var lnk *core.Lnk - if host != "" { - lnk = core.NewLnkWithHost(host) - } else { - lnk = core.NewLnk() + // Create service instance + lnkService, err := service.New() + if err != nil { + return wrapServiceError("initialize lnk service", err) } - restored, err := lnk.Pull() + // Pull changes using the service + ctx := context.Background() + restored, err := lnkService.PullChanges(ctx, host) if err != nil { - return fmt.Errorf("failed to pull changes: %w", err) + return formatError(err) } if len(restored) > 0 { @@ -40,7 +42,7 @@ func newPullCmd() *cobra.Command { } printf(cmd, "\033[0m:\n") for _, file := range restored { - printf(cmd, " ✨ \033[36m%s\033[0m\n", file) + printf(cmd, " ✨ \033[36m%s\033[0m\n", file.RelativePath) } printf(cmd, "\n šŸŽ‰ Your dotfiles are synced and ready!\n") } else { diff --git a/cmd/push.go b/cmd/push.go index eb992be..d3dcaf2 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -1,10 +1,11 @@ package cmd import ( - "fmt" + "context" "github.com/spf13/cobra" - "github.com/yarlson/lnk/internal/core" + + "github.com/yarlson/lnk/internal/service" ) func newPushCmd() *cobra.Command { @@ -20,9 +21,16 @@ func newPushCmd() *cobra.Command { message = args[0] } - lnk := core.NewLnk() - if err := lnk.Push(message); err != nil { - return fmt.Errorf("failed to push changes: %w", err) + // Create service instance + lnkService, err := service.New() + if err != nil { + return wrapServiceError("initialize lnk service", err) + } + + // Push changes using the service + ctx := context.Background() + if err := lnkService.PushChanges(ctx, message); err != nil { + return formatError(err) } printf(cmd, "šŸš€ \033[1;32mSuccessfully pushed changes\033[0m\n") diff --git a/cmd/rm.go b/cmd/rm.go index 4473f42..4cd34cf 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -1,11 +1,12 @@ package cmd import ( - "fmt" + "context" "path/filepath" "github.com/spf13/cobra" - "github.com/yarlson/lnk/internal/core" + + "github.com/yarlson/lnk/internal/service" ) func newRemoveCmd() *cobra.Command { @@ -19,15 +20,16 @@ func newRemoveCmd() *cobra.Command { filePath := args[0] host, _ := cmd.Flags().GetString("host") - var lnk *core.Lnk - if host != "" { - lnk = core.NewLnkWithHost(host) - } else { - lnk = core.NewLnk() + // Create service instance + lnkService, err := service.New() + if err != nil { + return wrapServiceError("initialize lnk service", err) } - if err := lnk.Remove(filePath); err != nil { - return fmt.Errorf("failed to remove file: %w", err) + // Remove the file using the service + ctx := context.Background() + if err := lnkService.RemoveFile(ctx, filePath, host); err != nil { + return formatError(err) } basename := filepath.Base(filePath) diff --git a/cmd/root.go b/cmd/root.go index cae5935..290cbb9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,8 +32,9 @@ Supports both common configurations and host-specific setups. lnk push "setup complete" # Sync to remote šŸŽÆ Simple, fast, Git-native, and multi-host ready.`, - SilenceUsage: true, - Version: fmt.Sprintf("%s (built %s)", version, buildTime), + SilenceUsage: true, + SilenceErrors: true, + Version: fmt.Sprintf("%s (built %s)", version, buildTime), } // Add subcommands @@ -57,7 +58,9 @@ func SetVersion(v, bt string) { func Execute() { rootCmd := NewRootCommand() if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + // Format the error nicely for the user + formattedErr := formatError(err) + fmt.Fprintln(os.Stderr, formattedErr) os.Exit(1) } } diff --git a/cmd/root_test.go b/cmd/root_test.go index 1102331..532c021 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -416,7 +416,7 @@ func (suite *CLITestSuite) TestRemoveUnmanagedFile() { // Try to remove it err := suite.runCommand("rm", testFile) suite.Error(err) - suite.Contains(err.Error(), "File is not managed by lnk") + suite.Contains(err.Error(), "not a symlink") } func (suite *CLITestSuite) TestAddDirectory() { @@ -462,6 +462,54 @@ func (suite *CLITestSuite) TestAddDirectory() { suite.Equal(".ssh\n", string(lnkContent)) } +func (suite *CLITestSuite) TestRemoveDirectory() { + // Initialize repository + _ = suite.runCommand("init") + suite.stdout.Reset() + + // Create a directory with files + testDir := filepath.Join(suite.tempDir, ".config", "aerospace") + _ = os.MkdirAll(testDir, 0755) + configFile := filepath.Join(testDir, "aerospace.toml") + _ = os.WriteFile(configFile, []byte("# Aerospace config"), 0644) + + // Add the directory + err := suite.runCommand("add", testDir) + suite.NoError(err) + suite.stdout.Reset() + + // Verify directory is now a symlink + info, err := os.Lstat(testDir) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) + + // Remove the directory + err = suite.runCommand("rm", testDir) + suite.NoError(err, "Should be able to remove directory without error") + + // Check output + output := suite.stdout.String() + suite.Contains(output, "Removed aerospace from lnk") + suite.Contains(output, "Original file restored") + + // Verify directory is no longer a symlink + info, err = os.Lstat(testDir) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink + + // Verify directory content is preserved + content, err := os.ReadFile(configFile) + suite.NoError(err) + suite.Equal("# Aerospace config", string(content)) + + // Verify directory is removed from tracking + lnkDir := filepath.Join(suite.tempDir, ".config", "lnk") + lnkFile := filepath.Join(lnkDir, ".lnk") + lnkContent, err := os.ReadFile(lnkFile) + suite.NoError(err) + suite.Equal("", string(lnkContent), ".lnk file should be empty after removing directory") +} + func (suite *CLITestSuite) TestSameBasenameFilesBug() { // Initialize repository err := suite.runCommand("init") @@ -737,7 +785,7 @@ func (suite *CLITestSuite) TestMultihostErrorHandling() { err = suite.runCommand("rm", "--host", "nonexistent", testFile) suite.Error(err) - suite.Contains(err.Error(), "File is not managed by lnk") + suite.Contains(err.Error(), "not a symlink") // Try to list non-existent host config err = suite.runCommand("list", "--host", "nonexistent") diff --git a/cmd/status.go b/cmd/status.go index 8544d7a..966884d 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -1,10 +1,12 @@ package cmd import ( - "fmt" + "context" "github.com/spf13/cobra" - "github.com/yarlson/lnk/internal/core" + + "github.com/yarlson/lnk/internal/models" + "github.com/yarlson/lnk/internal/service" ) func newStatusCmd() *cobra.Command { @@ -14,10 +16,15 @@ func newStatusCmd() *cobra.Command { Long: "Display how many commits ahead/behind the local repository is relative to the remote and check for uncommitted changes.", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - lnk := core.NewLnk() - status, err := lnk.Status() + lnkService, err := service.New() if err != nil { - return fmt.Errorf("failed to get status: %w", err) + return wrapServiceError("initialize lnk service", err) + } + + ctx := context.Background() + status, err := lnkService.GetStatus(ctx) + if err != nil { + return formatError(err) } if status.Dirty { @@ -36,9 +43,9 @@ func newStatusCmd() *cobra.Command { } } -func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) { +func displayDirtyStatus(cmd *cobra.Command, status *models.SyncStatus) { printf(cmd, "āš ļø \033[1;33mRepository has uncommitted changes\033[0m\n") - printf(cmd, " šŸ“” Remote: \033[36m%s\033[0m\n", status.Remote) + printf(cmd, " šŸ“” Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status)) if status.Ahead == 0 && status.Behind == 0 { printf(cmd, "\nšŸ’” Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n") @@ -50,14 +57,14 @@ func displayDirtyStatus(cmd *cobra.Command, status *core.StatusInfo) { printf(cmd, "\nšŸ’” Run \033[1mgit add && git commit\033[0m in \033[36m~/.config/lnk\033[0m or \033[1mlnk push\033[0m to commit changes\n") } -func displayUpToDateStatus(cmd *cobra.Command, status *core.StatusInfo) { +func displayUpToDateStatus(cmd *cobra.Command, status *models.SyncStatus) { printf(cmd, "āœ… \033[1;32mRepository is up to date\033[0m\n") - printf(cmd, " šŸ“” Synced with \033[36m%s\033[0m\n", status.Remote) + printf(cmd, " šŸ“” Synced with \033[36m%s\033[0m\n", getRemoteDisplay(status)) } -func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) { +func displaySyncStatus(cmd *cobra.Command, status *models.SyncStatus) { printf(cmd, "šŸ“Š \033[1mRepository Status\033[0m\n") - printf(cmd, " šŸ“” Remote: \033[36m%s\033[0m\n", status.Remote) + printf(cmd, " šŸ“” Remote: \033[36m%s\033[0m\n", getRemoteDisplay(status)) printf(cmd, "\n") displayAheadBehindInfo(cmd, status, false) @@ -69,7 +76,7 @@ func displaySyncStatus(cmd *cobra.Command, status *core.StatusInfo) { } } -func displayAheadBehindInfo(cmd *cobra.Command, status *core.StatusInfo, isDirty bool) { +func displayAheadBehindInfo(cmd *cobra.Command, status *models.SyncStatus, isDirty bool) { if status.Ahead > 0 { commitText := getCommitText(status.Ahead) if isDirty { @@ -91,3 +98,13 @@ func getCommitText(count int) string { } return "commits" } + +func getRemoteDisplay(status *models.SyncStatus) string { + if status.HasRemote && status.RemoteBranch != "" { + return status.RemoteBranch + } + if status.HasRemote && status.RemoteURL != "" { + return status.RemoteURL + } + return "no remote configured" +} diff --git a/cmd/utils.go b/cmd/utils.go index e4ed7d8..c86ea58 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -1,12 +1,175 @@ package cmd import ( + stderrors "errors" "fmt" + "strings" "github.com/spf13/cobra" + + "github.com/yarlson/lnk/internal/errors" ) // printf is a helper function to simplify output formatting in commands func printf(cmd *cobra.Command, format string, args ...interface{}) { _, _ = fmt.Fprintf(cmd.OutOrStdout(), format, args...) } + +// formatError provides user-friendly error formatting while preserving specific error messages for tests +func formatError(err error) error { + if err == nil { + return nil + } + + // Handle typed LnkError first + var lnkErr *errors.LnkError + if stderrors.As(err, &lnkErr) { + return formatLnkError(lnkErr) + } + + // Handle other error patterns with improved messages + errMsg := err.Error() + + // Git-related errors + if strings.Contains(errMsg, "git") { + if strings.Contains(errMsg, "no remote configured") { + return fmt.Errorf("🚫 no remote configured\n šŸ’” Add a remote first: \033[1mgit remote add origin \033[0m in \033[36m~/.config/lnk\033[0m") + } + if strings.Contains(errMsg, "authentication") || strings.Contains(errMsg, "permission denied") { + return fmt.Errorf("šŸ” \033[31mGit authentication failed\033[0m\n šŸ’” Check your SSH keys or credentials: \033[36mhttps://docs.github.com/en/authentication\033[0m") + } + if strings.Contains(errMsg, "not found") && strings.Contains(errMsg, "remote") { + return fmt.Errorf("🌐 \033[31mRemote repository not found\033[0m\n šŸ’” Verify the repository URL is correct and you have access") + } + } + + // Service initialization errors + if strings.Contains(errMsg, "failed to initialize lnk service") { + return fmt.Errorf("āš ļø \033[31mFailed to initialize lnk\033[0m\n šŸ’” This is likely a system configuration issue. Please check permissions and try again.") + } + + // Return original error for unhandled cases to maintain test compatibility + return err +} + +// formatLnkError formats typed LnkError instances with user-friendly messages +func formatLnkError(lnkErr *errors.LnkError) error { + switch lnkErr.Code { + case errors.ErrorCodeFileNotFound: + // Preserve "File does not exist" for test compatibility but add consistent colors + if path, ok := lnkErr.Context["path"].(string); ok { + return fmt.Errorf("āŒ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n šŸ’” Check the file path and try again", path) + } + return fmt.Errorf("āŒ \033[31mFile does not exist\033[0m\n šŸ’” Check the file path and try again") + + case errors.ErrorCodeRepoNotInitialized: + // Preserve "Lnk repository not initialized" for test compatibility but add consistent colors + return fmt.Errorf("šŸ“¦ \033[31mLnk repository not initialized\033[0m\n šŸ’” Run \033[1mlnk init\033[0m to get started") + + case errors.ErrorCodeNotSymlink: + // Preserve "not a symlink" for test compatibility but add consistent colors + return fmt.Errorf("šŸ”— \033[31mnot a symlink\033[0m\n šŸ’” Only files managed by lnk can be removed. Use \033[1mlnk list\033[0m to see managed files") + + case errors.ErrorCodeFileAlreadyManaged: + if path, ok := lnkErr.Context["path"].(string); ok { + return fmt.Errorf("✨ \033[33mFile is already managed by lnk:\033[0m \033[36m%s\033[0m\n šŸ’” Use \033[1mlnk list\033[0m to see all managed files", path) + } + return fmt.Errorf("✨ \033[33mFile is already managed by lnk\033[0m\n šŸ’” Use \033[1mlnk list\033[0m to see all managed files") + + case errors.ErrorCodeNoRemoteConfigured: + // Preserve "no remote configured" for test compatibility but add consistent colors + return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n šŸ’” Add a remote first: \033[1mgit remote add origin \033[0m in \033[36m~/.config/lnk\033[0m") + + case errors.ErrorCodePermissionDenied: + if path, ok := lnkErr.Context["path"].(string); ok { + return fmt.Errorf("šŸ”’ \033[31mPermission denied:\033[0m \033[36m%s\033[0m\n šŸ’” Check file permissions or run with appropriate privileges", path) + } + return fmt.Errorf("šŸ”’ \033[31mPermission denied\033[0m\n šŸ’” Check file permissions or run with appropriate privileges") + + case errors.ErrorCodeGitOperation: + // Check if this is a "no remote configured" case by examining the underlying error first + if lnkErr.Cause != nil && strings.Contains(lnkErr.Cause.Error(), "no remote configured") { + return fmt.Errorf("🚫 \033[31mno remote configured\033[0m\n šŸ’” Add a remote first: \033[1mgit remote add origin \033[0m in \033[36m~/.config/lnk\033[0m") + } + + operation := lnkErr.Context["operation"] + if op, ok := operation.(string); ok { + switch op { + case "get_status", "status": + return fmt.Errorf("šŸ”§ \033[31mGit operation failed\033[0m\n šŸ’” Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details") + case "push_to_remote", "push": + return fmt.Errorf("šŸš€ \033[31mGit operation failed\033[0m\n šŸ’” Check your internet connection and Git credentials\n šŸ’” Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details") + case "pull_from_remote", "pull": + return fmt.Errorf("ā¬‡ļø \033[31mGit operation failed\033[0m\n šŸ’” Check your internet connection and resolve any conflicts\n šŸ’” Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details") + case "clone_repository", "clone": + return fmt.Errorf("šŸ“„ \033[31mGit operation failed\033[0m\n šŸ’” Check the repository URL and your access permissions\n šŸ’” Ensure you have the correct SSH keys or credentials") + case "commit_changes", "commit": + return fmt.Errorf("šŸ’¾ \033[31mGit operation failed\033[0m\n šŸ’” Check if you have Git user.name and user.email configured\n šŸ’” Run \033[1mgit config --global user.name \"Your Name\"\033[0m") + default: + return fmt.Errorf("šŸ”§ \033[31mGit operation failed\033[0m\n šŸ’” Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details") + } + } + return fmt.Errorf("šŸ”§ \033[31mGit operation failed\033[0m\n šŸ’” Run \033[1mgit status\033[0m in \033[36m~/.config/lnk\033[0m for details") + + case errors.ErrorCodeFileSystemOperation: + operation := lnkErr.Context["operation"] + path := lnkErr.Context["path"] + + // Determine user-friendly message based on operation and underlying cause + if op, ok := operation.(string); ok { + switch op { + case "stat_symlink", "check_file_exists": + // Use consistent "File does not exist" messaging + if pathStr, pathOk := path.(string); pathOk { + return fmt.Errorf("āŒ \033[31mFile does not exist:\033[0m \033[36m%s\033[0m\n šŸ’” Check the file path and try again", pathStr) + } + return fmt.Errorf("āŒ \033[31mFile does not exist\033[0m\n šŸ’” Check the file path and try again") + case "move_file": + return fmt.Errorf("šŸ“ \033[31mFile operation failed\033[0m\n šŸ’” Check file permissions and available disk space") + case "create_symlink": + return fmt.Errorf("šŸ”— \033[31mFile operation failed\033[0m\n šŸ’” Check directory permissions and ensure target file exists") + case "remove_symlink", "remove_file": + return fmt.Errorf("šŸ—‘ļø \033[31mFile operation failed\033[0m\n šŸ’” Check file permissions and ensure file exists") + case "read_symlink": + return fmt.Errorf("šŸ”— \033[31mFile operation failed\033[0m\n šŸ’” The symlink may be broken or you don't have permission to read it") + case "resolve_path", "get_relative_path": + if pathStr, pathOk := path.(string); pathOk { + return fmt.Errorf("šŸ“‚ \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n šŸ’” Check the file path and try again", pathStr) + } + return fmt.Errorf("šŸ“‚ \033[31mInvalid file path\033[0m\n šŸ’” Check the file path and try again") + case "create_dest_dir", "create_repo_dir": + return fmt.Errorf("šŸ“ \033[31mFile operation failed\033[0m\n šŸ’” Check permissions and available disk space") + default: + // Don't expose cryptic operation names - give generic but helpful message + return fmt.Errorf("šŸ’½ \033[31mFile operation failed\033[0m\n šŸ’” Check file permissions, paths, and available disk space") + } + } + return fmt.Errorf("šŸ’½ \033[31mFile operation failed\033[0m\n šŸ’” Check file permissions and available disk space") + + case errors.ErrorCodeInvalidPath: + if path, ok := lnkErr.Context["path"].(string); ok { + return fmt.Errorf("šŸ“‚ \033[31mInvalid file path:\033[0m \033[36m%s\033[0m\n šŸ’” Check the file path and try again", path) + } + return fmt.Errorf("šŸ“‚ \033[31mInvalid file path\033[0m\n šŸ’” Check the file path and try again") + + default: + // For unknown LnkError types, preserve original message but add context + return fmt.Errorf("āš ļø \033[31m%s\033[0m", lnkErr.Error()) + } +} + +// wrapServiceError wraps service errors with consistent messaging while preserving specific errors for tests +func wrapServiceError(operation string, err error) error { + if err == nil { + return nil + } + + // For typed errors, format them nicely + var lnkErr *errors.LnkError + if stderrors.As(err, &lnkErr) { + return formatLnkError(lnkErr) + } + + // For other errors, provide operation context but preserve original message for tests + return fmt.Errorf("failed to %s: %w", operation, err) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..71de078 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,232 @@ +package config + +import ( + "context" + "os" + "sort" + "strings" + "time" + + "github.com/yarlson/lnk/internal/errors" + "github.com/yarlson/lnk/internal/fs" + "github.com/yarlson/lnk/internal/models" + "github.com/yarlson/lnk/internal/pathresolver" +) + +// Config implements the service.ConfigManager interface +type Config struct { + fileManager *fs.FileManager + pathResolver *pathresolver.Resolver +} + +// New creates a new ConfigManager instance +func New(fileManager *fs.FileManager, pathResolver *pathresolver.Resolver) *Config { + return &Config{ + fileManager: fileManager, + pathResolver: pathResolver, + } +} + +// LoadHostConfig loads the configuration for a specific host +func (cm *Config) LoadHostConfig(ctx context.Context, repoPath, host string) (*models.HostConfig, error) { + managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host) + if err != nil { + return nil, err + } + + return &models.HostConfig{ + Name: host, + ManagedFiles: managedFiles, + LastUpdate: time.Now(), + }, nil +} + +// SaveHostConfig saves the configuration for a specific host +func (cm *Config) SaveHostConfig(ctx context.Context, repoPath string, config *models.HostConfig) error { + // Convert managed files to relative paths for storage + var relativePaths []string + for _, file := range config.ManagedFiles { + relativePaths = append(relativePaths, file.RelativePath) + } + + // Sort for consistent ordering + sort.Strings(relativePaths) + + return cm.writeManagedItems(ctx, repoPath, config.Name, relativePaths) +} + +// AddManagedFileToHost adds a managed file to a host's configuration +func (cm *Config) AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error { + // Get current managed files + managedFiles, err := cm.getManagedItems(ctx, repoPath, host) + if err != nil { + return err + } + + // Check if already exists + for _, item := range managedFiles { + if item == file.RelativePath { + return nil // Already managed + } + } + + // Add new item + managedFiles = append(managedFiles, file.RelativePath) + + // Sort for consistent ordering + sort.Strings(managedFiles) + + return cm.writeManagedItems(ctx, repoPath, host, managedFiles) +} + +// RemoveManagedFileFromHost removes a managed file from a host's configuration +func (cm *Config) RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error { + // Get current managed files + managedFiles, err := cm.getManagedItems(ctx, repoPath, host) + if err != nil { + return err + } + + // Remove item + var newManagedFiles []string + for _, item := range managedFiles { + if item != relativePath { + newManagedFiles = append(newManagedFiles, item) + } + } + + return cm.writeManagedItems(ctx, repoPath, host, newManagedFiles) +} + +// ListManagedFiles returns all files managed by a specific host +func (cm *Config) ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) { + relativePaths, err := cm.getManagedItems(ctx, repoPath, host) + if err != nil { + return nil, err + } + + var managedFiles []models.ManagedFile + for _, relativePath := range relativePaths { + // Get file storage path + fileStoragePath, err := cm.pathResolver.GetFileStoragePathInRepo(repoPath, host, relativePath) + if err != nil { + return nil, errors.NewConfigNotFoundError(host). + WithContext("relative_path", relativePath) + } + + // Get original path (where symlink should be) + originalPath, err := cm.pathResolver.GetAbsolutePathInHome(relativePath) + if err != nil { + return nil, errors.NewInvalidPathError(relativePath, "cannot convert to absolute path") + } + + // Check if file exists and get info + var isDirectory bool + var mode os.FileMode + if exists, err := cm.fileManager.Exists(ctx, fileStoragePath); err == nil && exists { + if info, err := cm.fileManager.Stat(ctx, fileStoragePath); err == nil { + isDirectory = info.IsDir() + mode = info.Mode() + } + } + + managedFile := models.ManagedFile{ + OriginalPath: originalPath, + RepoPath: fileStoragePath, + RelativePath: relativePath, + Host: host, + IsDirectory: isDirectory, + Mode: mode, + } + + managedFiles = append(managedFiles, managedFile) + } + + return managedFiles, nil +} + +// GetManagedFile retrieves a specific managed file by relative path +func (cm *Config) GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) { + managedFiles, err := cm.ListManagedFiles(ctx, repoPath, host) + if err != nil { + return nil, err + } + + for _, file := range managedFiles { + if file.RelativePath == relativePath { + return &file, nil + } + } + + return nil, errors.NewFileNotFoundError(relativePath) +} + +// ConfigExists checks if a configuration file exists for the host +func (cm *Config) ConfigExists(ctx context.Context, repoPath, host string) (bool, error) { + trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host) + if err != nil { + return false, err + } + + return cm.fileManager.Exists(ctx, trackingFilePath) +} + +// getManagedItems returns the list of managed files and directories from .lnk file +// This is the core method that reads the plain text format +func (cm *Config) getManagedItems(ctx context.Context, repoPath, host string) ([]string, error) { + trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host) + if err != nil { + return nil, errors.NewConfigNotFoundError(host). + WithContext("repo_path", repoPath) + } + + // If .lnk file doesn't exist, return empty list + exists, err := cm.fileManager.Exists(ctx, trackingFilePath) + if err != nil { + return nil, errors.NewFileSystemOperationError("check_exists", trackingFilePath, err) + } + if !exists { + return []string{}, nil + } + + content, err := cm.fileManager.ReadFile(ctx, trackingFilePath) + if err != nil { + return nil, errors.NewFileSystemOperationError("read", trackingFilePath, err) + } + + if len(content) == 0 { + return []string{}, nil + } + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + var items []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + items = append(items, line) + } + } + + return items, nil +} + +// writeManagedItems writes the list of managed items to .lnk file +// This maintains the plain text line-by-line format for compatibility +func (cm *Config) writeManagedItems(ctx context.Context, repoPath, host string, items []string) error { + trackingFilePath, err := cm.pathResolver.GetTrackingFilePath(repoPath, host) + if err != nil { + return errors.NewConfigNotFoundError(host). + WithContext("repo_path", repoPath) + } + + content := strings.Join(items, "\n") + if len(items) > 0 { + content += "\n" + } + + if err := cm.fileManager.WriteFile(ctx, trackingFilePath, []byte(content), 0644); err != nil { + return errors.NewFileSystemOperationError("write", trackingFilePath, err) + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d6c5bc4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,278 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/yarlson/lnk/internal/errors" + "github.com/yarlson/lnk/internal/fs" + "github.com/yarlson/lnk/internal/models" + "github.com/yarlson/lnk/internal/pathresolver" +) + +type ConfigTestSuite struct { + suite.Suite + tempDir string + configManager *Config + fileManager *fs.FileManager + pathResolver *pathresolver.Resolver + ctx context.Context +} + +func (suite *ConfigTestSuite) SetupTest() { + // Create temp directory for testing + tempDir, err := os.MkdirTemp("", "lnk_config_test_*") + suite.Require().NoError(err) + suite.tempDir = tempDir + + // Create file manager and path resolver + suite.fileManager = fs.New() + suite.pathResolver = pathresolver.New() + + // Create config manager + suite.configManager = New(suite.fileManager, suite.pathResolver) + + // Create context + suite.ctx = context.Background() +} + +func (suite *ConfigTestSuite) TearDownTest() { + err := os.RemoveAll(suite.tempDir) + suite.Require().NoError(err) +} + +func (suite *ConfigTestSuite) TestAddAndListManagedFiles() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "testhost" + + // Create a managed file + managedFile := models.ManagedFile{ + RelativePath: ".vimrc", + Host: host, + IsDirectory: false, + } + + // Add managed file + err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile) + suite.NoError(err) + + // List managed files + files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host) + suite.NoError(err) + + suite.Len(files, 1) + suite.Equal(".vimrc", files[0].RelativePath) + suite.Equal(host, files[0].Host) + + // Verify tracking file was created + trackingPath, err := suite.pathResolver.GetTrackingFilePath(repoPath, host) + suite.NoError(err) + + exists, err := suite.fileManager.Exists(suite.ctx, trackingPath) + suite.NoError(err) + suite.True(exists) + + // Read tracking file content + content, err := suite.fileManager.ReadFile(suite.ctx, trackingPath) + suite.NoError(err) + + expectedContent := ".vimrc\n" + suite.Equal(expectedContent, string(content)) +} + +func (suite *ConfigTestSuite) TestAddDuplicateFile() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "testhost" + + managedFile := models.ManagedFile{ + RelativePath: ".bashrc", + Host: host, + } + + // Add file twice + err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile) + suite.NoError(err) + + err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile) + suite.NoError(err) + + // Should still have only one file + files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host) + suite.NoError(err) + suite.Len(files, 1) +} + +func (suite *ConfigTestSuite) TestRemoveManagedFile() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "testhost" + + // Add two managed files + file1 := models.ManagedFile{RelativePath: ".vimrc", Host: host} + file2 := models.ManagedFile{RelativePath: ".bashrc", Host: host} + + err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file1) + suite.NoError(err) + + err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, file2) + suite.NoError(err) + + // Remove one file + err = suite.configManager.RemoveManagedFileFromHost(suite.ctx, repoPath, host, ".vimrc") + suite.NoError(err) + + // Should have only one file left + files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host) + suite.NoError(err) + + suite.Len(files, 1) + suite.Equal(".bashrc", files[0].RelativePath) +} + +func (suite *ConfigTestSuite) TestLoadAndSaveHostConfig() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "workstation" + + // Create host config with managed files + config := &models.HostConfig{ + Name: host, + ManagedFiles: []models.ManagedFile{ + {RelativePath: ".vimrc", Host: host}, + {RelativePath: ".bashrc", Host: host}, + }, + LastUpdate: time.Now(), + } + + // Save config + err := suite.configManager.SaveHostConfig(suite.ctx, repoPath, config) + suite.NoError(err) + + // Load config + loadedConfig, err := suite.configManager.LoadHostConfig(suite.ctx, repoPath, host) + suite.NoError(err) + + suite.Equal(host, loadedConfig.Name) + suite.Len(loadedConfig.ManagedFiles, 2) + + // Check files are sorted + suite.Equal(".bashrc", loadedConfig.ManagedFiles[0].RelativePath) + suite.Equal(".vimrc", loadedConfig.ManagedFiles[1].RelativePath) +} + +func (suite *ConfigTestSuite) TestGetManagedFile() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "testhost" + + managedFile := models.ManagedFile{ + RelativePath: ".gitconfig", + Host: host, + } + + // Add managed file + err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile) + suite.NoError(err) + + // Get specific managed file + file, err := suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".gitconfig") + suite.NoError(err) + suite.Equal(".gitconfig", file.RelativePath) + + // Try to get non-existent file + _, err = suite.configManager.GetManagedFile(suite.ctx, repoPath, host, ".nonexistent") + suite.Error(err) + suite.True(errors.NewFileNotFoundError("").Is(err)) +} + +func (suite *ConfigTestSuite) TestConfigExists() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "testhost" + + // Initially should not exist + exists, err := suite.configManager.ConfigExists(suite.ctx, repoPath, host) + suite.NoError(err) + suite.False(exists) + + // Add a managed file + managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host} + err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile) + suite.NoError(err) + + // Now should exist + exists, err = suite.configManager.ConfigExists(suite.ctx, repoPath, host) + suite.NoError(err) + suite.True(exists) +} + +func (suite *ConfigTestSuite) TestEmptyConfig() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "emptyhost" + + // List files from non-existent config + files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host) + suite.NoError(err) + suite.Len(files, 0) +} + +func (suite *ConfigTestSuite) TestCommonAndHostConfigs() { + repoPath := filepath.Join(suite.tempDir, "repo") + + // Add file to common config (empty host) + commonFile := models.ManagedFile{RelativePath: ".bashrc", Host: ""} + err := suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "", commonFile) + suite.NoError(err) + + // Add file to host-specific config + hostFile := models.ManagedFile{RelativePath: ".vimrc", Host: "workstation"} + err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, "workstation", hostFile) + suite.NoError(err) + + // List common files + commonFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "") + suite.NoError(err) + suite.Len(commonFiles, 1) + suite.Equal(".bashrc", commonFiles[0].RelativePath) + + // List host files + hostFiles, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, "workstation") + suite.NoError(err) + suite.Len(hostFiles, 1) + suite.Equal(".vimrc", hostFiles[0].RelativePath) +} + +func (suite *ConfigTestSuite) TestFileWithMetadata() { + repoPath := filepath.Join(suite.tempDir, "repo") + host := "testhost" + + // Create actual file in repository storage area + hostStoragePath := filepath.Join(repoPath, host+".lnk") + testFilePath := filepath.Join(hostStoragePath, ".vimrc") + + err := suite.fileManager.WriteFile(suite.ctx, testFilePath, []byte("set number"), 0644) + suite.NoError(err) + + // Add managed file + managedFile := models.ManagedFile{RelativePath: ".vimrc", Host: host} + err = suite.configManager.AddManagedFileToHost(suite.ctx, repoPath, host, managedFile) + suite.NoError(err) + + // List files should include metadata + files, err := suite.configManager.ListManagedFiles(suite.ctx, repoPath, host) + suite.NoError(err) + suite.Len(files, 1) + + file := files[0] + suite.False(file.IsDirectory) + suite.NotZero(file.Mode) + + // Expected paths + expectedRepoPath := testFilePath + suite.Equal(expectedRepoPath, file.RepoPath) +} + +func TestConfigSuite(t *testing.T) { + suite.Run(t, new(ConfigTestSuite)) +} diff --git a/internal/core/lnk.go b/internal/core/lnk.go deleted file mode 100644 index bc49efa..0000000 --- a/internal/core/lnk.go +++ /dev/null @@ -1,674 +0,0 @@ -package core - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/yarlson/lnk/internal/fs" - "github.com/yarlson/lnk/internal/git" -) - -// Lnk represents the main application logic -type Lnk struct { - repoPath string - host string // Host-specific configuration - git *git.Git - fs *fs.FileSystem -} - -// NewLnk creates a new Lnk instance for common configuration -func NewLnk() *Lnk { - repoPath := getRepoPath() - return &Lnk{ - repoPath: repoPath, - host: "", // Empty host means common configuration - git: git.New(repoPath), - fs: fs.New(), - } -} - -// NewLnkWithHost creates a new Lnk instance for host-specific configuration -func NewLnkWithHost(host string) *Lnk { - repoPath := getRepoPath() - return &Lnk{ - repoPath: repoPath, - host: host, - git: git.New(repoPath), - fs: fs.New(), - } -} - -// GetCurrentHostname returns the current system hostname -func GetCurrentHostname() (string, error) { - hostname, err := os.Hostname() - if err != nil { - return "", fmt.Errorf("failed to get hostname: %w", err) - } - return hostname, nil -} - -// getRepoPath returns the path to the lnk repository directory -func getRepoPath() string { - xdgConfig := os.Getenv("XDG_CONFIG_HOME") - if xdgConfig == "" { - homeDir, err := os.UserHomeDir() - if err != nil { - // Fallback to current directory if we can't get home - xdgConfig = "." - } else { - xdgConfig = filepath.Join(homeDir, ".config") - } - } - return filepath.Join(xdgConfig, "lnk") -} - -// getHostStoragePath returns the storage path for host-specific or common files -func (l *Lnk) getHostStoragePath() string { - if l.host == "" { - // Common configuration - store in root of repo - return l.repoPath - } - // Host-specific configuration - store in host subdirectory - return filepath.Join(l.repoPath, l.host+".lnk") -} - -// getLnkFileName returns the appropriate .lnk tracking file name -func (l *Lnk) getLnkFileName() string { - if l.host == "" { - return ".lnk" - } - return ".lnk." + l.host -} - -// getRelativePath converts an absolute path to a relative path from home directory -func getRelativePath(absPath string) (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - - // Check if the file is under home directory - relPath, err := filepath.Rel(homeDir, absPath) - if err != nil { - return "", fmt.Errorf("failed to get relative path: %w", err) - } - - // If the relative path starts with "..", the file is outside home directory - // In this case, use the absolute path as relative (without the leading slash) - if strings.HasPrefix(relPath, "..") { - // Use absolute path but remove leading slash and drive letter (for cross-platform) - cleanPath := strings.TrimPrefix(absPath, "/") - return cleanPath, nil - } - - return relPath, nil -} - -// Init initializes the lnk repository -func (l *Lnk) Init() error { - return l.InitWithRemote("") -} - -// InitWithRemote initializes the lnk repository, optionally cloning from a remote -func (l *Lnk) InitWithRemote(remoteURL string) error { - if remoteURL != "" { - // Clone from remote - return l.Clone(remoteURL) - } - - // Create the repository directory - if err := os.MkdirAll(l.repoPath, 0755); err != nil { - return fmt.Errorf("failed to create lnk directory: %w", err) - } - - // Check if there's already a Git repository - if l.git.IsGitRepository() { - // Repository exists, check if it's a lnk repository - if l.git.IsLnkRepository() { - // It's a lnk repository, init is idempotent - do nothing - return nil - } else { - // It's not a lnk repository, error to prevent data loss - return fmt.Errorf("āŒ Directory \033[31m%s\033[0m contains an existing Git repository\n šŸ’” Please backup or move the existing repository before initializing lnk", l.repoPath) - } - } - - // No existing repository, initialize Git repository - if err := l.git.Init(); err != nil { - return fmt.Errorf("failed to initialize git repository: %w", err) - } - - return nil -} - -// Clone clones a repository from the given URL -func (l *Lnk) Clone(url string) error { - if err := l.git.Clone(url); err != nil { - return fmt.Errorf("failed to clone repository: %w", err) - } - return nil -} - -// AddRemote adds a remote to the repository -func (l *Lnk) AddRemote(name, url string) error { - if err := l.git.AddRemote(name, url); err != nil { - return fmt.Errorf("failed to add remote %s: %w", name, err) - } - return nil -} - -// Add moves a file or directory to the repository and creates a symlink -func (l *Lnk) Add(filePath string) error { - // Validate the file or directory - if err := l.fs.ValidateFileForAdd(filePath); err != nil { - return err - } - - // Get absolute path - absPath, err := filepath.Abs(filePath) - if err != nil { - return fmt.Errorf("failed to get absolute path: %w", err) - } - - // Get relative path for tracking - relativePath, err := getRelativePath(absPath) - if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) - } - - // Generate repository path from relative path - storagePath := l.getHostStoragePath() - destPath := filepath.Join(storagePath, relativePath) - - // Ensure destination directory exists (including parent directories for host-specific files) - destDir := filepath.Dir(destPath) - if err := os.MkdirAll(destDir, 0755); err != nil { - return fmt.Errorf("failed to create destination directory: %w", 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) - } - } - - // Check if it's a directory or file - info, err := os.Stat(absPath) - if err != nil { - return fmt.Errorf("failed to stat path: %w", err) - } - - // Move to repository (handles both files and directories) - if info.IsDir() { - if err := l.fs.MoveDirectory(absPath, destPath); err != nil { - return fmt.Errorf("failed to move directory to repository: %w", err) - } - } else { - if err := l.fs.MoveFile(absPath, destPath); err != nil { - return fmt.Errorf("failed to move file to repository: %w", err) - } - } - - // Create symlink - if err := l.fs.CreateSymlink(destPath, absPath); err != nil { - // Try to restore the original if symlink creation fails - if info.IsDir() { - _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup - } else { - _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup - } - return fmt.Errorf("failed to create symlink: %w", err) - } - - // Add to .lnk tracking file using relative path - if err := l.addManagedItem(relativePath); err != nil { - // Try to restore the original state if tracking fails - _ = os.Remove(absPath) // Ignore error in cleanup - if info.IsDir() { - _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup - } else { - _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup - } - return fmt.Errorf("failed to update tracking file: %w", err) - } - - // Add both the item and .lnk file to git in a single commit - // 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 { - // Try to restore the original state if git add fails - _ = os.Remove(absPath) // Ignore error in cleanup - _ = l.removeManagedItem(relativePath) // Ignore error in cleanup - if info.IsDir() { - _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup - } else { - _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup - } - return fmt.Errorf("failed to add item to git: %w", err) - } - - // Add .lnk file to the same commit - if err := l.git.Add(l.getLnkFileName()); err != nil { - // Try to restore the original state if git add fails - _ = os.Remove(absPath) // Ignore error in cleanup - _ = l.removeManagedItem(relativePath) // Ignore error in cleanup - if info.IsDir() { - _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup - } else { - _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup - } - return fmt.Errorf("failed to add .lnk file to git: %w", err) - } - - // Commit both changes together - basename := filepath.Base(relativePath) - if err := l.git.Commit(fmt.Sprintf("lnk: added %s", basename)); err != nil { - // Try to restore the original state if commit fails - _ = os.Remove(absPath) // Ignore error in cleanup - _ = l.removeManagedItem(relativePath) // Ignore error in cleanup - if info.IsDir() { - _ = l.fs.MoveDirectory(destPath, absPath) // Ignore error in cleanup - } else { - _ = l.fs.MoveFile(destPath, absPath) // Ignore error in cleanup - } - return fmt.Errorf("failed to commit changes: %w", err) - } - - return nil -} - -// Remove removes a symlink and restores the original file or directory -func (l *Lnk) Remove(filePath string) error { - // Get absolute path - absPath, err := filepath.Abs(filePath) - if err != nil { - return fmt.Errorf("failed to get absolute path: %w", err) - } - - // Validate that this is a symlink managed by lnk - if err := l.fs.ValidateSymlinkForRemove(absPath, l.repoPath); err != nil { - return err - } - - // Get relative path for tracking - relativePath, err := getRelativePath(absPath) - if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) - } - - // Check if this relative path is managed - managedItems, err := l.getManagedItems() - if err != nil { - return fmt.Errorf("failed to get managed items: %w", err) - } - - found := false - for _, item := range managedItems { - if item == relativePath { - found = true - break - } - } - if !found { - return fmt.Errorf("āŒ File is not managed by lnk: \033[31m%s\033[0m", relativePath) - } - - // Get the target path in the repository - target, err := os.Readlink(absPath) - if err != nil { - return fmt.Errorf("failed to read symlink: %w", err) - } - - // Convert relative path to absolute if needed - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(absPath), target) - } - - // Check if target is a directory or file - info, err := os.Stat(target) - if err != nil { - return fmt.Errorf("failed to stat target: %w", err) - } - - // Remove the symlink - if err := os.Remove(absPath); err != nil { - return fmt.Errorf("failed to remove symlink: %w", err) - } - - // Remove from .lnk tracking file using relative path - if err := l.removeManagedItem(relativePath); err != nil { - return fmt.Errorf("failed to update tracking file: %w", err) - } - - // Generate the correct git path for removal - gitPath := relativePath - if l.host != "" { - gitPath = filepath.Join(l.host+".lnk", relativePath) - } - if err := l.git.Remove(gitPath); err != nil { - return fmt.Errorf("failed to remove from git: %w", err) - } - - // Add .lnk file to the same commit - if err := l.git.Add(l.getLnkFileName()); err != nil { - return fmt.Errorf("failed to add .lnk file to git: %w", err) - } - - // Commit both changes together - basename := filepath.Base(relativePath) - if err := l.git.Commit(fmt.Sprintf("lnk: removed %s", basename)); err != nil { - return fmt.Errorf("failed to commit changes: %w", err) - } - - // Move back from repository (handles both files and directories) - if info.IsDir() { - if err := l.fs.MoveDirectory(target, absPath); err != nil { - return fmt.Errorf("failed to restore directory: %w", err) - } - } else { - if err := l.fs.MoveFile(target, absPath); err != nil { - return fmt.Errorf("failed to restore file: %w", err) - } - } - - return nil -} - -// GetCommits returns the list of commits for testing purposes -func (l *Lnk) GetCommits() ([]string, error) { - return l.git.GetCommits() -} - -// StatusInfo contains repository sync status information -type StatusInfo struct { - Ahead int - Behind int - Remote string - Dirty bool -} - -// Status returns the repository sync status -func (l *Lnk) Status() (*StatusInfo, error) { - // Check if repository is initialized - if !l.git.IsGitRepository() { - return nil, fmt.Errorf("āŒ Lnk repository not initialized\n šŸ’” Run \033[1mlnk init\033[0m first") - } - - gitStatus, err := l.git.GetStatus() - if err != nil { - return nil, fmt.Errorf("failed to get repository status: %w", err) - } - - return &StatusInfo{ - Ahead: gitStatus.Ahead, - Behind: gitStatus.Behind, - Remote: gitStatus.Remote, - Dirty: gitStatus.Dirty, - }, nil -} - -// Push stages all changes and creates a sync commit, then pushes to remote -func (l *Lnk) Push(message string) error { - // Check if repository is initialized - if !l.git.IsGitRepository() { - return fmt.Errorf("āŒ Lnk repository not initialized\n šŸ’” Run \033[1mlnk init\033[0m first") - } - - // Check if there are any changes - hasChanges, err := l.git.HasChanges() - if err != nil { - return fmt.Errorf("failed to check for changes: %w", err) - } - - if hasChanges { - // Stage all changes - if err := l.git.AddAll(); err != nil { - return fmt.Errorf("failed to stage changes: %w", err) - } - - // Create a sync commit - if err := l.git.Commit(message); err != nil { - return fmt.Errorf("failed to commit changes: %w", err) - } - } - - // Push to remote (this will be a no-op in tests since we don't have real remotes) - // In real usage, this would push to the actual remote repository - if err := l.git.Push(); err != nil { - return fmt.Errorf("failed to push to remote: %w", err) - } - - return nil -} - -// Pull fetches changes from remote and restores symlinks as needed -func (l *Lnk) Pull() ([]string, error) { - // Check if repository is initialized - if !l.git.IsGitRepository() { - return nil, fmt.Errorf("āŒ Lnk repository not initialized\n šŸ’” Run \033[1mlnk init\033[0m first") - } - - // Pull changes from remote (this will be a no-op in tests since we don't have real remotes) - if err := l.git.Pull(); err != nil { - return nil, fmt.Errorf("failed to pull from remote: %w", err) - } - - // Find all managed files in the repository and restore symlinks - restored, err := l.RestoreSymlinks() - if err != nil { - return nil, fmt.Errorf("failed to restore symlinks: %w", err) - } - - return restored, nil -} - -// List returns the list of files and directories currently managed by lnk -func (l *Lnk) List() ([]string, error) { - // Check if repository is initialized - if !l.git.IsGitRepository() { - return nil, fmt.Errorf("āŒ Lnk repository not initialized\n šŸ’” Run \033[1mlnk init\033[0m first") - } - - // Get managed items from .lnk file - managedItems, err := l.getManagedItems() - if err != nil { - return nil, fmt.Errorf("failed to get managed items: %w", err) - } - - return managedItems, nil -} - -// RestoreSymlinks finds all managed items from .lnk file and ensures they have proper symlinks -func (l *Lnk) RestoreSymlinks() ([]string, error) { - var restored []string - - // Get managed items from .lnk file (now containing relative paths) - managedItems, err := l.getManagedItems() - if err != nil { - return nil, fmt.Errorf("failed to get managed items: %w", err) - } - - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get home directory: %w", err) - } - - for _, relativePath := range managedItems { - // Generate repository name from relative path - storagePath := l.getHostStoragePath() - repoItem := filepath.Join(storagePath, relativePath) - - // Check if item exists in repository - if _, err := os.Stat(repoItem); os.IsNotExist(err) { - continue // Skip missing items - } - - // Determine where the symlink should be created - symlinkPath := filepath.Join(homeDir, relativePath) - - // Check if symlink already exists and is correct - if l.isValidSymlink(symlinkPath, repoItem) { - continue - } - - // Ensure parent directory exists - symlinkDir := filepath.Dir(symlinkPath) - if err := os.MkdirAll(symlinkDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create directory %s: %w", symlinkDir, err) - } - - // Remove existing file/symlink if it exists - if _, err := os.Lstat(symlinkPath); err == nil { - if err := os.RemoveAll(symlinkPath); err != nil { - return nil, fmt.Errorf("failed to remove existing item %s: %w", symlinkPath, err) - } - } - - // Create symlink - if err := l.fs.CreateSymlink(repoItem, symlinkPath); err != nil { - return nil, fmt.Errorf("failed to create symlink for %s: %w", relativePath, err) - } - - restored = append(restored, relativePath) - } - - return restored, nil -} - -// isValidSymlink checks if the given path is a symlink pointing to the expected target -func (l *Lnk) isValidSymlink(symlinkPath, expectedTarget string) bool { - info, err := os.Lstat(symlinkPath) - if err != nil { - return false - } - - // Check if it's a symlink - if info.Mode()&os.ModeSymlink == 0 { - return false - } - - // Check if it points to the correct target - target, err := os.Readlink(symlinkPath) - if err != nil { - return false - } - - // Convert relative path to absolute if needed - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(symlinkPath), target) - } - - // Clean both paths for comparison - targetAbs, err := filepath.Abs(target) - if err != nil { - return false - } - - expectedAbs, err := filepath.Abs(expectedTarget) - if err != nil { - return false - } - - return targetAbs == expectedAbs -} - -// getManagedItems returns the list of managed files and directories from .lnk file -func (l *Lnk) getManagedItems() ([]string, error) { - lnkFile := filepath.Join(l.repoPath, l.getLnkFileName()) - - // If .lnk file doesn't exist, return empty list - if _, err := os.Stat(lnkFile); os.IsNotExist(err) { - return []string{}, nil - } - - content, err := os.ReadFile(lnkFile) - if err != nil { - return nil, fmt.Errorf("failed to read .lnk file: %w", err) - } - - if len(content) == 0 { - return []string{}, nil - } - - lines := strings.Split(strings.TrimSpace(string(content)), "\n") - var items []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" { - items = append(items, line) - } - } - - return items, nil -} - -// addManagedItem adds an item to the .lnk tracking file -func (l *Lnk) addManagedItem(relativePath string) error { - // Get current items - items, err := l.getManagedItems() - if err != nil { - return fmt.Errorf("failed to get managed items: %w", err) - } - - // Check if already exists - for _, item := range items { - if item == relativePath { - return nil // Already managed - } - } - - // Add new item using relative path - items = append(items, relativePath) - - // Sort for consistent ordering - sort.Strings(items) - - return l.writeManagedItems(items) -} - -// removeManagedItem removes an item from the .lnk tracking file -func (l *Lnk) removeManagedItem(relativePath string) error { - // Get current items - items, err := l.getManagedItems() - if err != nil { - return fmt.Errorf("failed to get managed items: %w", err) - } - - // Remove item using relative path - var newItems []string - for _, item := range items { - if item != relativePath { - newItems = append(newItems, item) - } - } - - return l.writeManagedItems(newItems) -} - -// writeManagedItems writes the list of managed items to .lnk file -func (l *Lnk) writeManagedItems(items []string) error { - lnkFile := filepath.Join(l.repoPath, l.getLnkFileName()) - - content := strings.Join(items, "\n") - if len(items) > 0 { - content += "\n" - } - - err := os.WriteFile(lnkFile, []byte(content), 0644) - if err != nil { - return fmt.Errorf("failed to write .lnk file: %w", err) - } - - return nil -} diff --git a/internal/core/lnk_test.go b/internal/core/lnk_test.go deleted file mode 100644 index 1b3e1fa..0000000 --- a/internal/core/lnk_test.go +++ /dev/null @@ -1,754 +0,0 @@ -package core - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/suite" -) - -type CoreTestSuite struct { - suite.Suite - tempDir string - originalDir string - lnk *Lnk -} - -func (suite *CoreTestSuite) SetupTest() { - // Create temporary directory for each test - tempDir, err := os.MkdirTemp("", "lnk-test-*") - suite.Require().NoError(err) - suite.tempDir = tempDir - - // Change to temp directory - originalDir, err := os.Getwd() - suite.Require().NoError(err) - suite.originalDir = originalDir - - err = os.Chdir(tempDir) - suite.Require().NoError(err) - - // Set XDG_CONFIG_HOME to temp directory - suite.T().Setenv("XDG_CONFIG_HOME", tempDir) - - // Initialize Lnk instance - suite.lnk = NewLnk() -} - -func (suite *CoreTestSuite) TearDownTest() { - // Return to original directory - err := os.Chdir(suite.originalDir) - suite.Require().NoError(err) - - // Clean up temp directory - err = os.RemoveAll(suite.tempDir) - suite.Require().NoError(err) -} - -// Test core initialization functionality -func (suite *CoreTestSuite) TestCoreInit() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Check that the lnk directory was created - lnkDir := filepath.Join(suite.tempDir, "lnk") - suite.DirExists(lnkDir) - - // Check that Git repo was initialized - gitDir := filepath.Join(lnkDir, ".git") - suite.DirExists(gitDir) -} - -// Test core add/remove functionality with files -func (suite *CoreTestSuite) TestCoreFileOperations() { - // Initialize first - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create a test file - testFile := filepath.Join(suite.tempDir, ".bashrc") - content := "export PATH=$PATH:/usr/local/bin" - err = os.WriteFile(testFile, []byte(content), 0644) - suite.Require().NoError(err) - - // Add the file - err = suite.lnk.Add(testFile) - suite.Require().NoError(err) - - // Verify symlink and repo file - info, err := os.Lstat(testFile) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) - - // 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") - suite.FileExists(repoFile) - - // Verify content is preserved - repoContent, err := os.ReadFile(repoFile) - suite.Require().NoError(err) - suite.Equal(content, string(repoContent)) - - // Test remove - err = suite.lnk.Remove(testFile) - suite.Require().NoError(err) - - // Verify symlink is gone and regular file is restored - info, err = os.Lstat(testFile) - suite.Require().NoError(err) - suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink - - // Verify content is preserved - restoredContent, err := os.ReadFile(testFile) - suite.Require().NoError(err) - suite.Equal(content, string(restoredContent)) -} - -// Test core add/remove functionality with directories -func (suite *CoreTestSuite) TestCoreDirectoryOperations() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create a directory with files - testDir := filepath.Join(suite.tempDir, "testdir") - err = os.MkdirAll(testDir, 0755) - suite.Require().NoError(err) - - testFile := filepath.Join(testDir, "config.txt") - content := "test config" - err = os.WriteFile(testFile, []byte(content), 0644) - suite.Require().NoError(err) - - // Add the directory - err = suite.lnk.Add(testDir) - suite.Require().NoError(err) - - // Verify directory is now a symlink - info, err := os.Lstat(testDir) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) - - // 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") - suite.DirExists(repoDir) - - // Remove the directory - err = suite.lnk.Remove(testDir) - suite.Require().NoError(err) - - // Verify symlink is gone and regular directory is restored - info, err = os.Lstat(testDir) - suite.Require().NoError(err) - suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink - suite.True(info.IsDir()) // Is a directory - - // Verify content is preserved - restoredContent, err := os.ReadFile(testFile) - suite.Require().NoError(err) - suite.Equal(content, string(restoredContent)) -} - -// Test .lnk file tracking functionality -func (suite *CoreTestSuite) TestLnkFileTracking() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add multiple items - testFile := filepath.Join(suite.tempDir, ".bashrc") - err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) - suite.Require().NoError(err) - err = suite.lnk.Add(testFile) - suite.Require().NoError(err) - - testDir := filepath.Join(suite.tempDir, ".ssh") - err = os.MkdirAll(testDir, 0700) - suite.Require().NoError(err) - configFile := filepath.Join(testDir, "config") - err = os.WriteFile(configFile, []byte("Host example.com"), 0600) - suite.Require().NoError(err) - err = suite.lnk.Add(testDir) - suite.Require().NoError(err) - - // Check .lnk file contains both entries - lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") - suite.FileExists(lnkFile) - - lnkContent, err := os.ReadFile(lnkFile) - suite.Require().NoError(err) - - lines := strings.Split(strings.TrimSpace(string(lnkContent)), "\n") - suite.Len(lines, 2) - - // The .lnk file now contains relative paths, not basenames - // Check that the content contains references to .bashrc and .ssh - content := string(lnkContent) - suite.Contains(content, ".bashrc", ".lnk file should contain reference to .bashrc") - suite.Contains(content, ".ssh", ".lnk file should contain reference to .ssh") - - // Remove one item and verify tracking is updated - err = suite.lnk.Remove(testFile) - suite.Require().NoError(err) - - lnkContent, err = os.ReadFile(lnkFile) - suite.Require().NoError(err) - - lines = strings.Split(strings.TrimSpace(string(lnkContent)), "\n") - suite.Len(lines, 1) - - content = string(lnkContent) - suite.Contains(content, ".ssh", ".lnk file should still contain reference to .ssh") - suite.NotContains(content, ".bashrc", ".lnk file should not contain reference to .bashrc after removal") -} - -// Test XDG_CONFIG_HOME fallback -func (suite *CoreTestSuite) TestXDGConfigHomeFallback() { - // Test fallback to ~/.config/lnk when XDG_CONFIG_HOME is not set - suite.T().Setenv("XDG_CONFIG_HOME", "") - - homeDir := filepath.Join(suite.tempDir, "home") - err := os.MkdirAll(homeDir, 0755) - suite.Require().NoError(err) - suite.T().Setenv("HOME", homeDir) - - lnk := NewLnk() - err = lnk.Init() - suite.Require().NoError(err) - - // Check that the lnk directory was created under ~/.config/lnk - expectedDir := filepath.Join(homeDir, ".config", "lnk") - suite.DirExists(expectedDir) -} - -// Test symlink restoration (pull functionality) -func (suite *CoreTestSuite) TestSymlinkRestoration() { - _ = suite.lnk.Init() - - // Create a file in the repo directly (simulating a pull) - repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc") - content := "export PATH=$PATH:/usr/local/bin" - err := os.WriteFile(repoFile, []byte(content), 0644) - suite.Require().NoError(err) - - // Create .lnk file to track it - lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") - err = os.WriteFile(lnkFile, []byte(".bashrc\n"), 0644) - suite.Require().NoError(err) - - // Get home directory for the test - homeDir, err := os.UserHomeDir() - suite.Require().NoError(err) - - targetFile := filepath.Join(homeDir, ".bashrc") - - // Clean up the test file after the test - defer func() { - _ = os.Remove(targetFile) - }() - - // Test symlink restoration - restored, err := suite.lnk.RestoreSymlinks() - suite.Require().NoError(err) - - // Should have restored the symlink - suite.Len(restored, 1) - suite.Equal(".bashrc", restored[0]) - - // Check that file is now a symlink - info, err := os.Lstat(targetFile) - suite.NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) -} - -// Test error conditions -func (suite *CoreTestSuite) TestErrorConditions() { - // Test add nonexistent file - err := suite.lnk.Init() - suite.Require().NoError(err) - - err = suite.lnk.Add("/nonexistent/file") - suite.Error(err) - suite.Contains(err.Error(), "File does not exist") - - // Test remove unmanaged file - testFile := filepath.Join(suite.tempDir, ".regularfile") - err = os.WriteFile(testFile, []byte("content"), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Remove(testFile) - suite.Error(err) - suite.Contains(err.Error(), "File is not managed by lnk") - - // Test status without remote - _, err = suite.lnk.Status() - suite.Error(err) - suite.Contains(err.Error(), "no remote configured") -} - -// Test git operations -func (suite *CoreTestSuite) TestGitOperations() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add a file to create a commit - testFile := filepath.Join(suite.tempDir, ".bashrc") - content := "export PATH=$PATH:/usr/local/bin" - err = os.WriteFile(testFile, []byte(content), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Add(testFile) - suite.Require().NoError(err) - - // Check that Git commit was made - commits, err := suite.lnk.GetCommits() - suite.Require().NoError(err) - suite.Len(commits, 1) - suite.Contains(commits[0], "lnk: added .bashrc") - - // Test add remote - err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") - suite.Require().NoError(err) - - // Test status with remote - status, err := suite.lnk.Status() - suite.Require().NoError(err) - suite.Equal(1, status.Ahead) - suite.Equal(0, status.Behind) -} - -// Test edge case: files with same basename from different directories should be handled properly -func (suite *CoreTestSuite) TestSameBasenameFilesOverwrite() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create two directories with files having the same basename - dirA := filepath.Join(suite.tempDir, "a") - dirB := filepath.Join(suite.tempDir, "b") - err = os.MkdirAll(dirA, 0755) - suite.Require().NoError(err) - err = os.MkdirAll(dirB, 0755) - suite.Require().NoError(err) - - // Create files with same basename but different content - fileA := filepath.Join(dirA, "config.json") - fileB := filepath.Join(dirB, "config.json") - contentA := `{"name": "config_a"}` - contentB := `{"name": "config_b"}` - - err = os.WriteFile(fileA, []byte(contentA), 0644) - suite.Require().NoError(err) - err = os.WriteFile(fileB, []byte(contentB), 0644) - suite.Require().NoError(err) - - // Add first file - err = suite.lnk.Add(fileA) - suite.Require().NoError(err) - - // Verify first file is managed correctly and preserves content - info, err := os.Lstat(fileA) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) - - symlinkContentA, err := os.ReadFile(fileA) - suite.Require().NoError(err) - suite.Equal(contentA, string(symlinkContentA), "First file should preserve its original content") - - // Add second file - this should work without overwriting the first - err = suite.lnk.Add(fileB) - suite.Require().NoError(err) - - // Verify second file is managed - info, err = os.Lstat(fileB) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) - - // CORRECT BEHAVIOR: Both files should preserve their original content - symlinkContentA, err = os.ReadFile(fileA) - suite.Require().NoError(err) - symlinkContentB, err := os.ReadFile(fileB) - suite.Require().NoError(err) - - suite.Equal(contentA, string(symlinkContentA), "First file should keep its original content") - suite.Equal(contentB, string(symlinkContentB), "Second file should keep its original content") - - // Both files should be removable independently - err = suite.lnk.Remove(fileA) - suite.Require().NoError(err, "First file should be removable") - - // First file should be restored with correct content - info, err = os.Lstat(fileA) - suite.Require().NoError(err) - suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore - - restoredContentA, err := os.ReadFile(fileA) - suite.Require().NoError(err) - suite.Equal(contentA, string(restoredContentA), "Restored file should have original content") - - // Second file should still be manageable and removable - err = suite.lnk.Remove(fileB) - suite.Require().NoError(err, "Second file should also be removable without errors") - - // Second file should be restored with correct content - info, err = os.Lstat(fileB) - suite.Require().NoError(err) - suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink anymore - - restoredContentB, err := os.ReadFile(fileB) - suite.Require().NoError(err) - suite.Equal(contentB, string(restoredContentB), "Second restored file should have original content") -} - -// Test another variant: adding files with same basename should work correctly -func (suite *CoreTestSuite) TestSameBasenameSequentialAdd() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create subdirectories in different locations - configDir := filepath.Join(suite.tempDir, "config") - backupDir := filepath.Join(suite.tempDir, "backup") - err = os.MkdirAll(configDir, 0755) - suite.Require().NoError(err) - err = os.MkdirAll(backupDir, 0755) - suite.Require().NoError(err) - - // Create files with same basename (.bashrc) - configBashrc := filepath.Join(configDir, ".bashrc") - backupBashrc := filepath.Join(backupDir, ".bashrc") - - originalContent := "export PATH=/usr/local/bin:$PATH" - backupContent := "export PATH=/opt/bin:$PATH" - - err = os.WriteFile(configBashrc, []byte(originalContent), 0644) - suite.Require().NoError(err) - err = os.WriteFile(backupBashrc, []byte(backupContent), 0644) - suite.Require().NoError(err) - - // Add first .bashrc - err = suite.lnk.Add(configBashrc) - suite.Require().NoError(err) - - // Add second .bashrc - should work without overwriting the first - err = suite.lnk.Add(backupBashrc) - suite.Require().NoError(err) - - // Check .lnk tracking file should track both properly - lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") - lnkContent, err := os.ReadFile(lnkFile) - suite.Require().NoError(err) - - // Both entries should be tracked and distinguishable - content := string(lnkContent) - suite.Contains(content, ".bashrc", "Both .bashrc files should be tracked") - - // Both files should maintain their distinct content - content1, err := os.ReadFile(configBashrc) - suite.Require().NoError(err) - content2, err := os.ReadFile(backupBashrc) - suite.Require().NoError(err) - - suite.Equal(originalContent, string(content1), "First file should keep original content") - suite.Equal(backupContent, string(content2), "Second file should keep its distinct content") - - // Both should be removable independently - err = suite.lnk.Remove(configBashrc) - suite.Require().NoError(err, "First .bashrc should be removable") - - err = suite.lnk.Remove(backupBashrc) - suite.Require().NoError(err, "Second .bashrc should be removable") -} - -// Test dirty repository status detection -func (suite *CoreTestSuite) TestStatusDetectsDirtyRepo() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add and commit a file - testFile := filepath.Join(suite.tempDir, "a") - err = os.WriteFile(testFile, []byte("abc"), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Add(testFile) - suite.Require().NoError(err) - - // Add a remote so status works - err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") - suite.Require().NoError(err) - - // Check status - should be clean but ahead of remote - status, err := suite.lnk.Status() - suite.Require().NoError(err) - suite.Equal(1, status.Ahead) - suite.Equal(0, status.Behind) - suite.False(status.Dirty, "Repository should not be dirty after commit") - - // Now edit the managed file (simulating the issue scenario) - err = os.WriteFile(testFile, []byte("def"), 0644) - suite.Require().NoError(err) - - // Check status again - should detect dirty state - status, err = suite.lnk.Status() - suite.Require().NoError(err) - suite.Equal(1, status.Ahead) - suite.Equal(0, status.Behind) - suite.True(status.Dirty, "Repository should be dirty after editing managed file") -} - -// Test list functionality -func (suite *CoreTestSuite) TestListManagedItems() { - // Test list without init - should fail - _, err := suite.lnk.List() - suite.Error(err) - suite.Contains(err.Error(), "Lnk repository not initialized") - - // Initialize repository - err = suite.lnk.Init() - suite.Require().NoError(err) - - // Test list with no managed files - items, err := suite.lnk.List() - suite.Require().NoError(err) - suite.Empty(items) - - // Add a file - testFile := filepath.Join(suite.tempDir, ".bashrc") - content := "export PATH=$PATH:/usr/local/bin" - err = os.WriteFile(testFile, []byte(content), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Add(testFile) - suite.Require().NoError(err) - - // Test list with one managed file - items, err = suite.lnk.List() - suite.Require().NoError(err) - suite.Len(items, 1) - suite.Contains(items[0], ".bashrc") - - // Add a directory - testDir := filepath.Join(suite.tempDir, ".config") - err = os.MkdirAll(testDir, 0755) - suite.Require().NoError(err) - configFile := filepath.Join(testDir, "app.conf") - err = os.WriteFile(configFile, []byte("setting=value"), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Add(testDir) - suite.Require().NoError(err) - - // Test list with multiple managed items - items, err = suite.lnk.List() - suite.Require().NoError(err) - suite.Len(items, 2) - - // Check that both items are present - found := make(map[string]bool) - for _, item := range items { - if strings.Contains(item, ".bashrc") { - found[".bashrc"] = true - } - if strings.Contains(item, ".config") { - found[".config"] = true - } - } - suite.True(found[".bashrc"], "Should contain .bashrc") - suite.True(found[".config"], "Should contain .config") - - // Remove one item and verify list is updated - err = suite.lnk.Remove(testFile) - suite.Require().NoError(err) - - items, err = suite.lnk.List() - suite.Require().NoError(err) - suite.Len(items, 1) - suite.Contains(items[0], ".config") -} - -// Test multihost functionality -func (suite *CoreTestSuite) TestMultihostFileOperations() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create test files for different hosts - testFile1 := filepath.Join(suite.tempDir, ".bashrc") - content1 := "export PATH=$PATH:/usr/local/bin" - err = os.WriteFile(testFile1, []byte(content1), 0644) - suite.Require().NoError(err) - - testFile2 := filepath.Join(suite.tempDir, ".vimrc") - content2 := "set number" - err = os.WriteFile(testFile2, []byte(content2), 0644) - suite.Require().NoError(err) - - // Add file to common configuration - commonLnk := NewLnk() - err = commonLnk.Add(testFile1) - suite.Require().NoError(err) - - // Add file to host-specific configuration - hostLnk := NewLnkWithHost("workstation") - err = hostLnk.Add(testFile2) - suite.Require().NoError(err) - - // Verify both files are symlinks - info1, err := os.Lstat(testFile1) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info1.Mode()&os.ModeSymlink) - - info2, err := os.Lstat(testFile2) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info2.Mode()&os.ModeSymlink) - - // Verify common configuration tracking - commonItems, err := commonLnk.List() - suite.Require().NoError(err) - suite.Len(commonItems, 1) - suite.Contains(commonItems[0], ".bashrc") - - // Verify host-specific configuration tracking - hostItems, err := hostLnk.List() - suite.Require().NoError(err) - suite.Len(hostItems, 1) - suite.Contains(hostItems[0], ".vimrc") - - // Verify files are stored in correct locations - lnkDir := filepath.Join(suite.tempDir, "lnk") - - // Common file should be in root - commonFile := filepath.Join(lnkDir, ".lnk") - suite.FileExists(commonFile) - - // Host-specific file should be in host subdirectory - hostDir := filepath.Join(lnkDir, "workstation.lnk") - suite.DirExists(hostDir) - - hostTrackingFile := filepath.Join(lnkDir, ".lnk.workstation") - suite.FileExists(hostTrackingFile) - - // Test removal - err = commonLnk.Remove(testFile1) - suite.Require().NoError(err) - - err = hostLnk.Remove(testFile2) - suite.Require().NoError(err) - - // Verify files are restored - info1, err = os.Lstat(testFile1) - suite.Require().NoError(err) - suite.Equal(os.FileMode(0), info1.Mode()&os.ModeSymlink) - - info2, err = os.Lstat(testFile2) - suite.Require().NoError(err) - suite.Equal(os.FileMode(0), info2.Mode()&os.ModeSymlink) -} - -// Test hostname detection -func (suite *CoreTestSuite) TestHostnameDetection() { - hostname, err := GetCurrentHostname() - suite.NoError(err) - suite.NotEmpty(hostname) -} - -// Test host-specific symlink restoration -func (suite *CoreTestSuite) TestMultihostSymlinkRestoration() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create files directly in host-specific storage (simulating a pull) - hostLnk := NewLnkWithHost("testhost") - - // Ensure host storage directory exists - hostStoragePath := hostLnk.getHostStoragePath() - err = os.MkdirAll(hostStoragePath, 0755) - suite.Require().NoError(err) - - // Create a file in host storage - repoFile := filepath.Join(hostStoragePath, ".bashrc") - content := "export HOST=testhost" - err = os.WriteFile(repoFile, []byte(content), 0644) - suite.Require().NoError(err) - - // Create host tracking file - trackingFile := filepath.Join(suite.tempDir, "lnk", ".lnk.testhost") - err = os.WriteFile(trackingFile, []byte(".bashrc\n"), 0644) - suite.Require().NoError(err) - - // Get home directory for the test - homeDir, err := os.UserHomeDir() - suite.Require().NoError(err) - - targetFile := filepath.Join(homeDir, ".bashrc") - - // Clean up the test file after the test - defer func() { - _ = os.Remove(targetFile) - }() - - // Test symlink restoration - restored, err := hostLnk.RestoreSymlinks() - suite.Require().NoError(err) - - // Should have restored the symlink - suite.Len(restored, 1) - suite.Equal(".bashrc", restored[0]) - - // Check that file is now a symlink - info, err := os.Lstat(targetFile) - suite.NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) -} - -// Test that common and host-specific configurations don't interfere -func (suite *CoreTestSuite) TestMultihostIsolation() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create same file for common and host-specific - testFile := filepath.Join(suite.tempDir, ".gitconfig") - commonContent := "[user]\n\tname = Common User" - err = os.WriteFile(testFile, []byte(commonContent), 0644) - suite.Require().NoError(err) - - // Add to common - commonLnk := NewLnk() - err = commonLnk.Add(testFile) - suite.Require().NoError(err) - - // Remove and recreate with different content - err = commonLnk.Remove(testFile) - suite.Require().NoError(err) - - hostContent := "[user]\n\tname = Work User" - err = os.WriteFile(testFile, []byte(hostContent), 0644) - suite.Require().NoError(err) - - // Add to host-specific - hostLnk := NewLnkWithHost("work") - err = hostLnk.Add(testFile) - suite.Require().NoError(err) - - // Verify tracking files are separate - commonItems, err := commonLnk.List() - suite.Require().NoError(err) - suite.Len(commonItems, 0) // Should be empty after removal - - hostItems, err := hostLnk.List() - suite.Require().NoError(err) - suite.Len(hostItems, 1) - suite.Contains(hostItems[0], ".gitconfig") - - // Verify content is correct - symlinkContent, err := os.ReadFile(testFile) - suite.Require().NoError(err) - suite.Equal(hostContent, string(symlinkContent)) -} - -func TestCoreSuite(t *testing.T) { - suite.Run(t, new(CoreTestSuite)) -} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..d3ba9bc --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,241 @@ +package errors + +import ( + "errors" + "fmt" +) + +// Standard error variables +var ( + // ErrFileNotFound indicates a file or directory was not found + ErrFileNotFound = errors.New("file not found") + + // ErrFileAlreadyManaged indicates a file is already being managed by lnk + ErrFileAlreadyManaged = errors.New("file already managed") + + // ErrNotSymlink indicates the file is not a symbolic link + ErrNotSymlink = errors.New("not a symlink") + + // ErrRepoNotInitialized indicates the lnk repository has not been initialized + ErrRepoNotInitialized = errors.New("repository not initialized") + + // ErrNoRemoteConfigured indicates no Git remote has been configured + ErrNoRemoteConfigured = errors.New("no remote configured") + + // ErrOperationAborted indicates an operation was aborted by the user + ErrOperationAborted = errors.New("operation aborted") + + // ErrConfigNotFound indicates a configuration file was not found + ErrConfigNotFound = errors.New("configuration not found") + + // ErrInvalidPath indicates an invalid file path was provided + ErrInvalidPath = errors.New("invalid path") + + // ErrPermissionDenied indicates insufficient permissions for the operation + ErrPermissionDenied = errors.New("permission denied") + + // ErrGitOperation indicates a Git operation failed + ErrGitOperation = errors.New("git operation failed") + + // ErrFileSystemOperation indicates a file system operation failed + ErrFileSystemOperation = errors.New("file system operation failed") +) + +// ErrorCode represents different types of errors that can occur +type ErrorCode int + +const ( + // ErrorCodeUnknown represents an unknown error + ErrorCodeUnknown ErrorCode = iota + + // ErrorCodeFileNotFound represents file not found errors + ErrorCodeFileNotFound + + // ErrorCodeFileAlreadyManaged represents file already managed errors + ErrorCodeFileAlreadyManaged + + // ErrorCodeNotSymlink represents not a symlink errors + ErrorCodeNotSymlink + + // ErrorCodeRepoNotInitialized represents repository not initialized errors + ErrorCodeRepoNotInitialized + + // ErrorCodeNoRemoteConfigured represents no remote configured errors + ErrorCodeNoRemoteConfigured + + // ErrorCodeOperationAborted represents operation aborted errors + ErrorCodeOperationAborted + + // ErrorCodeConfigNotFound represents configuration not found errors + ErrorCodeConfigNotFound + + // ErrorCodeInvalidPath represents invalid path errors + ErrorCodeInvalidPath + + // ErrorCodePermissionDenied represents permission denied errors + ErrorCodePermissionDenied + + // ErrorCodeGitOperation represents Git operation errors + ErrorCodeGitOperation + + // ErrorCodeFileSystemOperation represents file system operation errors + ErrorCodeFileSystemOperation +) + +// String returns a string representation of the error code +func (e ErrorCode) String() string { + switch e { + case ErrorCodeFileNotFound: + return "FILE_NOT_FOUND" + case ErrorCodeFileAlreadyManaged: + return "FILE_ALREADY_MANAGED" + case ErrorCodeNotSymlink: + return "NOT_SYMLINK" + case ErrorCodeRepoNotInitialized: + return "REPO_NOT_INITIALIZED" + case ErrorCodeNoRemoteConfigured: + return "NO_REMOTE_CONFIGURED" + case ErrorCodeOperationAborted: + return "OPERATION_ABORTED" + case ErrorCodeConfigNotFound: + return "CONFIG_NOT_FOUND" + case ErrorCodeInvalidPath: + return "INVALID_PATH" + case ErrorCodePermissionDenied: + return "PERMISSION_DENIED" + case ErrorCodeGitOperation: + return "GIT_OPERATION" + case ErrorCodeFileSystemOperation: + return "FILE_SYSTEM_OPERATION" + default: + return "UNKNOWN" + } +} + +// LnkError represents a structured error with additional context +type LnkError struct { + // Code represents the type of error + Code ErrorCode + + // Message is the human-readable error message + Message string + + // Cause is the underlying error that caused this error + Cause error + + // Context provides additional context about when/where the error occurred + Context map[string]interface{} +} + +// Error implements the error interface +func (e *LnkError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Cause) + } + return e.Message +} + +// Unwrap returns the underlying cause error for Go 1.13+ error handling +func (e *LnkError) Unwrap() error { + return e.Cause +} + +// Is implements error comparison for Go 1.13+ error handling +func (e *LnkError) Is(target error) bool { + if lnkErr, ok := target.(*LnkError); ok { + return e.Code == lnkErr.Code + } + return errors.Is(e.Cause, target) +} + +// WithContext adds context information to the error +func (e *LnkError) WithContext(key string, value interface{}) *LnkError { + if e.Context == nil { + e.Context = make(map[string]interface{}) + } + e.Context[key] = value + return e +} + +// NewLnkError creates a new LnkError with the given code and message +func NewLnkError(code ErrorCode, message string) *LnkError { + return &LnkError{ + Code: code, + Message: message, + Context: make(map[string]interface{}), + } +} + +// WrapError wraps an existing error with LnkError context +func WrapError(code ErrorCode, message string, cause error) *LnkError { + return &LnkError{ + Code: code, + Message: message, + Cause: cause, + Context: make(map[string]interface{}), + } +} + +// Helper functions for creating common errors + +// NewFileNotFoundError creates a file not found error +func NewFileNotFoundError(path string) *LnkError { + return NewLnkError(ErrorCodeFileNotFound, fmt.Sprintf("āŒ File does not exist: \033[31m%s\033[0m", path)). + WithContext("path", path) +} + +// NewFileAlreadyManagedError creates a file already managed error +func NewFileAlreadyManagedError(path string) *LnkError { + return NewLnkError(ErrorCodeFileAlreadyManaged, fmt.Sprintf("file already managed: %s", path)). + WithContext("path", path) +} + +// NewNotSymlinkError creates a not symlink error +func NewNotSymlinkError(path string) *LnkError { + return NewLnkError(ErrorCodeNotSymlink, fmt.Sprintf("not a symlink: %s", path)). + WithContext("path", path) +} + +// NewRepoNotInitializedError creates a repository not initialized error +func NewRepoNotInitializedError(repoPath string) *LnkError { + return NewLnkError(ErrorCodeRepoNotInitialized, "Lnk repository not initialized"). + WithContext("repo_path", repoPath) +} + +// NewNoRemoteConfiguredError creates a no remote configured error +func NewNoRemoteConfiguredError() *LnkError { + return NewLnkError(ErrorCodeNoRemoteConfigured, "no git remote configured") +} + +// NewConfigNotFoundError creates a configuration not found error +func NewConfigNotFoundError(host string) *LnkError { + return NewLnkError(ErrorCodeConfigNotFound, fmt.Sprintf("configuration not found for host: %s", host)). + WithContext("host", host) +} + +// NewInvalidPathError creates an invalid path error +func NewInvalidPathError(path string, reason string) *LnkError { + return NewLnkError(ErrorCodeInvalidPath, fmt.Sprintf("invalid path %s: %s", path, reason)). + WithContext("path", path). + WithContext("reason", reason) +} + +// NewPermissionDeniedError creates a permission denied error +func NewPermissionDeniedError(operation, path string) *LnkError { + return NewLnkError(ErrorCodePermissionDenied, fmt.Sprintf("permission denied for %s: %s", operation, path)). + WithContext("operation", operation). + WithContext("path", path) +} + +// NewGitOperationError creates a Git operation error +func NewGitOperationError(operation string, cause error) *LnkError { + return WrapError(ErrorCodeGitOperation, fmt.Sprintf("git %s failed", operation), cause). + WithContext("operation", operation) +} + +// NewFileSystemOperationError creates a file system operation error +func NewFileSystemOperationError(operation, path string, cause error) *LnkError { + return WrapError(ErrorCodeFileSystemOperation, fmt.Sprintf("file system %s failed for %s", operation, path), cause). + WithContext("operation", operation). + WithContext("path", path) +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 0000000..3909300 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,126 @@ +package errors + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ErrorsTestSuite struct { + suite.Suite +} + +func (suite *ErrorsTestSuite) TestErrorCodeString() { + tests := []struct { + code ErrorCode + expected string + }{ + {ErrorCodeFileNotFound, "FILE_NOT_FOUND"}, + {ErrorCodeFileAlreadyManaged, "FILE_ALREADY_MANAGED"}, + {ErrorCodeNotSymlink, "NOT_SYMLINK"}, + {ErrorCodeRepoNotInitialized, "REPO_NOT_INITIALIZED"}, + {ErrorCodeNoRemoteConfigured, "NO_REMOTE_CONFIGURED"}, + {ErrorCodeOperationAborted, "OPERATION_ABORTED"}, + {ErrorCodeConfigNotFound, "CONFIG_NOT_FOUND"}, + {ErrorCodeInvalidPath, "INVALID_PATH"}, + {ErrorCodePermissionDenied, "PERMISSION_DENIED"}, + {ErrorCodeGitOperation, "GIT_OPERATION"}, + {ErrorCodeFileSystemOperation, "FILE_SYSTEM_OPERATION"}, + {ErrorCodeUnknown, "UNKNOWN"}, + } + + for _, tt := range tests { + suite.Run(tt.expected, func() { + result := tt.code.String() + suite.Equal(tt.expected, result) + }) + } +} + +func (suite *ErrorsTestSuite) TestLnkErrorError() { + suite.Run("without_cause", func() { + err := NewLnkError(ErrorCodeFileNotFound, "test file not found") + expected := "test file not found" + suite.Equal(expected, err.Error()) + }) + + suite.Run("with_cause", func() { + cause := errors.New("underlying error") + err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause) + expected := "file operation failed: underlying error" + suite.Equal(expected, err.Error()) + }) +} + +func (suite *ErrorsTestSuite) TestLnkErrorUnwrap() { + cause := errors.New("underlying error") + err := WrapError(ErrorCodeFileSystemOperation, "file operation failed", cause) + + unwrapped := err.Unwrap() + suite.Equal(cause, unwrapped) +} + +func (suite *ErrorsTestSuite) TestLnkErrorIs() { + err1 := NewLnkError(ErrorCodeFileNotFound, "file not found") + err2 := NewLnkError(ErrorCodeFileNotFound, "another file not found") + err3 := NewLnkError(ErrorCodeFileAlreadyManaged, "file already managed") + + // Same error code should match + suite.True(errors.Is(err1, err2), "expected errors with same code to match") + + // Different error codes should not match + suite.False(errors.Is(err1, err3), "expected errors with different codes to not match") + + // Test with wrapped errors + cause := errors.New("io error") + wrappedErr := WrapError(ErrorCodeFileSystemOperation, "wrapped", cause) + suite.True(errors.Is(wrappedErr, cause), "expected wrapped error to match its cause") +} + +func (suite *ErrorsTestSuite) TestLnkErrorWithContext() { + err := NewLnkError(ErrorCodeFileNotFound, "file not found") + err = err.WithContext("path", "/test/file.txt") + err = err.WithContext("operation", "read") + + suite.Equal("/test/file.txt", err.Context["path"]) + suite.Equal("read", err.Context["operation"]) +} + +func (suite *ErrorsTestSuite) TestNewFileNotFoundError() { + path := "/test/file.txt" + err := NewFileNotFoundError(path) + + suite.Equal(ErrorCodeFileNotFound, err.Code) + suite.Equal(path, err.Context["path"]) +} + +func (suite *ErrorsTestSuite) TestNewFileAlreadyManagedError() { + path := "/test/file.txt" + err := NewFileAlreadyManagedError(path) + + suite.Equal(ErrorCodeFileAlreadyManaged, err.Code) + suite.Equal(path, err.Context["path"]) +} + +func (suite *ErrorsTestSuite) TestNewRepoNotInitializedError() { + repoPath := "/test/repo" + err := NewRepoNotInitializedError(repoPath) + + suite.Equal(ErrorCodeRepoNotInitialized, err.Code) + suite.Equal(repoPath, err.Context["repo_path"]) +} + +func (suite *ErrorsTestSuite) TestNewGitOperationError() { + operation := "push" + cause := errors.New("network error") + err := NewGitOperationError(operation, cause) + + suite.Equal(ErrorCodeGitOperation, err.Code) + suite.Equal(cause, err.Cause) + suite.Equal(operation, err.Context["operation"]) +} + +func TestErrorsSuite(t *testing.T) { + suite.Run(t, new(ErrorsTestSuite)) +} diff --git a/internal/fs/filemanager.go b/internal/fs/filemanager.go new file mode 100644 index 0000000..20f4bdd --- /dev/null +++ b/internal/fs/filemanager.go @@ -0,0 +1,254 @@ +package fs + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/yarlson/lnk/internal/errors" +) + +// FileManager implements the models.FileManager interface +type FileManager struct{} + +// New creates a new FileManager instance +func New() *FileManager { + return &FileManager{} +} + +// Exists checks if a file or directory exists +func (fm *FileManager) Exists(ctx context.Context, path string) (bool, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + + _, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, errors.NewFileSystemOperationError("stat", path, err) + } + return true, nil +} + +// IsDirectory checks if the path points to a directory +func (fm *FileManager) IsDirectory(ctx context.Context, path string) (bool, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, errors.NewFileNotFoundError(path) + } + return false, errors.NewFileSystemOperationError("stat", path, err) + } + return info.IsDir(), nil +} + +// Move moves a file or directory from src to dst +func (fm *FileManager) Move(ctx context.Context, src, dst string) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Ensure destination directory exists + dstDir := filepath.Dir(dst) + if err := fm.MkdirAll(ctx, dstDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Check for context cancellation before move + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Move the file or directory + if err := os.Rename(src, dst); err != nil { + return errors.NewFileSystemOperationError("move", src, err). + WithContext("destination", dst) + } + + return nil +} + +// CreateSymlink creates a symlink pointing from linkPath to target +func (fm *FileManager) CreateSymlink(ctx context.Context, target, linkPath string) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Calculate relative path from linkPath to target + linkDir := filepath.Dir(linkPath) + relTarget, err := filepath.Rel(linkDir, target) + if err != nil { + return errors.NewFileSystemOperationError("calculate_relative_path", linkPath, err). + WithContext("target", target) + } + + // Create the symlink + if err := os.Symlink(relTarget, linkPath); err != nil { + return errors.NewFileSystemOperationError("create_symlink", linkPath, err). + WithContext("target", relTarget) + } + + return nil +} + +// Remove removes a file or directory +func (fm *FileManager) Remove(ctx context.Context, path string) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := os.RemoveAll(path); err != nil { + return errors.NewFileSystemOperationError("remove", path, err) + } + + return nil +} + +// ReadFile reads the contents of a file +func (fm *FileManager) ReadFile(ctx context.Context, path string) ([]byte, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.NewFileNotFoundError(path) + } + return nil, errors.NewFileSystemOperationError("read", path, err) + } + + return data, nil +} + +// WriteFile writes data to a file with the given permissions +func (fm *FileManager) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Ensure parent directory exists + dir := filepath.Dir(path) + if err := fm.MkdirAll(ctx, dir, 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // Check for context cancellation before write + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := os.WriteFile(path, data, perm); err != nil { + return errors.NewFileSystemOperationError("write", path, err) + } + + return nil +} + +// MkdirAll creates a directory and all necessary parent directories +func (fm *FileManager) MkdirAll(ctx context.Context, path string, perm os.FileMode) error { + // Check for context cancellation + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := os.MkdirAll(path, perm); err != nil { + return errors.NewFileSystemOperationError("mkdir", path, err) + } + + return nil +} + +// Readlink returns the target of a symbolic link +func (fm *FileManager) Readlink(ctx context.Context, path string) (string, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + target, err := os.Readlink(path) + if err != nil { + if os.IsNotExist(err) { + return "", errors.NewFileNotFoundError(path) + } + return "", errors.NewFileSystemOperationError("readlink", path, err) + } + + return target, nil +} + +// Lstat returns file info without following symbolic links +func (fm *FileManager) Lstat(ctx context.Context, path string) (os.FileInfo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.NewFileNotFoundError(path) + } + return nil, errors.NewFileSystemOperationError("lstat", path, err) + } + + return info, nil +} + +// Stat returns file info, following symbolic links +func (fm *FileManager) Stat(ctx context.Context, path string) (os.FileInfo, error) { + // Check for context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.NewFileNotFoundError(path) + } + return nil, errors.NewFileSystemOperationError("stat", path, err) + } + + return info, nil +} diff --git a/internal/fs/filemanager_test.go b/internal/fs/filemanager_test.go new file mode 100644 index 0000000..6bfb148 --- /dev/null +++ b/internal/fs/filemanager_test.go @@ -0,0 +1,261 @@ +package fs + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/yarlson/lnk/internal/errors" +) + +type FileManagerTestSuite struct { + suite.Suite + tempDir string + fileManager *FileManager + ctx context.Context +} + +func (suite *FileManagerTestSuite) SetupTest() { + // Create temp directory for testing + tempDir, err := os.MkdirTemp("", "lnk_test_*") + suite.Require().NoError(err) + suite.tempDir = tempDir + + // Create file manager + suite.fileManager = New() + + // Create context + suite.ctx = context.Background() +} + +func (suite *FileManagerTestSuite) TearDownTest() { + err := os.RemoveAll(suite.tempDir) + suite.Require().NoError(err) +} + +func (suite *FileManagerTestSuite) TestExists() { + // Test existing file + testFile := filepath.Join(suite.tempDir, "test.txt") + err := os.WriteFile(testFile, []byte("test"), 0644) + suite.Require().NoError(err) + + exists, err := suite.fileManager.Exists(suite.ctx, testFile) + suite.NoError(err) + suite.True(exists) + + // Test non-existing file + nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt") + exists, err = suite.fileManager.Exists(suite.ctx, nonExistentFile) + suite.NoError(err) + suite.False(exists) +} + +func (suite *FileManagerTestSuite) TestExistsWithCancellation() { + // Create cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := suite.fileManager.Exists(ctx, "/any/path") + suite.Equal(context.Canceled, err) +} + +func (suite *FileManagerTestSuite) TestIsDirectory() { + // Test directory + isDir, err := suite.fileManager.IsDirectory(suite.ctx, suite.tempDir) + suite.NoError(err) + suite.True(isDir) + + // Test file + testFile := filepath.Join(suite.tempDir, "test.txt") + err = os.WriteFile(testFile, []byte("test"), 0644) + suite.Require().NoError(err) + + isDir, err = suite.fileManager.IsDirectory(suite.ctx, testFile) + suite.NoError(err) + suite.False(isDir) + + // Test non-existing file + nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt") + _, err = suite.fileManager.IsDirectory(suite.ctx, nonExistentFile) + suite.Error(err) + + // Check that it's a models error + suite.True(errors.NewFileNotFoundError("").Is(err)) +} + +func (suite *FileManagerTestSuite) TestMove() { + // Create test file + srcFile := filepath.Join(suite.tempDir, "source.txt") + testContent := []byte("test content") + err := os.WriteFile(srcFile, testContent, 0644) + suite.Require().NoError(err) + + // Test moving file + dstFile := filepath.Join(suite.tempDir, "subdir", "destination.txt") + err = suite.fileManager.Move(suite.ctx, srcFile, dstFile) + suite.NoError(err) + + // Verify source doesn't exist + _, err = os.Stat(srcFile) + suite.True(os.IsNotExist(err)) + + // Verify destination exists with correct content + content, err := os.ReadFile(dstFile) + suite.NoError(err) + suite.Equal(string(testContent), string(content)) +} + +func (suite *FileManagerTestSuite) TestCreateSymlink() { + // Create target file + targetFile := filepath.Join(suite.tempDir, "target.txt") + err := os.WriteFile(targetFile, []byte("test"), 0644) + suite.Require().NoError(err) + + // Create symlink + linkFile := filepath.Join(suite.tempDir, "link.txt") + err = suite.fileManager.CreateSymlink(suite.ctx, targetFile, linkFile) + suite.NoError(err) + + // Verify symlink exists and points to target + info, err := os.Lstat(linkFile) + suite.NoError(err) + suite.NotZero(info.Mode() & os.ModeSymlink) + + // Verify symlink target + target, err := os.Readlink(linkFile) + suite.NoError(err) + + expectedTarget := "target.txt" // Should be relative + suite.Equal(expectedTarget, target) +} + +func (suite *FileManagerTestSuite) TestReadWriteFile() { + // Test writing file + testFile := filepath.Join(suite.tempDir, "subdir", "test.txt") + testContent := []byte("test content") + err := suite.fileManager.WriteFile(suite.ctx, testFile, testContent, 0644) + suite.NoError(err) + + // Test reading file + content, err := suite.fileManager.ReadFile(suite.ctx, testFile) + suite.NoError(err) + suite.Equal(string(testContent), string(content)) + + // Test reading non-existent file + nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt") + _, err = suite.fileManager.ReadFile(suite.ctx, nonExistentFile) + suite.Error(err) + suite.True(errors.NewFileNotFoundError("").Is(err)) +} + +func (suite *FileManagerTestSuite) TestRemove() { + // Create test file + testFile := filepath.Join(suite.tempDir, "test.txt") + err := os.WriteFile(testFile, []byte("test"), 0644) + suite.Require().NoError(err) + + // Remove file + err = suite.fileManager.Remove(suite.ctx, testFile) + suite.NoError(err) + + // Verify file doesn't exist + _, err = os.Stat(testFile) + suite.True(os.IsNotExist(err)) + + // Test removing non-existent file (should not error) + err = suite.fileManager.Remove(suite.ctx, testFile) + suite.NoError(err) +} + +func (suite *FileManagerTestSuite) TestMkdirAll() { + // Create nested directory + nestedDir := filepath.Join(suite.tempDir, "a", "b", "c") + err := suite.fileManager.MkdirAll(suite.ctx, nestedDir, 0755) + suite.NoError(err) + + // Verify directory exists + info, err := os.Stat(nestedDir) + suite.NoError(err) + suite.True(info.IsDir()) +} + +func (suite *FileManagerTestSuite) TestReadlink() { + // Create target file + targetFile := filepath.Join(suite.tempDir, "target.txt") + err := os.WriteFile(targetFile, []byte("test"), 0644) + suite.Require().NoError(err) + + // Create symlink + linkFile := filepath.Join(suite.tempDir, "link.txt") + err = os.Symlink("target.txt", linkFile) + suite.Require().NoError(err) + + // Test reading symlink + target, err := suite.fileManager.Readlink(suite.ctx, linkFile) + suite.NoError(err) + suite.Equal("target.txt", target) + + // Test reading non-symlink + _, err = suite.fileManager.Readlink(suite.ctx, targetFile) + suite.Error(err) +} + +func (suite *FileManagerTestSuite) TestStatAndLstat() { + // Create target file + targetFile := filepath.Join(suite.tempDir, "target.txt") + err := os.WriteFile(targetFile, []byte("test"), 0644) + suite.Require().NoError(err) + + // Create symlink + linkFile := filepath.Join(suite.tempDir, "link.txt") + err = os.Symlink("target.txt", linkFile) + suite.Require().NoError(err) + + // Test Stat on regular file + info, err := suite.fileManager.Stat(suite.ctx, targetFile) + suite.NoError(err) + suite.False(info.IsDir()) + + // Test Stat on symlink (should follow link) + info, err = suite.fileManager.Stat(suite.ctx, linkFile) + suite.NoError(err) + suite.False(info.IsDir()) + + // Test Lstat on symlink (should not follow link) + info, err = suite.fileManager.Lstat(suite.ctx, linkFile) + suite.NoError(err) + suite.NotZero(info.Mode() & os.ModeSymlink) + + // Test on non-existent file + nonExistentFile := filepath.Join(suite.tempDir, "nonexistent.txt") + _, err = suite.fileManager.Stat(suite.ctx, nonExistentFile) + suite.Error(err) + suite.True(errors.NewFileNotFoundError("").Is(err)) +} + +func (suite *FileManagerTestSuite) TestContextCancellation() { + // Test with timeout context + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Allow time for context to expire + time.Sleep(1 * time.Millisecond) + + // Test various operations with cancelled context + _, err := suite.fileManager.Exists(ctx, "/any/path") + suite.Equal(context.DeadlineExceeded, err) + + _, err = suite.fileManager.IsDirectory(ctx, "/any/path") + suite.Equal(context.DeadlineExceeded, err) + + err = suite.fileManager.Move(ctx, "/src", "/dst") + suite.Equal(context.DeadlineExceeded, err) +} + +func TestFileManagerSuite(t *testing.T) { + suite.Run(t, new(FileManagerTestSuite)) +} diff --git a/internal/fs/filesystem.go b/internal/fs/filesystem.go deleted file mode 100644 index 996e492..0000000 --- a/internal/fs/filesystem.go +++ /dev/null @@ -1,133 +0,0 @@ -package fs - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// FileSystem handles file system operations -type FileSystem struct{} - -// New creates a new FileSystem instance -func New() *FileSystem { - return &FileSystem{} -} - -// ValidateFileForAdd validates that a file or directory can be added to lnk -func (fs *FileSystem) ValidateFileForAdd(filePath string) error { - // Check if file exists - info, err := os.Stat(filePath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("āŒ File does not exist: \033[31m%s\033[0m", filePath) - } - return fmt.Errorf("āŒ Failed to check file: %w", err) - } - - // Allow both regular files and directories - if !info.Mode().IsRegular() && !info.IsDir() { - return fmt.Errorf("āŒ Only regular files and directories are supported: \033[31m%s\033[0m", filePath) - } - - return nil -} - -// ValidateSymlinkForRemove validates that a symlink can be removed from lnk -func (fs *FileSystem) ValidateSymlinkForRemove(filePath, repoPath string) error { - // Check if file exists - info, err := os.Lstat(filePath) // Use Lstat to not follow symlinks - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("āŒ File does not exist: \033[31m%s\033[0m", filePath) - } - return fmt.Errorf("āŒ Failed to check file: %w", err) - } - - // Check if it's a symlink - if info.Mode()&os.ModeSymlink == 0 { - return fmt.Errorf("āŒ File is not managed by lnk: \033[31m%s\033[0m\n šŸ’” Use \033[1mlnk add\033[0m to manage this file first", filePath) - } - - // Check if symlink points to the repository - target, err := os.Readlink(filePath) - if err != nil { - return fmt.Errorf("failed to read symlink: %w", err) - } - - // Convert relative path to absolute if needed - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(filePath), target) - } - - // Clean the path to resolve any .. or . components - target = filepath.Clean(target) - repoPath = filepath.Clean(repoPath) - - // Check if target is inside the repository - if !strings.HasPrefix(target, repoPath+string(filepath.Separator)) && target != repoPath { - return fmt.Errorf("āŒ File is not managed by lnk: \033[31m%s\033[0m", filePath) - } - - return nil -} - -// MoveFile moves a file from source to destination -func (fs *FileSystem) MoveFile(src, dst string) error { - // Ensure destination directory exists - dstDir := filepath.Dir(dst) - if err := os.MkdirAll(dstDir, 0755); err != nil { - return fmt.Errorf("failed to create destination directory: %w", err) - } - - // Move the file - if err := os.Rename(src, dst); err != nil { - return fmt.Errorf("failed to move file from %s to %s: %w", src, dst, err) - } - - return nil -} - -// CreateSymlink creates a relative symlink from target to linkPath -func (fs *FileSystem) CreateSymlink(target, linkPath string) error { - // Calculate relative path from linkPath to target - linkDir := filepath.Dir(linkPath) - relTarget, err := filepath.Rel(linkDir, target) - if err != nil { - return fmt.Errorf("failed to calculate relative path: %w", err) - } - - // Create the symlink - if err := os.Symlink(relTarget, linkPath); err != nil { - return fmt.Errorf("failed to create symlink: %w", err) - } - - return nil -} - -// MoveDirectory moves a directory from source to destination recursively -func (fs *FileSystem) MoveDirectory(src, dst string) error { - // Check if source is a directory - info, err := os.Stat(src) - if err != nil { - return fmt.Errorf("failed to stat source: %w", err) - } - - if !info.IsDir() { - return fmt.Errorf("source is not a directory: %s", src) - } - - // Ensure destination parent directory exists - dstParent := filepath.Dir(dst) - if err := os.MkdirAll(dstParent, 0755); err != nil { - return fmt.Errorf("failed to create destination parent directory: %w", err) - } - - // Use os.Rename which works for directories - if err := os.Rename(src, dst); err != nil { - return fmt.Errorf("failed to move directory from %s to %s: %w", src, dst, err) - } - - return nil -} diff --git a/internal/git/git.go b/internal/git/git.go deleted file mode 100644 index 7b3c2f3..0000000 --- a/internal/git/git.go +++ /dev/null @@ -1,508 +0,0 @@ -package git - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// Git handles Git operations -type Git struct { - repoPath string -} - -// New creates a new Git instance -func New(repoPath string) *Git { - return &Git{ - repoPath: repoPath, - } -} - -// Init initializes a new Git repository -func (g *Git) Init() error { - // Try using git init -b main first (Git 2.28+) - cmd := exec.Command("git", "init", "-b", "main") - cmd.Dir = g.repoPath - - _, err := cmd.CombinedOutput() - if err != nil { - // Fallback to regular init + branch rename for older Git versions - cmd = exec.Command("git", "init") - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output)) - } - - // Set the default branch to main - cmd = exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/main") - cmd.Dir = g.repoPath - - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to set default branch to main: %w", err) - } - } - - return nil -} - -// AddRemote adds a remote to the repository -func (g *Git) AddRemote(name, url string) error { - // Check if remote already exists - existingURL, err := g.getRemoteURL(name) - if err == nil { - // Remote exists, check if URL matches - if existingURL == url { - // Same URL, idempotent - do nothing - return nil - } - // Different URL, error - return fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url) - } - - // Remote doesn't exist, add it - cmd := exec.Command("git", "remote", "add", name, url) - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// getRemoteURL returns the URL for a remote, or error if not found -func (g *Git) getRemoteURL(name string) (string, error) { - cmd := exec.Command("git", "remote", "get-url", name) - cmd.Dir = g.repoPath - - output, err := cmd.Output() - if err != nil { - return "", err - } - - return strings.TrimSpace(string(output)), nil -} - -// IsGitRepository checks if the directory contains a Git repository -func (g *Git) IsGitRepository() bool { - gitDir := filepath.Join(g.repoPath, ".git") - _, err := os.Stat(gitDir) - return err == nil -} - -// IsLnkRepository checks if the repository appears to be managed by lnk -func (g *Git) IsLnkRepository() bool { - if !g.IsGitRepository() { - return false - } - - // Check if this looks like a lnk repository - // We consider it a lnk repo if: - // 1. It has no commits (fresh repo), OR - // 2. All commits start with "lnk:" pattern - - commits, err := g.GetCommits() - if err != nil { - return false - } - - // If no commits, it's a fresh repo - could be lnk - if len(commits) == 0 { - return true - } - - // If all commits start with "lnk:", it's definitely ours - // If ANY commit doesn't start with "lnk:", it's probably not ours - for _, commit := range commits { - if !strings.HasPrefix(commit, "lnk:") { - return false - } - } - - return true -} - -// AddAndCommit stages a file and commits it -func (g *Git) AddAndCommit(filename, message string) error { - // Stage the file - if err := g.Add(filename); err != nil { - return err - } - - // Commit the changes - if err := g.Commit(message); err != nil { - return err - } - - return nil -} - -// RemoveAndCommit removes a file from Git and commits the change -func (g *Git) RemoveAndCommit(filename, message string) error { - // Remove the file from Git - if err := g.Remove(filename); err != nil { - return err - } - - // Commit the changes - if err := g.Commit(message); err != nil { - return err - } - - return nil -} - -// Add stages a file -func (g *Git) Add(filename string) error { - cmd := exec.Command("git", "add", filename) - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// Remove removes a file from Git tracking -func (g *Git) Remove(filename string) error { - // Check if it's a directory that needs -r flag - fullPath := filepath.Join(g.repoPath, filename) - info, err := os.Stat(fullPath) - - var cmd *exec.Cmd - if err == nil && info.IsDir() { - // Use -r and --cached flags for directories (only remove from git, not filesystem) - cmd = exec.Command("git", "rm", "-r", "--cached", filename) - } else { - // Regular file (only remove from git, not filesystem) - cmd = exec.Command("git", "rm", "--cached", filename) - } - - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// Commit creates a commit with the given message -func (g *Git) Commit(message string) error { - // Configure git user if not already configured - if err := g.ensureGitConfig(); err != nil { - return err - } - - cmd := exec.Command("git", "commit", "-m", message) - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// ensureGitConfig ensures that git user.name and user.email are configured -func (g *Git) ensureGitConfig() error { - // Check if user.name is configured - cmd := exec.Command("git", "config", "user.name") - cmd.Dir = g.repoPath - if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { - // Set a default user.name - cmd = exec.Command("git", "config", "user.name", "Lnk User") - cmd.Dir = g.repoPath - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to set git user.name: %w", err) - } - } - - // Check if user.email is configured - cmd = exec.Command("git", "config", "user.email") - cmd.Dir = g.repoPath - if output, err := cmd.Output(); err != nil || len(strings.TrimSpace(string(output))) == 0 { - // Set a default user.email - cmd = exec.Command("git", "config", "user.email", "lnk@localhost") - cmd.Dir = g.repoPath - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to set git user.email: %w", err) - } - } - - return nil -} - -// GetCommits returns the list of commit messages for testing purposes -func (g *Git) GetCommits() ([]string, error) { - // Check if .git directory exists - gitDir := filepath.Join(g.repoPath, ".git") - if _, err := os.Stat(gitDir); os.IsNotExist(err) { - return []string{}, nil - } - - cmd := exec.Command("git", "log", "--oneline", "--format=%s") - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - // If there are no commits yet, return empty slice - outputStr := string(output) - if strings.Contains(outputStr, "does not have any commits yet") { - return []string{}, nil - } - return nil, fmt.Errorf("git log failed: %w", err) - } - - commits := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(commits) == 1 && commits[0] == "" { - return []string{}, nil - } - - return commits, nil -} - -// GetRemoteInfo returns information about the default remote -func (g *Git) GetRemoteInfo() (string, error) { - // First try to get origin remote - url, err := g.getRemoteURL("origin") - if err != nil { - // If origin doesn't exist, try to get any remote - cmd := exec.Command("git", "remote") - cmd.Dir = g.repoPath - - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to list remotes: %w", err) - } - - remotes := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(remotes) == 0 || remotes[0] == "" { - return "", fmt.Errorf("no remote configured") - } - - // Use the first remote - url, err = g.getRemoteURL(remotes[0]) - if err != nil { - return "", fmt.Errorf("failed to get remote URL: %w", err) - } - } - - return url, nil -} - -// StatusInfo contains repository status information -type StatusInfo struct { - Ahead int - Behind int - Remote string - Dirty bool -} - -// GetStatus returns the repository status relative to remote -func (g *Git) GetStatus() (*StatusInfo, error) { - // Check if we have a remote - _, err := g.GetRemoteInfo() - if err != nil { - return nil, err - } - - // Check for uncommitted changes - dirty, err := g.HasChanges() - if err != nil { - return nil, fmt.Errorf("failed to check for uncommitted changes: %w", err) - } - - // Get the remote tracking branch - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") - cmd.Dir = g.repoPath - - output, err := cmd.Output() - if err != nil { - // No upstream branch set, assume origin/main - remoteBranch := "origin/main" - return &StatusInfo{ - Ahead: g.getAheadCount(remoteBranch), - Behind: 0, // Can't be behind if no upstream - Remote: remoteBranch, - Dirty: dirty, - }, nil - } - - remoteBranch := strings.TrimSpace(string(output)) - - return &StatusInfo{ - Ahead: g.getAheadCount(remoteBranch), - Behind: g.getBehindCount(remoteBranch), - Remote: remoteBranch, - Dirty: dirty, - }, nil -} - -// getAheadCount returns how many commits ahead of remote -func (g *Git) getAheadCount(remoteBranch string) int { - cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch)) - cmd.Dir = g.repoPath - - output, err := cmd.Output() - if err != nil { - // If remote branch doesn't exist, count all local commits - cmd = exec.Command("git", "rev-list", "--count", "HEAD") - cmd.Dir = g.repoPath - - output, err = cmd.Output() - if err != nil { - return 0 - } - } - - count := strings.TrimSpace(string(output)) - if count == "" { - return 0 - } - - // Convert to int - var ahead int - if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil { - return 0 - } - - return ahead -} - -// getBehindCount returns how many commits behind remote -func (g *Git) getBehindCount(remoteBranch string) int { - cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch)) - cmd.Dir = g.repoPath - - output, err := cmd.Output() - if err != nil { - return 0 - } - - count := strings.TrimSpace(string(output)) - if count == "" { - return 0 - } - - // Convert to int - var behind int - if _, err := fmt.Sscanf(count, "%d", &behind); err != nil { - return 0 - } - - return behind -} - -// HasChanges checks if there are uncommitted changes -func (g *Git) HasChanges() (bool, error) { - cmd := exec.Command("git", "status", "--porcelain") - cmd.Dir = g.repoPath - - output, err := cmd.Output() - if err != nil { - return false, fmt.Errorf("git status failed: %w", err) - } - - return len(strings.TrimSpace(string(output))) > 0, nil -} - -// AddAll stages all changes in the repository -func (g *Git) AddAll() error { - cmd := exec.Command("git", "add", "-A") - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// Push pushes changes to remote -func (g *Git) Push() error { - // First ensure we have a remote configured - _, err := g.GetRemoteInfo() - if err != nil { - return fmt.Errorf("cannot push: %w", err) - } - - cmd := exec.Command("git", "push", "-u", "origin", "main") - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// Pull pulls changes from remote -func (g *Git) Pull() error { - // First ensure we have a remote configured - _, err := g.GetRemoteInfo() - if err != nil { - return fmt.Errorf("cannot pull: %w", err) - } - - cmd := exec.Command("git", "pull", "origin", "main") - cmd.Dir = g.repoPath - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// Clone clones a repository from the given URL -func (g *Git) Clone(url string) error { - // Remove the directory if it exists to ensure clean clone - if err := os.RemoveAll(g.repoPath); err != nil { - return fmt.Errorf("failed to remove existing directory: %w", err) - } - - // Create parent directory - parentDir := filepath.Dir(g.repoPath) - if err := os.MkdirAll(parentDir, 0755); err != nil { - return fmt.Errorf("failed to create parent directory: %w", err) - } - - // Clone the repository - cmd := exec.Command("git", "clone", url, g.repoPath) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output)) - } - - // Set up upstream tracking for main branch - cmd = exec.Command("git", "branch", "--set-upstream-to=origin/main", "main") - cmd.Dir = g.repoPath - _, err = cmd.CombinedOutput() - if err != nil { - // If main doesn't exist, try master - cmd = exec.Command("git", "branch", "--set-upstream-to=origin/master", "master") - cmd.Dir = g.repoPath - _, err = cmd.CombinedOutput() - if err != nil { - // If that also fails, try to set upstream for current branch - cmd = exec.Command("git", "branch", "--set-upstream-to=origin/HEAD") - cmd.Dir = g.repoPath - _, _ = cmd.CombinedOutput() // Ignore error as this is best effort - } - } - - return nil -} diff --git a/internal/git/gitmanager.go b/internal/git/gitmanager.go new file mode 100644 index 0000000..3f10620 --- /dev/null +++ b/internal/git/gitmanager.go @@ -0,0 +1,547 @@ +package git + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/yarlson/lnk/internal/errors" + "github.com/yarlson/lnk/internal/models" +) + +// GitManager implements the models.GitManager interface +type GitManager struct{} + +// New creates a new GitManager instance +func New() *GitManager { + return &GitManager{} +} + +// Init initializes a new Git repository at repoPath +func (g *GitManager) Init(ctx context.Context, repoPath string) error { + // Try using git init -b main first (Git 2.28+) + cmd := exec.CommandContext(ctx, "git", "init", "-b", "main") + cmd.Dir = repoPath + + _, err := cmd.CombinedOutput() + if err != nil { + // Fallback to regular init + branch rename for older Git versions + cmd = exec.CommandContext(ctx, "git", "init") + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.NewGitOperationError("init", fmt.Errorf("git init failed: %w\nOutput: %s", err, string(output))) + } + + // Set the default branch to main + cmd = exec.CommandContext(ctx, "git", "symbolic-ref", "HEAD", "refs/heads/main") + cmd.Dir = repoPath + + if err := cmd.Run(); err != nil { + return errors.NewGitOperationError("init", fmt.Errorf("failed to set default branch to main: %w", err)) + } + } + + return nil +} + +// Clone clones a repository from url to repoPath +func (g *GitManager) Clone(ctx context.Context, repoPath, url string) error { + // Remove the directory if it exists to ensure clean clone + if err := os.RemoveAll(repoPath); err != nil { + return errors.NewFileSystemOperationError("remove_existing_dir", repoPath, + fmt.Errorf("failed to remove existing directory: %w", err)) + } + + // Create parent directory + parentDir := filepath.Dir(repoPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return errors.NewFileSystemOperationError("create_parent_dir", parentDir, + fmt.Errorf("failed to create parent directory: %w", err)) + } + + // Clone the repository + cmd := exec.CommandContext(ctx, "git", "clone", url, repoPath) + output, err := cmd.CombinedOutput() + if err != nil { + return errors.NewGitOperationError("clone", fmt.Errorf("git clone failed: %w\nOutput: %s", err, string(output))) + } + + // Set up upstream tracking for main branch + cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/main", "main") + cmd.Dir = repoPath + _, err = cmd.CombinedOutput() + if err != nil { + // If main doesn't exist, try master + cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/master", "master") + cmd.Dir = repoPath + _, err = cmd.CombinedOutput() + if err != nil { + // If that also fails, try to set upstream for current branch + cmd = exec.CommandContext(ctx, "git", "branch", "--set-upstream-to=origin/HEAD") + cmd.Dir = repoPath + _, _ = cmd.CombinedOutput() // Ignore error as this is best effort + } + } + + return nil +} + +// Add stages files for commit +func (g *GitManager) Add(ctx context.Context, repoPath string, files ...string) error { + args := append([]string{"add"}, files...) + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.NewGitOperationError("add", fmt.Errorf("git add failed: %w\nOutput: %s", err, string(output))) + } + + return nil +} + +// Remove removes files from Git tracking +func (g *GitManager) Remove(ctx context.Context, repoPath string, files ...string) error { + for _, filename := range files { + // Check if it's a directory in the repository by checking the actual repo path + fullPath := filepath.Join(repoPath, filename) + info, err := os.Stat(fullPath) + + var cmd *exec.Cmd + useRecursive := false + if err == nil && info.IsDir() { + useRecursive = true + } + + if useRecursive { + // Use -r and --cached flags for directories (only remove from git, not fs) + cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename) + } else { + // Regular file (only remove from git, not fs) + cmd = exec.CommandContext(ctx, "git", "rm", "--cached", filename) + } + + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + // If we tried without -r and got a "recursively without -r" error, try with -r + if !useRecursive && strings.Contains(string(output), "recursively without -r") { + cmd = exec.CommandContext(ctx, "git", "rm", "-r", "--cached", filename) + cmd.Dir = repoPath + output, err = cmd.CombinedOutput() + } + + if err != nil { + return errors.NewGitOperationError("remove", fmt.Errorf("git rm failed: %w\nOutput: %s", err, string(output))) + } + } + } + + return nil +} + +// Commit creates a commit with the given message +func (g *GitManager) Commit(ctx context.Context, repoPath, message string) error { + // Configure git user if not already configured + if err := g.ensureGitConfig(ctx, repoPath); err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "git", "commit", "-m", message) + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.NewGitOperationError("commit", fmt.Errorf("git commit failed: %w\nOutput: %s", err, string(output))) + } + + return nil +} + +// Push pushes changes to the remote repository +func (g *GitManager) Push(ctx context.Context, repoPath string) error { + // First ensure we have a remote configured + _, err := g.GetRemoteURL(ctx, repoPath, "origin") + if err != nil { + return errors.NewGitOperationError("push", fmt.Errorf("cannot push: %w", err)) + } + + cmd := exec.CommandContext(ctx, "git", "push", "-u", "origin", "main") + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.NewGitOperationError("push", fmt.Errorf("git push failed: %w\nOutput: %s", err, string(output))) + } + + return nil +} + +// Pull pulls changes from the remote repository +func (g *GitManager) Pull(ctx context.Context, repoPath string) error { + // First ensure we have a remote configured + _, err := g.GetRemoteURL(ctx, repoPath, "origin") + if err != nil { + return errors.NewGitOperationError("pull", fmt.Errorf("cannot pull: %w", err)) + } + + cmd := exec.CommandContext(ctx, "git", "pull", "origin", "main") + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.NewGitOperationError("pull", fmt.Errorf("git pull failed: %w\nOutput: %s", err, string(output))) + } + + return nil +} + +// Status returns the current Git status +func (g *GitManager) Status(ctx context.Context, repoPath string) (*models.SyncStatus, error) { + // First check if we have a remote configured - this should match old behavior + _, err := g.GetRemoteURL(ctx, repoPath, "origin") + if err != nil { + // If origin doesn't exist, check if we have any remotes at all + cmd := exec.CommandContext(ctx, "git", "remote") + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + return nil, errors.NewGitOperationError("list_remotes", err) + } + + remotes := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(remotes) == 0 || remotes[0] == "" { + return nil, errors.NewGitOperationError("status", fmt.Errorf("no remote configured")) + } + } + + // Get current branch + currentBranch, err := g.getCurrentBranch(ctx, repoPath) + if err != nil { + return nil, errors.NewGitOperationError("get_current_branch", err) + } + + // Check for uncommitted changes + dirty, err := g.HasChanges(ctx, repoPath) + if err != nil { + return nil, errors.NewGitOperationError("check_changes", err) + } + + // Get the remote URL + remoteURL, err := g.GetRemoteURL(ctx, repoPath, "origin") + hasRemote := err == nil + + // Initialize status with basic information + status := &models.SyncStatus{ + CurrentBranch: currentBranch, + Dirty: dirty, + HasRemote: hasRemote, + RemoteURL: remoteURL, + } + + // If no remote, we can't determine ahead/behind counts + if !hasRemote { + return status, nil + } + + // Get the remote tracking branch + remoteBranch, err := g.getRemoteTrackingBranch(ctx, repoPath) + if err != nil { + // No upstream branch set, assume origin/main + remoteBranch = "origin/main" + } + status.RemoteBranch = remoteBranch + + // Get ahead/behind counts + status.Ahead = g.getAheadCount(ctx, repoPath, remoteBranch) + status.Behind = g.getBehindCount(ctx, repoPath, remoteBranch) + + // Get last commit hash + lastCommitHash, err := g.getLastCommitHash(ctx, repoPath) + if err == nil { + status.LastCommitHash = lastCommitHash + } + + return status, nil +} + +// IsRepository checks if the path is a Git repository +func (g *GitManager) IsRepository(ctx context.Context, repoPath string) (bool, error) { + gitDir := filepath.Join(repoPath, ".git") + _, err := os.Stat(gitDir) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, errors.NewFileSystemOperationError("check_git_dir", gitDir, err) + } + return true, nil +} + +// HasChanges checks if there are uncommitted changes +func (g *GitManager) HasChanges(ctx context.Context, repoPath string) (bool, error) { + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain") + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + return false, errors.NewGitOperationError("status", fmt.Errorf("git status failed: %w", err)) + } + + return len(strings.TrimSpace(string(output))) > 0, nil +} + +// AddRemote adds a remote to the repository +func (g *GitManager) AddRemote(ctx context.Context, repoPath, name, url string) error { + // Check if remote already exists + existingURL, err := g.GetRemoteURL(ctx, repoPath, name) + if err == nil { + // Remote exists, check if URL matches + if existingURL == url { + // Same URL, idempotent - do nothing + return nil + } + // Different URL, error + return errors.NewGitOperationError("add_remote", + fmt.Errorf("remote %s already exists with different URL: %s (trying to add: %s)", name, existingURL, url)) + } + + // Remote doesn't exist, add it + cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url) + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + return errors.NewGitOperationError("add_remote", fmt.Errorf("git remote add failed: %w\nOutput: %s", err, string(output))) + } + + return nil +} + +// GetRemoteURL returns the URL of a remote +func (g *GitManager) GetRemoteURL(ctx context.Context, repoPath, name string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "remote", "get-url", name) + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + return "", errors.NewGitOperationError("get_remote_url", fmt.Errorf("failed to get remote URL for %s: %w", name, err)) + } + + return strings.TrimSpace(string(output)), nil +} + +// IsLnkRepository checks if the repository appears to be managed by lnk +func (g *GitManager) IsLnkRepository(ctx context.Context, repoPath string) (bool, error) { + isRepo, err := g.IsRepository(ctx, repoPath) + if err != nil { + return false, err + } + if !isRepo { + return false, nil + } + + // Check if this looks like a lnk repository + // We consider it a lnk repo if: + // 1. It has no commits (fresh repo), OR + // 2. All commits start with "lnk:" pattern + + commits, err := g.getCommits(ctx, repoPath) + if err != nil { + return false, errors.NewGitOperationError("get_commits", err) + } + + // If no commits, it's a fresh repo - could be lnk + if len(commits) == 0 { + return true, nil + } + + // If all commits start with "lnk:", it's definitely ours + // If ANY commit doesn't start with "lnk:", it's probably not ours + for _, commit := range commits { + if !strings.HasPrefix(commit, "lnk:") { + return false, nil + } + } + + return true, nil +} + +// Helper methods + +// ensureGitConfig configures git user if not already configured +func (g *GitManager) ensureGitConfig(ctx context.Context, repoPath string) error { + // Check if user.name is configured + cmd := exec.CommandContext(ctx, "git", "config", "user.name") + cmd.Dir = repoPath + if err := cmd.Run(); err != nil { + // Set default user.name + cmd = exec.CommandContext(ctx, "git", "config", "user.name", "lnk") + cmd.Dir = repoPath + if err := cmd.Run(); err != nil { + return errors.NewGitOperationError("config_user_name", fmt.Errorf("failed to set git user.name: %w", err)) + } + } + + // Check if user.email is configured + cmd = exec.CommandContext(ctx, "git", "config", "user.email") + cmd.Dir = repoPath + if err := cmd.Run(); err != nil { + // Set default user.email + cmd = exec.CommandContext(ctx, "git", "config", "user.email", "lnk@local") + cmd.Dir = repoPath + if err := cmd.Run(); err != nil { + return errors.NewGitOperationError("config_user_email", fmt.Errorf("failed to set git user.email: %w", err)) + } + } + + return nil +} + +// getCurrentBranch returns the current branch name +func (g *GitManager) getCurrentBranch(ctx context.Context, repoPath string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = repoPath + + output, err := cmd.CombinedOutput() + if err != nil { + // For empty repositories, HEAD might not exist yet, default to main + errStr := string(output) + + if strings.Contains(errStr, "fatal: ambiguous argument 'HEAD'") || + strings.Contains(errStr, "unknown revision") || + strings.Contains(errStr, "not a valid ref") || + strings.Contains(errStr, "bad revision") { + return "main", nil + } + return "", fmt.Errorf("failed to get current branch: %w", err) + } + + branch := strings.TrimSpace(string(output)) + // If the branch is HEAD (detached state), try to get the default branch + if branch == "HEAD" { + return "main", nil + } + + return branch, nil +} + +// getRemoteTrackingBranch returns the remote tracking branch +func (g *GitManager) getRemoteTrackingBranch(ctx context.Context, repoPath string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("no upstream branch set: %w", err) + } + + return strings.TrimSpace(string(output)), nil +} + +// getAheadCount returns how many commits ahead of remote +func (g *GitManager) getAheadCount(ctx context.Context, repoPath, remoteBranch string) int { + cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("%s..HEAD", remoteBranch)) + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + // If remote branch doesn't exist, count all local commits + cmd = exec.CommandContext(ctx, "git", "rev-list", "--count", "HEAD") + cmd.Dir = repoPath + + output, err = cmd.Output() + if err != nil { + return 0 + } + } + + count := strings.TrimSpace(string(output)) + if count == "" { + return 0 + } + + // Convert to int + var ahead int + if _, err := fmt.Sscanf(count, "%d", &ahead); err != nil { + return 0 + } + + return ahead +} + +// getBehindCount returns how many commits behind remote +func (g *GitManager) getBehindCount(ctx context.Context, repoPath, remoteBranch string) int { + cmd := exec.CommandContext(ctx, "git", "rev-list", "--count", fmt.Sprintf("HEAD..%s", remoteBranch)) + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + return 0 + } + + count := strings.TrimSpace(string(output)) + if count == "" { + return 0 + } + + // Convert to int + var behind int + if _, err := fmt.Sscanf(count, "%d", &behind); err != nil { + return 0 + } + + return behind +} + +// getLastCommitHash returns the hash of the last commit +func (g *GitManager) getLastCommitHash(ctx context.Context, repoPath string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD") + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get last commit hash: %w", err) + } + + return strings.TrimSpace(string(output)), nil +} + +// getCommits returns commit messages +func (g *GitManager) getCommits(ctx context.Context, repoPath string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "log", "--pretty=format:%s") + cmd.Dir = repoPath + + output, err := cmd.Output() + if err != nil { + // If there are no commits, git log will fail + // Use CombinedOutput to get both stdout and stderr to check the error message + cmd = exec.CommandContext(ctx, "git", "log", "--pretty=format:%s") + cmd.Dir = repoPath + combinedOutput, _ := cmd.CombinedOutput() + errStr := string(combinedOutput) + + if strings.Contains(errStr, "does not have any commits yet") || + strings.Contains(errStr, "bad default revision") || + strings.Contains(errStr, "unknown revision") || + strings.Contains(errStr, "ambiguous argument") { + return []string{}, nil + } + return nil, fmt.Errorf("failed to get commits: %w", err) + } + + outputStr := strings.TrimSpace(string(output)) + if outputStr == "" { + return []string{}, nil + } + + commitMessages := strings.Split(outputStr, "\n") + return commitMessages, nil +} diff --git a/internal/git/gitmanager_test.go b/internal/git/gitmanager_test.go new file mode 100644 index 0000000..5ea7cb8 --- /dev/null +++ b/internal/git/gitmanager_test.go @@ -0,0 +1,307 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type GitManagerTestSuite struct { + suite.Suite + tempDir string + gitManager *GitManager + ctx context.Context +} + +func (suite *GitManagerTestSuite) SetupTest() { + // Create temp directory for testing + tempDir, err := os.MkdirTemp("", "lnk_git_test_*") + suite.Require().NoError(err) + suite.tempDir = tempDir + + // Create git manager + suite.gitManager = New() + + // Create context + suite.ctx = context.Background() +} + +func (suite *GitManagerTestSuite) TearDownTest() { + err := os.RemoveAll(suite.tempDir) + suite.Require().NoError(err) +} + +// Helper function to check if file exists +func (suite *GitManagerTestSuite) fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func (suite *GitManagerTestSuite) TestInit() { + repoPath := filepath.Join(suite.tempDir, "test-repo") + + // Create the directory + err := os.MkdirAll(repoPath, 0755) + suite.Require().NoError(err) + + // Test init + err = suite.gitManager.Init(suite.ctx, repoPath) + suite.NoError(err) + + // Verify repository was created + isRepo, err := suite.gitManager.IsRepository(suite.ctx, repoPath) + suite.NoError(err) + suite.True(isRepo) +} + +func (suite *GitManagerTestSuite) TestAddCommit() { + repoPath := filepath.Join(suite.tempDir, "test-repo") + + // Create and initialize repository + err := os.MkdirAll(repoPath, 0755) + suite.Require().NoError(err) + + err = suite.gitManager.Init(suite.ctx, repoPath) + suite.Require().NoError(err) + + // Create a test file + testFile := filepath.Join(repoPath, "test.txt") + err = os.WriteFile(testFile, []byte("test content"), 0644) + suite.Require().NoError(err) + + // Test adding file + err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt") + suite.NoError(err) + + // Test commit + err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit") + suite.NoError(err) + + // Verify no uncommitted changes + hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath) + suite.NoError(err) + suite.False(hasChanges) +} + +func (suite *GitManagerTestSuite) TestStatus() { + repoPath := filepath.Join(suite.tempDir, "test-repo") + + // Create and initialize repository + err := os.MkdirAll(repoPath, 0755) + suite.Require().NoError(err) + + err = suite.gitManager.Init(suite.ctx, repoPath) + suite.Require().NoError(err) + + // Test status on empty repository should fail with no remote configured + _, err = suite.gitManager.Status(suite.ctx, repoPath) + suite.Error(err) + suite.Contains(err.Error(), "no remote configured") + + // Add a remote to make status work + testURL := "https://github.com/test/repo.git" + err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL) + suite.Require().NoError(err) + + // Test status with remote configured but no commits + status, err := suite.gitManager.Status(suite.ctx, repoPath) + suite.NoError(err) + + suite.Equal("main", status.CurrentBranch) + suite.False(status.Dirty) + suite.True(status.HasRemote) + + // Create and commit a file + testFile := filepath.Join(repoPath, "test.txt") + err = os.WriteFile(testFile, []byte("test content"), 0644) + suite.Require().NoError(err) + + // Test dirty status + status, err = suite.gitManager.Status(suite.ctx, repoPath) + suite.NoError(err) + suite.True(status.Dirty) + + // Add and commit + err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt") + suite.Require().NoError(err) + + err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: test commit") + suite.Require().NoError(err) + + // Test clean status + status, err = suite.gitManager.Status(suite.ctx, repoPath) + suite.NoError(err) + suite.False(status.Dirty) + suite.NotEmpty(status.LastCommitHash) +} + +func (suite *GitManagerTestSuite) TestRemoteOperations() { + repoPath := filepath.Join(suite.tempDir, "test-repo") + + // Create and initialize repository + err := os.MkdirAll(repoPath, 0755) + suite.Require().NoError(err) + + err = suite.gitManager.Init(suite.ctx, repoPath) + suite.Require().NoError(err) + + // Test adding remote + testURL := "https://github.com/test/repo.git" + err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL) + suite.NoError(err) + + // Test getting remote URL + remoteURL, err := suite.gitManager.GetRemoteURL(suite.ctx, repoPath, "origin") + suite.NoError(err) + suite.Equal(testURL, remoteURL) + + // Test idempotent add (same URL) + err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", testURL) + suite.NoError(err) + + // Test adding remote with different URL should fail + err = suite.gitManager.AddRemote(suite.ctx, repoPath, "origin", "https://github.com/different/repo.git") + suite.Error(err) +} + +func (suite *GitManagerTestSuite) TestIsLnkRepository() { + tests := []struct { + name string + setup func(string) error + expected bool + }{ + { + name: "not_a_repository", + setup: func(path string) error { + return os.MkdirAll(path, 0755) + }, + expected: false, + }, + { + name: "empty_git_repository", + setup: func(path string) error { + if err := os.MkdirAll(path, 0755); err != nil { + return err + } + return suite.gitManager.Init(suite.ctx, path) + }, + expected: true, + }, + { + name: "repository_with_lnk_commits", + setup: func(path string) error { + if err := os.MkdirAll(path, 0755); err != nil { + return err + } + if err := suite.gitManager.Init(suite.ctx, path); err != nil { + return err + } + + // Create and commit a file with lnk prefix + testFile := filepath.Join(path, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + return err + } + if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil { + return err + } + return suite.gitManager.Commit(suite.ctx, path, "lnk: add test file") + }, + expected: true, + }, + { + name: "repository_with_non-lnk_commits", + setup: func(path string) error { + if err := os.MkdirAll(path, 0755); err != nil { + return err + } + if err := suite.gitManager.Init(suite.ctx, path); err != nil { + return err + } + + // Create and commit a file without lnk prefix + testFile := filepath.Join(path, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + return err + } + if err := suite.gitManager.Add(suite.ctx, path, "test.txt"); err != nil { + return err + } + return suite.gitManager.Commit(suite.ctx, path, "regular commit") + }, + expected: false, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + repoPath := filepath.Join(suite.tempDir, tt.name) + err := tt.setup(repoPath) + suite.Require().NoError(err) + + isLnk, err := suite.gitManager.IsLnkRepository(suite.ctx, repoPath) + suite.NoError(err) + suite.Equal(tt.expected, isLnk) + }) + } +} + +func (suite *GitManagerTestSuite) TestContextCancellation() { + repoPath := filepath.Join(suite.tempDir, "test-repo") + + err := os.MkdirAll(repoPath, 0755) + suite.Require().NoError(err) + + // Test context cancellation + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // This should fail due to context timeout + err = suite.gitManager.Init(ctx, repoPath) + suite.Error(err) + + // Verify the error is context-related + suite.NotNil(ctx.Err()) +} + +func (suite *GitManagerTestSuite) TestRemove() { + repoPath := filepath.Join(suite.tempDir, "test-repo") + + // Create and initialize repository + err := os.MkdirAll(repoPath, 0755) + suite.Require().NoError(err) + + err = suite.gitManager.Init(suite.ctx, repoPath) + suite.Require().NoError(err) + + // Create and add files + testFile := filepath.Join(repoPath, "test.txt") + err = os.WriteFile(testFile, []byte("test content"), 0644) + suite.Require().NoError(err) + + err = suite.gitManager.Add(suite.ctx, repoPath, "test.txt") + suite.Require().NoError(err) + + err = suite.gitManager.Commit(suite.ctx, repoPath, "lnk: add test file") + suite.Require().NoError(err) + + // Test removing file + err = suite.gitManager.Remove(suite.ctx, repoPath, "test.txt") + suite.NoError(err) + + // Verify file is removed from git but still exists on fs + suite.True(suite.fileExists(testFile)) + + // Verify repository has changes + hasChanges, err := suite.gitManager.HasChanges(suite.ctx, repoPath) + suite.NoError(err) + suite.True(hasChanges) +} + +func TestGitManagerSuite(t *testing.T) { + suite.Run(t, new(GitManagerTestSuite)) +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..74818bf --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,108 @@ +package models + +import ( + "os" + "time" +) + +// ManagedFile represents a file or directory managed by lnk +type ManagedFile struct { + // ID for potential future database use + ID string `json:"id,omitempty"` + + // OriginalPath is the original absolute path where the file was located + OriginalPath string `json:"original_path"` + + // RepoPath is the path within the lnk repository + RepoPath string `json:"repo_path"` + + // RelativePath is the path relative to the home directory (or absolute for files outside home) + RelativePath string `json:"relative_path"` + + // Host is the hostname where this file is managed + Host string `json:"host"` + + // IsDirectory indicates whether this is a directory + IsDirectory bool `json:"is_directory"` + + // SymlinkTarget is the current symlink target (if the original location is now a symlink) + SymlinkTarget string `json:"symlink_target,omitempty"` + + // AddedAt is when the file was first added to lnk + AddedAt time.Time `json:"added_at,omitempty"` + + // UpdatedAt is when the file was last updated + UpdatedAt time.Time `json:"updated_at,omitempty"` + + // Mode stores the file permissions + Mode os.FileMode `json:"mode,omitempty"` +} + +// RepositoryConfig represents the lnk repository settings +type RepositoryConfig struct { + // Path is the absolute path to the lnk repository + Path string `json:"path"` + + // DefaultRemote is the default Git remote for sync operations + DefaultRemote string `json:"default_remote,omitempty"` + + // Created is when the repository was created + Created time.Time `json:"created,omitempty"` + + // LastSync is when the repository was last synced + LastSync time.Time `json:"last_sync,omitempty"` +} + +// HostConfig represents configuration specific to a host +type HostConfig struct { + // Name is the hostname + Name string `json:"name"` + + // ManagedFiles is the list of files managed on this host + ManagedFiles []ManagedFile `json:"managed_files"` + + // LastUpdate is when this host configuration was last updated + LastUpdate time.Time `json:"last_update,omitempty"` +} + +// SyncStatus represents Git repository sync status +type SyncStatus struct { + // Ahead is the number of commits ahead of remote + Ahead int `json:"ahead"` + + // Behind is the number of commits behind remote + Behind int `json:"behind"` + + // CurrentBranch is the currently checked out branch + CurrentBranch string `json:"current_branch"` + + // RemoteBranch is the remote tracking branch + RemoteBranch string `json:"remote_branch"` + + // RemoteURL is the URL of the remote repository + RemoteURL string `json:"remote_url"` + + // Dirty indicates if there are uncommitted changes + Dirty bool `json:"dirty"` + + // LastCommitHash is the hash of the last commit + LastCommitHash string `json:"last_commit_hash"` + + // HasRemote indicates if a remote is configured + HasRemote bool `json:"has_remote"` +} + +// IsClean returns true if the repository is clean (no uncommitted changes) +func (s *SyncStatus) IsClean() bool { + return !s.Dirty +} + +// IsSynced returns true if the repository is in sync with remote (ahead=0, behind=0) +func (s *SyncStatus) IsSynced() bool { + return s.Ahead == 0 && s.Behind == 0 +} + +// NeedsSync returns true if the repository needs to be synced with remote +func (s *SyncStatus) NeedsSync() bool { + return s.Ahead > 0 || s.Behind > 0 +} diff --git a/internal/models/models_test.go b/internal/models/models_test.go new file mode 100644 index 0000000..56403ed --- /dev/null +++ b/internal/models/models_test.go @@ -0,0 +1,185 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type ModelsTestSuite struct { + suite.Suite +} + +func (suite *ModelsTestSuite) TestManagedFile() { + now := time.Now() + + file := ManagedFile{ + ID: "test-id", + OriginalPath: "/home/user/.vimrc", + RepoPath: "/home/user/.config/lnk/.vimrc", + RelativePath: ".vimrc", + Host: "workstation", + IsDirectory: false, + AddedAt: now, + UpdatedAt: now, + } + + suite.Equal("test-id", file.ID) + suite.Equal("/home/user/.vimrc", file.OriginalPath) + suite.Equal("workstation", file.Host) +} + +func (suite *ModelsTestSuite) TestRepositoryConfig() { + now := time.Now() + + config := RepositoryConfig{ + Path: "/home/user/.config/lnk", + DefaultRemote: "origin", + Created: now, + LastSync: now, + } + + suite.Equal("/home/user/.config/lnk", config.Path) + suite.Equal("origin", config.DefaultRemote) +} + +func (suite *ModelsTestSuite) TestHostConfig() { + now := time.Now() + + managedFile := ManagedFile{ + RelativePath: ".vimrc", + Host: "workstation", + } + + config := HostConfig{ + Name: "workstation", + ManagedFiles: []ManagedFile{managedFile}, + LastUpdate: now, + } + + suite.Equal("workstation", config.Name) + suite.Len(config.ManagedFiles, 1) + suite.Equal(".vimrc", config.ManagedFiles[0].RelativePath) +} + +func (suite *ModelsTestSuite) TestSyncStatusIsClean() { + tests := []struct { + name string + dirty bool + expected bool + }{ + { + name: "clean_repository", + dirty: false, + expected: true, + }, + { + name: "dirty_repository", + dirty: true, + expected: false, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + status := SyncStatus{Dirty: tt.dirty} + result := status.IsClean() + suite.Equal(tt.expected, result) + }) + } +} + +func (suite *ModelsTestSuite) TestSyncStatusIsSynced() { + tests := []struct { + name string + ahead int + behind int + expected bool + }{ + { + name: "fully_synced", + ahead: 0, + behind: 0, + expected: true, + }, + { + name: "ahead_of_remote", + ahead: 2, + behind: 0, + expected: false, + }, + { + name: "behind_remote", + ahead: 0, + behind: 3, + expected: false, + }, + { + name: "diverged", + ahead: 1, + behind: 2, + expected: false, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + status := SyncStatus{ + Ahead: tt.ahead, + Behind: tt.behind, + } + result := status.IsSynced() + suite.Equal(tt.expected, result) + }) + } +} + +func (suite *ModelsTestSuite) TestSyncStatusNeedsSync() { + tests := []struct { + name string + ahead int + behind int + expected bool + }{ + { + name: "fully_synced", + ahead: 0, + behind: 0, + expected: false, + }, + { + name: "ahead_of_remote", + ahead: 2, + behind: 0, + expected: true, + }, + { + name: "behind_remote", + ahead: 0, + behind: 3, + expected: true, + }, + { + name: "diverged", + ahead: 1, + behind: 2, + expected: true, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + status := SyncStatus{ + Ahead: tt.ahead, + Behind: tt.behind, + } + result := status.NeedsSync() + suite.Equal(tt.expected, result) + }) + } +} + +func TestModelsSuite(t *testing.T) { + suite.Run(t, new(ModelsTestSuite)) +} diff --git a/internal/pathresolver/resolver.go b/internal/pathresolver/resolver.go new file mode 100644 index 0000000..b136005 --- /dev/null +++ b/internal/pathresolver/resolver.go @@ -0,0 +1,153 @@ +package pathresolver + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Resolver implements the models.PathResolver interface +type Resolver struct{} + +// New creates a new PathResolver instance +func New() *Resolver { + return &Resolver{} +} + +// GetRepoStoragePath returns the base path where lnk repositories are stored +// This is based on XDG Base Directory specification +func (r *Resolver) GetRepoStoragePath() (string, error) { + xdgConfig := os.Getenv("XDG_CONFIG_HOME") + if xdgConfig == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + xdgConfig = filepath.Join(homeDir, ".config") + } + return filepath.Join(xdgConfig, "lnk"), nil +} + +// GetFileStoragePathInRepo returns the path where a file should be stored in the repository +func (r *Resolver) GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error) { + hostPath, err := r.GetHostStoragePath(repoPath, host) + if err != nil { + return "", err + } + return filepath.Join(hostPath, relativePath), nil +} + +// GetTrackingFilePath returns the path to the tracking file for a host +func (r *Resolver) GetTrackingFilePath(repoPath, host string) (string, error) { + var fileName string + if host == "" { + // Common configuration + fileName = ".lnk" + } else { + // Host-specific configuration + fileName = ".lnk." + host + } + return filepath.Join(repoPath, fileName), nil +} + +// GetHomePath returns the user's home directory path +func (r *Resolver) GetHomePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return homeDir, nil +} + +// GetRelativePathFromHome converts an absolute path to relative from home directory +// This is migrated from the original getRelativePath function +func (r *Resolver) GetRelativePathFromHome(absPath string) (string, error) { + homeDir, err := r.GetHomePath() + if err != nil { + return "", err + } + + // Check if the file is under home directory + relPath, err := filepath.Rel(homeDir, absPath) + if err != nil { + return "", fmt.Errorf("failed to get relative path: %w", err) + } + + // If the relative path starts with "..", the file is outside home directory + // In this case, use the absolute path as relative (without the leading slash) + if strings.HasPrefix(relPath, "..") { + // Use absolute path but remove leading slash and drive letter (for cross-platform) + cleanPath := strings.TrimPrefix(absPath, "/") + return cleanPath, nil + } + + return relPath, nil +} + +// GetAbsolutePathInHome converts a relative path to absolute within home directory +func (r *Resolver) GetAbsolutePathInHome(relPath string) (string, error) { + homeDir, err := r.GetHomePath() + if err != nil { + return "", err + } + + // If the relative path looks like an absolute path (starts with / or drive letter), + // it's probably a file outside home directory + if filepath.IsAbs(relPath) { + return relPath, nil + } + + // If it starts with a drive letter on Windows or looks like an absolute path, + // treat it as absolute + if len(relPath) > 0 && !strings.HasPrefix(relPath, ".") { + // Check if it looks like an absolute path stored without leading slash + // This handles paths like "etc/hosts" which should become "/etc/hosts" + if strings.HasPrefix(relPath, "etc/") || + strings.HasPrefix(relPath, "usr/") || + strings.HasPrefix(relPath, "var/") || + strings.HasPrefix(relPath, "opt/") || + strings.HasPrefix(relPath, "tmp/") { + // Reconstruct the absolute path + return "/" + relPath, nil + } + // Windows drive patterns like "C:" or contains drive separator + if strings.Contains(relPath, ":") { + return relPath, nil + } + } + + return filepath.Join(homeDir, relPath), nil +} + +// GetHostStoragePath returns the directory where files for a host are stored +// This is migrated from the original getHostStoragePath method +func (r *Resolver) GetHostStoragePath(repoPath, host string) (string, error) { + if host == "" { + // Common configuration - store in root of repo + return repoPath, nil + } + // Host-specific configuration - store in host subdirectory + return filepath.Join(repoPath, host+".lnk"), nil +} + +// IsUnderHome checks if a path is under the home directory +func (r *Resolver) IsUnderHome(path string) (bool, error) { + homeDir, err := r.GetHomePath() + if err != nil { + return false, err + } + + // Clean both paths to handle relative components like .. and . + cleanPath := filepath.Clean(path) + cleanHome := filepath.Clean(homeDir) + + // Get relative path + relPath, err := filepath.Rel(cleanHome, cleanPath) + if err != nil { + return false, nil // If we can't get relative path, assume not under home + } + + // If relative path starts with "..", it's outside home directory + return !strings.HasPrefix(relPath, ".."), nil +} diff --git a/internal/pathresolver/resolver_test.go b/internal/pathresolver/resolver_test.go new file mode 100644 index 0000000..0a3693d --- /dev/null +++ b/internal/pathresolver/resolver_test.go @@ -0,0 +1,250 @@ +package pathresolver + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ResolverTestSuite struct { + suite.Suite + resolver *Resolver +} + +func (suite *ResolverTestSuite) SetupTest() { + suite.resolver = New() +} + +func (suite *ResolverTestSuite) TestGetRepoStoragePath() { + // Test with XDG_CONFIG_HOME set + originalXDG := os.Getenv("XDG_CONFIG_HOME") + defer os.Setenv("XDG_CONFIG_HOME", originalXDG) + + suite.Run("with_XDG_CONFIG_HOME_set", func() { + testXDG := "/test/config" + os.Setenv("XDG_CONFIG_HOME", testXDG) + + path, err := suite.resolver.GetRepoStoragePath() + suite.NoError(err) + + expected := filepath.Join(testXDG, "lnk") + suite.Equal(expected, path) + }) + + suite.Run("without_XDG_CONFIG_HOME", func() { + os.Unsetenv("XDG_CONFIG_HOME") + + path, err := suite.resolver.GetRepoStoragePath() + suite.NoError(err) + + homeDir, _ := os.UserHomeDir() + expected := filepath.Join(homeDir, ".config", "lnk") + suite.Equal(expected, path) + }) +} + +func (suite *ResolverTestSuite) TestGetTrackingFilePath() { + repoPath := "/test/repo" + + tests := []struct { + name string + host string + expected string + }{ + { + name: "common_config", + host: "", + expected: filepath.Join(repoPath, ".lnk"), + }, + { + name: "host-specific_config", + host: "myhost", + expected: filepath.Join(repoPath, ".lnk.myhost"), + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + path, err := suite.resolver.GetTrackingFilePath(repoPath, tt.host) + suite.NoError(err) + suite.Equal(tt.expected, path) + }) + } +} + +func (suite *ResolverTestSuite) TestGetHostStoragePath() { + repoPath := "/test/repo" + + tests := []struct { + name string + host string + expected string + }{ + { + name: "common_config", + host: "", + expected: repoPath, + }, + { + name: "host-specific_config", + host: "myhost", + expected: filepath.Join(repoPath, "myhost.lnk"), + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + path, err := suite.resolver.GetHostStoragePath(repoPath, tt.host) + suite.NoError(err) + suite.Equal(tt.expected, path) + }) + } +} + +func (suite *ResolverTestSuite) TestGetRelativePathFromHome() { + homeDir, err := os.UserHomeDir() + suite.Require().NoError(err) + + tests := []struct { + name string + absPath string + expected string + }{ + { + name: "file_in_home", + absPath: filepath.Join(homeDir, "Documents", "test.txt"), + expected: filepath.Join("Documents", "test.txt"), + }, + { + name: "file_outside_home", + absPath: "/etc/hosts", + expected: "etc/hosts", + }, + { + name: "home_directory_itself", + absPath: homeDir, + expected: ".", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + result, err := suite.resolver.GetRelativePathFromHome(tt.absPath) + suite.NoError(err) + suite.Equal(tt.expected, result) + }) + } +} + +func (suite *ResolverTestSuite) TestGetAbsolutePathInHome() { + homeDir, err := os.UserHomeDir() + suite.Require().NoError(err) + + tests := []struct { + name string + relPath string + expected string + }{ + { + name: "relative_path_in_home", + relPath: filepath.Join("Documents", "test.txt"), + expected: filepath.Join(homeDir, "Documents", "test.txt"), + }, + { + name: "already_absolute_path", + relPath: "/etc/hosts", + expected: "/etc/hosts", + }, + { + name: "absolute-like_path_without_leading_slash", + relPath: "etc/hosts", + expected: "/etc/hosts", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + result, err := suite.resolver.GetAbsolutePathInHome(tt.relPath) + suite.NoError(err) + suite.Equal(tt.expected, result) + }) + } +} + +func (suite *ResolverTestSuite) TestIsUnderHome() { + homeDir, err := os.UserHomeDir() + suite.Require().NoError(err) + + tests := []struct { + name string + path string + expected bool + }{ + { + name: "file_in_home", + path: filepath.Join(homeDir, "Documents", "test.txt"), + expected: true, + }, + { + name: "file_outside_home", + path: "/etc/hosts", + expected: false, + }, + { + name: "home_directory_itself", + path: homeDir, + expected: true, + }, + { + name: "parent_of_home", + path: filepath.Dir(homeDir), + expected: false, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + result, err := suite.resolver.IsUnderHome(tt.path) + suite.NoError(err) + suite.Equal(tt.expected, result) + }) + } +} + +func (suite *ResolverTestSuite) TestGetFileStoragePathInRepo() { + repoPath := "/test/repo" + + tests := []struct { + name string + host string + relativePath string + expected string + }{ + { + name: "common_config_file", + host: "", + relativePath: "Documents/test.txt", + expected: filepath.Join(repoPath, "Documents", "test.txt"), + }, + { + name: "host-specific_file", + host: "myhost", + relativePath: "Documents/test.txt", + expected: filepath.Join(repoPath, "myhost.lnk", "Documents", "test.txt"), + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + result, err := suite.resolver.GetFileStoragePathInRepo(repoPath, tt.host, tt.relativePath) + suite.NoError(err) + suite.Equal(tt.expected, result) + }) + } +} + +func TestResolverSuite(t *testing.T) { + suite.Run(t, new(ResolverTestSuite)) +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..1bb4556 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,823 @@ +package service + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/yarlson/lnk/internal/config" + "github.com/yarlson/lnk/internal/errors" + "github.com/yarlson/lnk/internal/fs" + "github.com/yarlson/lnk/internal/git" + "github.com/yarlson/lnk/internal/models" + "github.com/yarlson/lnk/internal/pathresolver" +) + +// FileManager handles file system operations +type FileManager interface { + Exists(ctx context.Context, path string) (bool, error) + Move(ctx context.Context, src, dst string) error + CreateSymlink(ctx context.Context, target, linkPath string) error + Remove(ctx context.Context, path string) error + MkdirAll(ctx context.Context, path string, perm os.FileMode) error + Readlink(ctx context.Context, path string) (string, error) + Lstat(ctx context.Context, path string) (os.FileInfo, error) + Stat(ctx context.Context, path string) (os.FileInfo, error) +} + +// ConfigManager handles configuration persistence (reading and writing .lnk files) +type ConfigManager interface { + AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error + RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error + ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) + GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) +} + +// GitManager handles Git operations +type GitManager interface { + Init(ctx context.Context, repoPath string) error + Clone(ctx context.Context, repoPath, url string) error + Add(ctx context.Context, repoPath string, files ...string) error + Remove(ctx context.Context, repoPath string, files ...string) error + Commit(ctx context.Context, repoPath, message string) error + Push(ctx context.Context, repoPath string) error + Pull(ctx context.Context, repoPath string) error + Status(ctx context.Context, repoPath string) (*models.SyncStatus, error) + IsRepository(ctx context.Context, repoPath string) (bool, error) + HasChanges(ctx context.Context, repoPath string) (bool, error) + IsLnkRepository(ctx context.Context, repoPath string) (bool, error) +} + +// PathResolver handles path resolution and manipulation +type PathResolver interface { + GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error) + GetTrackingFilePath(repoPath, host string) (string, error) + GetHomePath() (string, error) + GetRelativePathFromHome(absPath string) (string, error) + GetAbsolutePathInHome(relPath string) (string, error) +} + +// Service encapsulates the business logic for lnk operations +type Service struct { + fileManager FileManager + gitManager GitManager // May be nil for some operations + configManager ConfigManager + pathResolver PathResolver + repoPath string +} + +// New creates a new Service instance with default dependencies +func New() (*Service, error) { + // Initialize adapters + fileManager := fs.New() + gitManager := git.New() + pathResolver := pathresolver.New() + configManager := config.New(fileManager, pathResolver) + + // Get repository path + repoPath, err := pathResolver.GetRepoStoragePath() + if err != nil { + return nil, errors.NewInvalidPathError("", "failed to determine repository storage path"). + WithContext("error", err.Error()) + } + + return &Service{ + fileManager: fileManager, + gitManager: gitManager, + configManager: configManager, + pathResolver: pathResolver, + repoPath: repoPath, + }, nil +} + +// NewLnkServiceWithDeps creates a new Service instance with provided dependencies (for testing) +func NewLnkServiceWithDeps( + fileManager FileManager, + gitManager GitManager, + configManager ConfigManager, + pathResolver PathResolver, + repoPath string, +) *Service { + return &Service{ + fileManager: fileManager, + gitManager: gitManager, + configManager: configManager, + pathResolver: pathResolver, + repoPath: repoPath, + } +} + +// ListManagedFiles returns the list of files managed by lnk for a specific host +// If host is empty, returns common configuration files +func (s *Service) ListManagedFiles(ctx context.Context, host string) ([]models.ManagedFile, error) { + // Check if the repository exists + exists, err := s.fileManager.Exists(ctx, s.repoPath) + if err != nil { + return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err) + } + + if !exists { + return nil, errors.NewRepoNotInitializedError(s.repoPath) + } + + // Use the config manager to list managed files + managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host) + if err != nil { + return nil, err // ConfigManager already returns properly typed errors + } + + return managedFiles, nil +} + +// GetStatus returns the Git repository status +// Returns an error if the repository is not initialized or GitManager is not available +func (s *Service) GetStatus(ctx context.Context) (*models.SyncStatus, error) { + // Check if GitManager is available + if s.gitManager == nil { + return nil, errors.NewGitOperationError("get_status", + fmt.Errorf("git manager not available")) + } + + // Check if the repository exists + exists, err := s.fileManager.Exists(ctx, s.repoPath) + if err != nil { + return nil, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err) + } + + if !exists { + return nil, errors.NewRepoNotInitializedError(s.repoPath) + } + + // Check if it's a Git repository + isRepo, err := s.gitManager.IsRepository(ctx, s.repoPath) + if err != nil { + return nil, errors.NewGitOperationError("check_git_repo", err) + } + + if !isRepo { + return nil, errors.NewRepoNotInitializedError(s.repoPath). + WithContext("reason", "directory exists but is not a git repository") + } + + // Get Git status + status, err := s.gitManager.Status(ctx, s.repoPath) + if err != nil { + return nil, err // GitManager already returns properly typed errors + } + + return status, nil +} + +// GetRepoPath returns the repository path +func (s *Service) GetRepoPath() string { + return s.repoPath +} + +// IsRepositoryInitialized checks if the lnk repository has been initialized +func (s *Service) IsRepositoryInitialized(ctx context.Context) (bool, error) { + // Check if repository directory exists + exists, err := s.fileManager.Exists(ctx, s.repoPath) + if err != nil { + return false, errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err) + } + + if !exists { + return false, nil + } + + // Check if it's a Git repository (if GitManager is available) + if s.gitManager != nil { + isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath) + if err != nil { + return false, errors.NewGitOperationError("check_git_repo", err) + } + return isGitRepo, nil + } + + // If no GitManager, just check if the directory exists + return true, nil +} + +// InitializeRepository initializes a new lnk repository, optionally cloning from a remote URL +func (s *Service) InitializeRepository(ctx context.Context, remoteURL string) error { + // Check if GitManager is available + if s.gitManager == nil { + return errors.NewGitOperationError("initialize_repository", + fmt.Errorf("git manager not available")) + } + + if remoteURL != "" { + // Clone from remote + return s.cloneRepository(ctx, remoteURL) + } + + // Initialize empty repository + return s.initEmptyRepository(ctx) +} + +// cloneRepository clones a repository from the given URL +func (s *Service) cloneRepository(ctx context.Context, remoteURL string) error { + // Clone using GitManager + if err := s.gitManager.Clone(ctx, s.repoPath, remoteURL); err != nil { + return errors.NewGitOperationError("clone_repository", err). + WithContext("remote_url", remoteURL). + WithContext("repo_path", s.repoPath) + } + + return nil +} + +// initEmptyRepository initializes an empty Git repository +func (s *Service) initEmptyRepository(ctx context.Context) error { + // Check if repository directory already exists + exists, err := s.fileManager.Exists(ctx, s.repoPath) + if err != nil { + return errors.NewFileSystemOperationError("check_repo_exists", s.repoPath, err) + } + + if exists { + // Check if it's already a Git repository + isGitRepo, err := s.gitManager.IsRepository(ctx, s.repoPath) + if err != nil { + return errors.NewGitOperationError("check_git_repo", err) + } + + if isGitRepo { + // Check if it's a lnk repository + isLnkRepo, err := s.gitManager.IsLnkRepository(ctx, s.repoPath) + if err != nil { + return errors.NewGitOperationError("check_lnk_repo", err) + } + + if isLnkRepo { + // It's already a lnk repository, init is idempotent + return nil + } else { + // It's not a lnk repository, error to prevent data loss + return errors.NewRepoNotInitializedError(s.repoPath). + WithContext("reason", "directory contains an existing non-lnk Git repository") + } + } + } + + // Create the repository directory if it doesn't exist + if !exists { + if err := s.fileManager.MkdirAll(ctx, s.repoPath, 0755); err != nil { + return errors.NewFileSystemOperationError("create_repo_dir", s.repoPath, err) + } + } + + // Initialize Git repository + if err := s.gitManager.Init(ctx, s.repoPath); err != nil { + // Clean up directory if we created it + if !exists { + _ = s.fileManager.Remove(ctx, s.repoPath) // Ignore cleanup errors + } + return errors.NewGitOperationError("init_git_repo", err). + WithContext("repo_path", s.repoPath) + } + + return nil +} + +// AddFile adds a file or directory to lnk management for the specified host +// This involves moving the file to the repository, creating a symlink, updating tracking, and committing to Git +func (s *Service) AddFile(ctx context.Context, filePath, host string) (*models.ManagedFile, error) { + // Check if GitManager is available + if s.gitManager == nil { + return nil, errors.NewGitOperationError("add_file", + fmt.Errorf("git manager not available")) + } + + // Get absolute path + absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath) + if err != nil { + // If it fails, try as-is (might be already absolute) + var pathErr error + absPath, pathErr = filepath.Abs(filePath) + if pathErr != nil { + return nil, errors.NewFileSystemOperationError("resolve_path", filePath, err) + } + } + + // Validate that the file exists and is accessible (check this FIRST like the old implementation) + exists, err := s.fileManager.Exists(ctx, absPath) + if err != nil { + return nil, errors.NewFileSystemOperationError("check_file_exists", absPath, err) + } + if !exists { + return nil, errors.NewFileNotFoundError(absPath) + } + + // Check if repository is initialized (after file existence check) + initialized, err := s.IsRepositoryInitialized(ctx) + if err != nil { + return nil, err + } + if !initialized { + return nil, errors.NewRepoNotInitializedError(s.repoPath) + } + + // Get file information to determine if it's a directory + fileInfo, err := s.fileManager.Stat(ctx, absPath) + if err != nil { + return nil, errors.NewFileSystemOperationError("stat_file", absPath, err) + } + + // Get relative path for tracking + relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath) + if err != nil { + return nil, errors.NewFileSystemOperationError("get_relative_path", absPath, err) + } + + // Check if file is already managed + existingFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath) + if err == nil && existingFile != nil { + return nil, errors.NewFileAlreadyManagedError(relativePath) + } + + // Create managed file model + managedFile := models.ManagedFile{ + OriginalPath: absPath, + RelativePath: relativePath, + Host: host, + IsDirectory: fileInfo.IsDir(), + } + + // Get storage path in repository + storagePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, relativePath) + if err != nil { + return nil, errors.NewFileSystemOperationError("get_storage_path", relativePath, err) + } + + managedFile.RepoPath = storagePath + + // Execute the file addition with rollback support + if err := s.executeFileAddition(ctx, &managedFile); err != nil { + return nil, err + } + + return &managedFile, nil +} + +// executeFileAddition performs the actual file addition with rollback logic +func (s *Service) executeFileAddition(ctx context.Context, file *models.ManagedFile) error { + var rollbackActions []func() error + + // Helper function to add rollback action + addRollback := func(action func() error) { + rollbackActions = append([]func() error{action}, rollbackActions...) + } + + // Execute rollback if any step fails + defer func() { + if len(rollbackActions) > 0 { + for _, action := range rollbackActions { + _ = action() // Ignore rollback errors + } + } + }() + + // Step 1: Create destination directory + destDir := filepath.Dir(file.RepoPath) + if err := s.fileManager.MkdirAll(ctx, destDir, 0755); err != nil { + return errors.NewFileSystemOperationError("create_dest_dir", destDir, err) + } + + // Step 2: Move file to repository + if err := s.fileManager.Move(ctx, file.OriginalPath, file.RepoPath); err != nil { + return errors.NewFileSystemOperationError("move_file", file.OriginalPath, err) + } + + // Add rollback for move operation + addRollback(func() error { + return s.fileManager.Move(context.Background(), file.RepoPath, file.OriginalPath) + }) + + // Step 3: Create symlink + if err := s.fileManager.CreateSymlink(ctx, file.RepoPath, file.OriginalPath); err != nil { + return errors.NewFileSystemOperationError("create_symlink", file.OriginalPath, err) + } + + // Add rollback for symlink creation + addRollback(func() error { + return s.fileManager.Remove(context.Background(), file.OriginalPath) + }) + + // Step 4: Add to config tracking + if err := s.configManager.AddManagedFileToHost(ctx, s.repoPath, file.Host, *file); err != nil { + return err // ConfigManager returns properly typed errors + } + + // Add rollback for config update + addRollback(func() error { + return s.configManager.RemoveManagedFileFromHost(context.Background(), + s.repoPath, file.Host, file.RelativePath) + }) + + // Step 5: Add file to Git + gitPath := file.RelativePath + if file.Host != "" { + gitPath = filepath.Join(file.Host+".lnk", file.RelativePath) + } + + if err := s.gitManager.Add(ctx, s.repoPath, gitPath); err != nil { + return errors.NewGitOperationError("add_file_to_git", err) + } + + // Step 6: Add config file to Git + trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, file.Host) + if err != nil { + return errors.NewFileSystemOperationError("get_tracking_file", "", err) + } + + // Get relative path of tracking file from repo root + trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile) + if err != nil { + return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err) + } + + if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil { + return errors.NewGitOperationError("add_tracking_file_to_git", err) + } + + // Step 7: Commit changes + basename := filepath.Base(file.RelativePath) + commitMessage := fmt.Sprintf("lnk: added %s", basename) + if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil { + return errors.NewGitOperationError("commit_changes", err) + } + + // If we reach here, everything succeeded - clear rollback actions + rollbackActions = nil + return nil +} + +// RemoveFile removes a file or directory from lnk management for the specified host +// This involves removing the symlink, restoring the original file, updating tracking, and committing to Git +func (s *Service) RemoveFile(ctx context.Context, filePath, host string) error { + // Check if GitManager is available + if s.gitManager == nil { + return errors.NewGitOperationError("remove_file", + fmt.Errorf("git manager not available")) + } + + // Check if repository is initialized + initialized, err := s.IsRepositoryInitialized(ctx) + if err != nil { + return err + } + if !initialized { + return errors.NewRepoNotInitializedError(s.repoPath) + } + + // Get absolute path + absPath, err := s.pathResolver.GetAbsolutePathInHome(filePath) + if err != nil { + // If it fails, try as-is (might be already absolute) + var pathErr error + absPath, pathErr = filepath.Abs(filePath) + if pathErr != nil { + return errors.NewFileSystemOperationError("resolve_path", filePath, err) + } + } + + // Validate that this is a symlink + linkInfo, err := s.fileManager.Lstat(ctx, absPath) + if err != nil { + if os.IsNotExist(err) { + return errors.NewFileNotFoundError(absPath) + } + return errors.NewFileSystemOperationError("stat_symlink", absPath, err) + } + + if linkInfo.Mode()&os.ModeSymlink == 0 { + return errors.NewNotSymlinkError(absPath) + } + + // Get symlink target + target, err := s.fileManager.Readlink(ctx, absPath) + if err != nil { + return errors.NewFileSystemOperationError("read_symlink", absPath, err) + } + + // Convert relative symlink target to absolute path + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(absPath), target) + } + + // Validate that the target exists in our repository + targetAbs, err := filepath.Abs(target) + if err != nil { + return errors.NewFileSystemOperationError("resolve_target", target, err) + } + + repoPathAbs, err := filepath.Abs(s.repoPath) + if err != nil { + return errors.NewFileSystemOperationError("resolve_repo_path", s.repoPath, err) + } + + if !strings.HasPrefix(targetAbs, repoPathAbs) { + return errors.NewInvalidPathError(targetAbs, "symlink target is not in lnk repository") + } + + // Get relative path for tracking + relativePath, err := s.pathResolver.GetRelativePathFromHome(absPath) + if err != nil { + return errors.NewFileSystemOperationError("get_relative_path", absPath, err) + } + + // Check if this file is actually managed + managedFile, err := s.configManager.GetManagedFile(ctx, s.repoPath, host, relativePath) + if err != nil || managedFile == nil { + return errors.NewLnkError(errors.ErrorCodeFileNotFound, fmt.Sprintf("file is not managed by lnk: %s", relativePath)) + } + + // Get target file info to determine if it's a directory + targetInfo, err := s.fileManager.Stat(ctx, targetAbs) + if err != nil { + return errors.NewFileSystemOperationError("stat_target", targetAbs, err) + } + + // Execute the file removal with rollback support + return s.executeFileRemoval(ctx, absPath, targetAbs, relativePath, host, targetInfo.IsDir()) +} + +// executeFileRemoval performs the actual file removal with rollback logic +func (s *Service) executeFileRemoval(ctx context.Context, symlinkPath, targetPath, relativePath, host string, isDirectory bool) error { + var rollbackActions []func() error + + // Helper function to add rollback action + addRollback := func(action func() error) { + rollbackActions = append([]func() error{action}, rollbackActions...) + } + + // Execute rollback if any step fails + defer func() { + if len(rollbackActions) > 0 { + for _, action := range rollbackActions { + _ = action() // Ignore rollback errors + } + } + }() + + // Step 1: Remove the symlink + if err := s.fileManager.Remove(ctx, symlinkPath); err != nil { + return errors.NewFileSystemOperationError("remove_symlink", symlinkPath, err) + } + + // Add rollback for symlink removal + addRollback(func() error { + return s.fileManager.CreateSymlink(context.Background(), targetPath, symlinkPath) + }) + + // Step 2: Move file back from repository to original location + if err := s.fileManager.Move(ctx, targetPath, symlinkPath); err != nil { + return errors.NewFileSystemOperationError("restore_file", targetPath, err) + } + + // Add rollback for file restoration + addRollback(func() error { + return s.fileManager.Move(context.Background(), symlinkPath, targetPath) + }) + + // Step 3: Remove from config tracking + if err := s.configManager.RemoveManagedFileFromHost(ctx, s.repoPath, host, relativePath); err != nil { + return err // ConfigManager returns properly typed errors + } + + // Add rollback for config update + managedFile := models.ManagedFile{ + OriginalPath: symlinkPath, + RelativePath: relativePath, + RepoPath: targetPath, + Host: host, + IsDirectory: isDirectory, + } + addRollback(func() error { + return s.configManager.AddManagedFileToHost(context.Background(), s.repoPath, host, managedFile) + }) + + // Step 4: Remove file from Git + gitPath := relativePath + if host != "" { + gitPath = filepath.Join(host+".lnk", relativePath) + } + + if err := s.gitManager.Remove(ctx, s.repoPath, gitPath); err != nil { + return err + } + + // Step 5: Add config file to Git (to commit the tracking change) + trackingFile, err := s.pathResolver.GetTrackingFilePath(s.repoPath, host) + if err != nil { + return errors.NewFileSystemOperationError("get_tracking_file", "", err) + } + + // Get relative path of tracking file from repo root + trackingFileRel, err := filepath.Rel(s.repoPath, trackingFile) + if err != nil { + return errors.NewFileSystemOperationError("get_tracking_file_rel", trackingFile, err) + } + + if err := s.gitManager.Add(ctx, s.repoPath, trackingFileRel); err != nil { + return errors.NewGitOperationError("add_tracking_file_to_git", err) + } + + // Step 6: Commit changes + basename := filepath.Base(relativePath) + commitMessage := fmt.Sprintf("lnk: removed %s", basename) + if err := s.gitManager.Commit(ctx, s.repoPath, commitMessage); err != nil { + return errors.NewGitOperationError("commit_changes", err) + } + + // If we reach here, everything succeeded - clear rollback actions + rollbackActions = nil + return nil +} + +// PushChanges stages all changes and pushes to remote repository +func (s *Service) PushChanges(ctx context.Context, message string) error { + // Check if GitManager is available + if s.gitManager == nil { + return errors.NewGitOperationError("push_changes", + fmt.Errorf("git manager not available")) + } + + // Check if repository is initialized + initialized, err := s.IsRepositoryInitialized(ctx) + if err != nil { + return err + } + if !initialized { + return errors.NewRepoNotInitializedError(s.repoPath) + } + + // Check if there are any changes to commit + hasChanges, err := s.gitManager.HasChanges(ctx, s.repoPath) + if err != nil { + return errors.NewGitOperationError("check_changes", err) + } + + if hasChanges { + // Add all changes (equivalent to git add .) + if err := s.gitManager.Add(ctx, s.repoPath, "."); err != nil { + return errors.NewGitOperationError("stage_changes", err) + } + + // Create a sync commit + if err := s.gitManager.Commit(ctx, s.repoPath, message); err != nil { + return errors.NewGitOperationError("commit_changes", err) + } + } + + // Push to remote + if err := s.gitManager.Push(ctx, s.repoPath); err != nil { + return errors.NewGitOperationError("push_to_remote", err) + } + + return nil +} + +// PullChanges pulls changes from remote and restores symlinks for the specified host +func (s *Service) PullChanges(ctx context.Context, host string) ([]models.ManagedFile, error) { + // Check if GitManager is available + if s.gitManager == nil { + return nil, errors.NewGitOperationError("pull_changes", + fmt.Errorf("git manager not available")) + } + + // Check if repository is initialized + initialized, err := s.IsRepositoryInitialized(ctx) + if err != nil { + return nil, err + } + if !initialized { + return nil, errors.NewRepoNotInitializedError(s.repoPath) + } + + // Pull changes from remote + if err := s.gitManager.Pull(ctx, s.repoPath); err != nil { + return nil, errors.NewGitOperationError("pull_from_remote", err) + } + + // Restore symlinks for the specified host + restored, err := s.RestoreSymlinksForHost(ctx, host) + if err != nil { + return nil, err + } + + return restored, nil +} + +// RestoreSymlinksForHost restores symlinks for all managed files for the specified host +func (s *Service) RestoreSymlinksForHost(ctx context.Context, host string) ([]models.ManagedFile, error) { + // Check if repository is initialized + initialized, err := s.IsRepositoryInitialized(ctx) + if err != nil { + return nil, err + } + if !initialized { + return nil, errors.NewRepoNotInitializedError(s.repoPath) + } + + // Get list of managed files for this host + managedFiles, err := s.configManager.ListManagedFiles(ctx, s.repoPath, host) + if err != nil { + return nil, err + } + + var restored []models.ManagedFile + homeDir, err := s.pathResolver.GetHomePath() + if err != nil { + return nil, errors.NewFileSystemOperationError("get_home_dir", "", err) + } + + for _, managedFile := range managedFiles { + // Determine symlink path (where the symlink should be created) + symlinkPath := filepath.Join(homeDir, managedFile.RelativePath) + + // Determine repository file path (what the symlink should point to) + repoFilePath, err := s.pathResolver.GetFileStoragePathInRepo(s.repoPath, host, managedFile.RelativePath) + if err != nil { + continue // Skip files with path resolution issues + } + + // Check if repository file exists + repoExists, err := s.fileManager.Exists(ctx, repoFilePath) + if err != nil || !repoExists { + continue // Skip missing files + } + + // Check if symlink already exists and is correct + if s.isValidSymlink(ctx, symlinkPath, repoFilePath) { + continue // Skip files that are already correctly symlinked + } + + // Ensure parent directory exists + symlinkDir := filepath.Dir(symlinkPath) + if err := s.fileManager.MkdirAll(ctx, symlinkDir, 0755); err != nil { + continue // Skip files with directory creation issues + } + + // Remove existing file/symlink if it exists + exists, err := s.fileManager.Exists(ctx, symlinkPath) + if err == nil && exists { + if err := s.fileManager.Remove(ctx, symlinkPath); err != nil { + continue // Skip files that can't be removed + } + } + + // Create symlink + if err := s.fileManager.CreateSymlink(ctx, repoFilePath, symlinkPath); err != nil { + continue // Skip files with symlink creation issues + } + + // Update the managed file with current paths + restoredFile := managedFile + restoredFile.OriginalPath = symlinkPath + restoredFile.RepoPath = repoFilePath + restored = append(restored, restoredFile) + } + + return restored, nil +} + +// isValidSymlink checks if the given path is a symlink pointing to the expected target +func (s *Service) isValidSymlink(ctx context.Context, symlinkPath, expectedTarget string) bool { + info, err := s.fileManager.Lstat(ctx, symlinkPath) + if err != nil { + return false + } + + // Check if it's a symlink + if info.Mode()&os.ModeSymlink == 0 { + return false + } + + // Check if it points to the correct target + target, err := s.fileManager.Readlink(ctx, symlinkPath) + if err != nil { + return false + } + + // Convert relative path to absolute if needed + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(symlinkPath), target) + } + + // Clean both paths for comparison + targetAbs, err := filepath.Abs(target) + if err != nil { + return false + } + + expectedAbs, err := filepath.Abs(expectedTarget) + if err != nil { + return false + } + + return targetAbs == expectedAbs +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..976fbe7 --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,1080 @@ +package service + +import ( + "context" + stderrors "errors" + "os" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/yarlson/lnk/internal/errors" + "github.com/yarlson/lnk/internal/models" +) + +// Mock implementations for testing + +type mockFileManager struct { + existsFunc func(ctx context.Context, path string) (bool, error) + isDirectoryFunc func(ctx context.Context, path string) (bool, error) + moveFunc func(ctx context.Context, src, dst string) error + removeFunc func(ctx context.Context, path string) error + statFunc func(ctx context.Context, path string) (os.FileInfo, error) + createSymlinkFunc func(ctx context.Context, target, linkPath string) error + mkdirAllFunc func(ctx context.Context, path string, perm os.FileMode) error + readlinkFunc func(ctx context.Context, path string) (string, error) + lstatFunc func(ctx context.Context, path string) (os.FileInfo, error) +} + +func (m *mockFileManager) Exists(ctx context.Context, path string) (bool, error) { + if m.existsFunc != nil { + return m.existsFunc(ctx, path) + } + return false, nil +} + +func (m *mockFileManager) IsDirectory(ctx context.Context, path string) (bool, error) { + if m.isDirectoryFunc != nil { + return m.isDirectoryFunc(ctx, path) + } + return false, nil +} + +func (m *mockFileManager) Move(ctx context.Context, src, dst string) error { + if m.moveFunc != nil { + return m.moveFunc(ctx, src, dst) + } + return nil +} + +func (m *mockFileManager) CreateSymlink(ctx context.Context, target, linkPath string) error { + if m.createSymlinkFunc != nil { + return m.createSymlinkFunc(ctx, target, linkPath) + } + return nil +} + +func (m *mockFileManager) Remove(ctx context.Context, path string) error { + if m.removeFunc != nil { + return m.removeFunc(ctx, path) + } + return nil +} + +func (m *mockFileManager) ReadFile(ctx context.Context, path string) ([]byte, error) { + return nil, nil +} + +func (m *mockFileManager) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error { + return nil +} + +func (m *mockFileManager) MkdirAll(ctx context.Context, path string, perm os.FileMode) error { + if m.mkdirAllFunc != nil { + return m.mkdirAllFunc(ctx, path, perm) + } + return nil +} + +func (m *mockFileManager) Readlink(ctx context.Context, path string) (string, error) { + if m.readlinkFunc != nil { + return m.readlinkFunc(ctx, path) + } + return "", nil +} + +func (m *mockFileManager) Lstat(ctx context.Context, path string) (os.FileInfo, error) { + if m.lstatFunc != nil { + return m.lstatFunc(ctx, path) + } + return nil, nil +} + +func (m *mockFileManager) Stat(ctx context.Context, path string) (os.FileInfo, error) { + if m.statFunc != nil { + return m.statFunc(ctx, path) + } + return nil, nil +} + +type mockGitManager struct { + isRepositoryFunc func(ctx context.Context, repoPath string) (bool, error) + statusFunc func(ctx context.Context, repoPath string) (*models.SyncStatus, error) + isLnkRepositoryFunc func(ctx context.Context, repoPath string) (bool, error) + initFunc func(ctx context.Context, repoPath string) error + cloneFunc func(ctx context.Context, repoPath, url string) error +} + +func (m *mockGitManager) Init(ctx context.Context, repoPath string) error { + if m.initFunc != nil { + return m.initFunc(ctx, repoPath) + } + return nil +} + +func (m *mockGitManager) Clone(ctx context.Context, repoPath, url string) error { + if m.cloneFunc != nil { + return m.cloneFunc(ctx, repoPath, url) + } + return nil +} + +func (m *mockGitManager) Add(ctx context.Context, repoPath string, files ...string) error { + return nil +} + +func (m *mockGitManager) Remove(ctx context.Context, repoPath string, files ...string) error { + return nil +} + +func (m *mockGitManager) Commit(ctx context.Context, repoPath, message string) error { + return nil +} + +func (m *mockGitManager) Push(ctx context.Context, repoPath string) error { + return nil +} + +func (m *mockGitManager) Pull(ctx context.Context, repoPath string) error { + return nil +} + +func (m *mockGitManager) Status(ctx context.Context, repoPath string) (*models.SyncStatus, error) { + if m.statusFunc != nil { + return m.statusFunc(ctx, repoPath) + } + return nil, nil +} + +func (m *mockGitManager) IsRepository(ctx context.Context, repoPath string) (bool, error) { + if m.isRepositoryFunc != nil { + return m.isRepositoryFunc(ctx, repoPath) + } + return true, nil +} + +func (m *mockGitManager) HasChanges(ctx context.Context, repoPath string) (bool, error) { + return false, nil +} + +func (m *mockGitManager) AddRemote(ctx context.Context, repoPath, name, url string) error { + return nil +} + +func (m *mockGitManager) GetRemoteURL(ctx context.Context, repoPath, name string) (string, error) { + return "", nil +} + +func (m *mockGitManager) IsLnkRepository(ctx context.Context, repoPath string) (bool, error) { + if m.isLnkRepositoryFunc != nil { + return m.isLnkRepositoryFunc(ctx, repoPath) + } + return true, nil +} + +type mockConfigManager struct { + listManagedFilesFunc func(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) + getManagedFileFunc func(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) + addManagedFileToHostFunc func(ctx context.Context, repoPath, host string, file models.ManagedFile) error + removeManagedFileFromHostFunc func(ctx context.Context, repoPath, host, relativePath string) error +} + +func (m *mockConfigManager) LoadHostConfig(ctx context.Context, repoPath, host string) (*models.HostConfig, error) { + return nil, nil +} + +func (m *mockConfigManager) SaveHostConfig(ctx context.Context, repoPath string, config *models.HostConfig) error { + return nil +} + +func (m *mockConfigManager) AddManagedFileToHost(ctx context.Context, repoPath, host string, file models.ManagedFile) error { + if m.addManagedFileToHostFunc != nil { + return m.addManagedFileToHostFunc(ctx, repoPath, host, file) + } + return nil +} + +func (m *mockConfigManager) RemoveManagedFileFromHost(ctx context.Context, repoPath, host, relativePath string) error { + if m.removeManagedFileFromHostFunc != nil { + return m.removeManagedFileFromHostFunc(ctx, repoPath, host, relativePath) + } + return nil +} + +func (m *mockConfigManager) ListManagedFiles(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) { + if m.listManagedFilesFunc != nil { + return m.listManagedFilesFunc(ctx, repoPath, host) + } + return []models.ManagedFile{}, nil +} + +func (m *mockConfigManager) GetManagedFile(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) { + if m.getManagedFileFunc != nil { + return m.getManagedFileFunc(ctx, repoPath, host, relativePath) + } + return nil, nil +} + +func (m *mockConfigManager) ConfigExists(ctx context.Context, repoPath, host string) (bool, error) { + return true, nil +} + +type mockPathResolver struct { + getAbsolutePathInHomeFunc func(path string) (string, error) + getRelativePathFromHomeFunc func(absPath string) (string, error) + getFileStoragePathInRepoFunc func(repoPath, host, relativePath string) (string, error) + getTrackingFilePathFunc func(repoPath, host string) (string, error) + getHomePathFunc func() (string, error) +} + +func (m *mockPathResolver) GetRepoStoragePath() (string, error) { + return "/test/repo", nil +} + +func (m *mockPathResolver) GetFileStoragePathInRepo(repoPath, host, relativePath string) (string, error) { + if m.getFileStoragePathInRepoFunc != nil { + return m.getFileStoragePathInRepoFunc(repoPath, host, relativePath) + } + return "/test/repo/file", nil +} + +func (m *mockPathResolver) GetTrackingFilePath(repoPath, host string) (string, error) { + if m.getTrackingFilePathFunc != nil { + return m.getTrackingFilePathFunc(repoPath, host) + } + return "/test/repo/.lnk", nil +} + +func (m *mockPathResolver) GetHomePath() (string, error) { + if m.getHomePathFunc != nil { + return m.getHomePathFunc() + } + return "/home/user", nil +} + +func (m *mockPathResolver) GetRelativePathFromHome(absPath string) (string, error) { + if m.getRelativePathFromHomeFunc != nil { + return m.getRelativePathFromHomeFunc(absPath) + } + return ".bashrc", nil +} + +func (m *mockPathResolver) GetAbsolutePathInHome(path string) (string, error) { + if m.getAbsolutePathInHomeFunc != nil { + return m.getAbsolutePathInHomeFunc(path) + } + return "/home/user/.bashrc", nil +} + +func (m *mockPathResolver) GetHostStoragePath(repoPath, host string) (string, error) { + if host == "" { + return repoPath, nil + } + return repoPath + "/" + host + ".lnk", nil +} + +func (m *mockPathResolver) IsUnderHome(path string) (bool, error) { + return true, nil +} + +// Helper mock for os.FileInfo +type mockFileInfo struct { + isDir bool + mode os.FileMode +} + +func (m *mockFileInfo) Name() string { return "test" } +func (m *mockFileInfo) Size() int64 { return 0 } +func (m *mockFileInfo) Mode() os.FileMode { + if m.mode != 0 { + return m.mode + } + return 0644 +} +func (m *mockFileInfo) ModTime() time.Time { return time.Now() } +func (m *mockFileInfo) IsDir() bool { return m.isDir } +func (m *mockFileInfo) Sys() interface{} { return nil } + +// Test Suite + +type LnkServiceTestSuite struct { + suite.Suite + ctx context.Context + repoPath string + host string +} + +func (suite *LnkServiceTestSuite) SetupTest() { + suite.ctx = context.Background() + suite.repoPath = "/test/repo" + suite.host = "testhost" +} + +func (suite *LnkServiceTestSuite) TestListManagedFilesSuccess() { + expectedFiles := []models.ManagedFile{ + { + RelativePath: ".vimrc", + Host: suite.host, + IsDirectory: false, + }, + { + RelativePath: ".bashrc", + Host: suite.host, + IsDirectory: false, + }, + } + + // Setup mocks + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + if path == suite.repoPath { + return true, nil + } + return false, nil + }, + } + + configManager := &mockConfigManager{ + listManagedFilesFunc: func(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) { + return expectedFiles, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, nil, configManager, &mockPathResolver{}, suite.repoPath) + + // Test + result, err := service.ListManagedFiles(suite.ctx, suite.host) + suite.NoError(err) + suite.Len(result, len(expectedFiles)) + + for i, expected := range expectedFiles { + suite.Equal(expected.RelativePath, result[i].RelativePath) + } +} + +func (suite *LnkServiceTestSuite) TestListManagedFilesRepoNotExists() { + // Setup mocks + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return false, nil // Repository doesn't exist + }, + } + + service := NewLnkServiceWithDeps(fileManager, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + // Test + _, err := service.ListManagedFiles(suite.ctx, suite.host) + suite.Error(err) + + // Check that it's the correct error type + suite.True(errors.NewRepoNotInitializedError("").Is(err)) +} + +func (suite *LnkServiceTestSuite) TestListManagedFilesFileSystemError() { + expectedError := stderrors.New("fs error") + + // Setup mocks + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return false, expectedError + }, + } + + service := NewLnkServiceWithDeps(fileManager, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + // Test + _, err := service.ListManagedFiles(suite.ctx, suite.host) + suite.Error(err) + + // Check that it's wrapped as a FileSystemOperation error + suite.True(errors.NewFileSystemOperationError("", "", nil).Is(err)) +} + +func (suite *LnkServiceTestSuite) TestListManagedFilesConfigManagerError() { + expectedError := errors.NewConfigNotFoundError(suite.host) + + // Setup mocks + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil // Repository exists + }, + } + + configManager := &mockConfigManager{ + listManagedFilesFunc: func(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) { + return nil, expectedError + }, + } + + service := NewLnkServiceWithDeps(fileManager, nil, configManager, &mockPathResolver{}, suite.repoPath) + + // Test + _, err := service.ListManagedFiles(suite.ctx, suite.host) + suite.Error(err) + suite.Equal(expectedError, err) +} + +func (suite *LnkServiceTestSuite) TestIsRepositoryInitializedWithGitManager() { + // Setup mocks + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + } + + gitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, gitManager, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + // Test + isInit, err := service.IsRepositoryInitialized(suite.ctx) + suite.NoError(err) + suite.True(isInit) +} + +func (suite *LnkServiceTestSuite) TestIsRepositoryInitializedWithoutGitManager() { + // Setup mocks + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + // Test + isInit, err := service.IsRepositoryInitialized(suite.ctx) + suite.NoError(err) + suite.True(isInit) +} + +func (suite *LnkServiceTestSuite) TestIsRepositoryInitializedDirectoryNotExists() { + // Setup mocks + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return false, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + // Test + isInit, err := service.IsRepositoryInitialized(suite.ctx) + suite.NoError(err) + suite.False(isInit) +} + +func (suite *LnkServiceTestSuite) TestGetStatusSuccess() { + mockFileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil // Repository exists + }, + } + mockGitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil // Is a git repository + }, + statusFunc: func(ctx context.Context, repoPath string) (*models.SyncStatus, error) { + return &models.SyncStatus{ + CurrentBranch: "main", + Dirty: false, + HasRemote: true, + RemoteURL: "https://github.com/test/repo.git", + Ahead: 2, + Behind: 1, + }, nil + }, + } + + service := NewLnkServiceWithDeps(mockFileManager, mockGitManager, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + status, err := service.GetStatus(suite.ctx) + suite.NoError(err) + suite.Equal("main", status.CurrentBranch) + suite.Equal(2, status.Ahead) + suite.Equal(1, status.Behind) +} + +func (suite *LnkServiceTestSuite) TestGetStatusGitManagerNotAvailable() { + // Create service without GitManager (nil) + service := NewLnkServiceWithDeps(&mockFileManager{}, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.GetStatus(suite.ctx) + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeGitOperation, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestGetStatusRepoNotExists() { + mockFileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return false, nil + }, + } + + service := NewLnkServiceWithDeps(mockFileManager, &mockGitManager{}, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.GetStatus(suite.ctx) + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeRepoNotInitialized, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestGetStatusNotAGitRepository() { + mockFileManager := &mockFileManager{} + mockGitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return false, nil + }, + } + + service := NewLnkServiceWithDeps(mockFileManager, mockGitManager, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.GetStatus(suite.ctx) + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeRepoNotInitialized, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestGetStatusGitStatusError() { + // Mock file manager - repo exists + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + } + + // Mock git manager - is repository, but status fails + mockGit := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + statusFunc: func(ctx context.Context, repoPath string) (*models.SyncStatus, error) { + return nil, errors.NewGitOperationError("status", stderrors.New("git status failed")) + }, + } + + service := NewLnkServiceWithDeps(mockFS, mockGit, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.GetStatus(suite.ctx) + suite.Error(err) + + // Should be a git operation error + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeGitOperation, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestInitializeRepositoryEmptySuccess() { + // Mock file manager - directory doesn't exist initially + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return false, nil // Directory doesn't exist + }, + } + + service := NewLnkServiceWithDeps(mockFS, &mockGitManager{}, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.InitializeRepository(suite.ctx, "") + suite.NoError(err) +} + +func (suite *LnkServiceTestSuite) TestInitializeRepositoryCloneSuccess() { + service := NewLnkServiceWithDeps(&mockFileManager{}, &mockGitManager{}, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.InitializeRepository(suite.ctx, "https://github.com/user/dotfiles.git") + suite.NoError(err) +} + +func (suite *LnkServiceTestSuite) TestInitializeRepositoryGitManagerNotAvailable() { + service := NewLnkServiceWithDeps(&mockFileManager{}, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.InitializeRepository(suite.ctx, "") + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeGitOperation, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestInitializeRepositoryExistingLnkRepo() { + // Mock file manager - directory exists + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + } + + // Mock git manager - existing lnk repository + mockGit := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + service := NewLnkServiceWithDeps(mockFS, mockGit, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.InitializeRepository(suite.ctx, "") + suite.NoError(err) +} + +func (suite *LnkServiceTestSuite) TestInitializeRepositoryExistingNonLnkRepo() { + // Mock file manager - directory exists + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + } + + // Mock git manager - existing non-lnk repository + mockGit := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + isLnkRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return false, nil + }, + } + + service := NewLnkServiceWithDeps(mockFS, mockGit, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.InitializeRepository(suite.ctx, "") + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeRepoNotInitialized, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestInitializeRepositoryCloneError() { + // Mock git manager with clone error + mockGit := &mockGitManager{ + cloneFunc: func(ctx context.Context, repoPath, url string) error { + return stderrors.New("clone failed") + }, + } + + service := NewLnkServiceWithDeps(&mockFileManager{}, mockGit, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.InitializeRepository(suite.ctx, "https://github.com/user/dotfiles.git") + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeGitOperation, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestAddFileSuccess() { + // Mock file info + mockFileInfo := &mockFileInfo{isDir: false} + + // Mock file manager + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + if path == suite.repoPath { + return true, nil // Repository exists + } + if path == "/home/user/.vimrc" { + return true, nil // File exists + } + return false, nil + }, + statFunc: func(ctx context.Context, path string) (os.FileInfo, error) { + return mockFileInfo, nil + }, + } + + // Mock git manager + mockGit := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + // Mock config manager + mockConfig := &mockConfigManager{ + getManagedFileFunc: func(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) { + return nil, errors.NewFileNotFoundError("not managed") // File not managed yet + }, + } + + // Mock path resolver + mockPath := &mockPathResolver{ + getAbsolutePathInHomeFunc: func(path string) (string, error) { + if path == ".vimrc" { + return "/home/user/.vimrc", nil + } + return "", stderrors.New("path not found") + }, + getRelativePathFromHomeFunc: func(absPath string) (string, error) { + if absPath == "/home/user/.vimrc" { + return ".vimrc", nil + } + return "", stderrors.New("path not under home") + }, + getFileStoragePathInRepoFunc: func(repoPath, host, relativePath string) (string, error) { + return "/test/repo/.vimrc", nil + }, + } + + service := NewLnkServiceWithDeps(mockFS, mockGit, mockConfig, mockPath, suite.repoPath) + + managedFile, err := service.AddFile(suite.ctx, ".vimrc", "") + suite.NoError(err) + suite.NotNil(managedFile) + suite.Equal(".vimrc", managedFile.RelativePath) + suite.False(managedFile.IsDirectory) +} + +func (suite *LnkServiceTestSuite) TestAddFileGitManagerNotAvailable() { + service := NewLnkServiceWithDeps(&mockFileManager{}, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.AddFile(suite.ctx, ".vimrc", "") + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeGitOperation, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestAddFileRepoNotInitialized() { + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + if path == "/home/user/.vimrc" { + return true, nil // File exists + } + return false, nil // Repository doesn't exist + }, + } + + // Mock path resolver + mockPath := &mockPathResolver{ + getAbsolutePathInHomeFunc: func(path string) (string, error) { + if path == ".vimrc" { + return "/home/user/.vimrc", nil + } + return "", stderrors.New("path not found") + }, + } + + service := NewLnkServiceWithDeps(mockFS, &mockGitManager{}, &mockConfigManager{}, mockPath, suite.repoPath) + + _, err := service.AddFile(suite.ctx, ".vimrc", "") + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeRepoNotInitialized, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestAddFileFileNotExists() { + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + if path == suite.repoPath { + return true, nil // Repository exists + } + return false, nil // File doesn't exist + }, + } + + mockGit := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + service := NewLnkServiceWithDeps(mockFS, mockGit, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.AddFile(suite.ctx, ".vimrc", "") + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeFileNotFound, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestAddFileFileAlreadyManaged() { + mockFS := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil // Both repo and file exist + }, + statFunc: func(ctx context.Context, path string) (os.FileInfo, error) { + return &mockFileInfo{isDir: false}, nil + }, + } + + mockGit := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + mockConfig := &mockConfigManager{ + getManagedFileFunc: func(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) { + // Return existing managed file + return &models.ManagedFile{RelativePath: relativePath}, nil + }, + } + + service := NewLnkServiceWithDeps(mockFS, mockGit, mockConfig, &mockPathResolver{}, suite.repoPath) + + _, err := service.AddFile(suite.ctx, ".vimrc", "") + suite.Error(err) + + var lnkErr *errors.LnkError + suite.True(stderrors.As(err, &lnkErr)) + suite.Equal(errors.ErrorCodeFileAlreadyManaged, lnkErr.Code) +} + +func (suite *LnkServiceTestSuite) TestRemoveFileSuccess() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + lstatFunc: func(ctx context.Context, path string) (os.FileInfo, error) { + return &mockFileInfo{mode: os.ModeSymlink}, nil + }, + readlinkFunc: func(ctx context.Context, path string) (string, error) { + return "/test/repo/.bashrc", nil + }, + statFunc: func(ctx context.Context, path string) (os.FileInfo, error) { + return &mockFileInfo{mode: 0644}, nil + }, + } + + gitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + configManager := &mockConfigManager{ + getManagedFileFunc: func(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) { + return &models.ManagedFile{ + RelativePath: ".bashrc", + Host: "", + }, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, gitManager, configManager, &mockPathResolver{}, suite.repoPath) + + err := service.RemoveFile(suite.ctx, ".bashrc", "") + suite.NoError(err) +} + +func (suite *LnkServiceTestSuite) TestRemoveFileGitManagerNotAvailable() { + service := NewLnkServiceWithDeps(&mockFileManager{}, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.RemoveFile(suite.ctx, ".bashrc", "") + suite.Error(err) + suite.Contains(err.Error(), "git manager not available") +} + +func (suite *LnkServiceTestSuite) TestRemoveFileRepoNotInitialized() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return false, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, &mockGitManager{}, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.RemoveFile(suite.ctx, ".bashrc", "") + suite.Error(err) + suite.Contains(err.Error(), "repository not initialized") +} + +func (suite *LnkServiceTestSuite) TestRemoveFileFileNotSymlink() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + lstatFunc: func(ctx context.Context, path string) (os.FileInfo, error) { + return &mockFileInfo{mode: 0644}, nil // Not a symlink + }, + } + + gitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, gitManager, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.RemoveFile(suite.ctx, ".bashrc", "") + suite.Error(err) + suite.Contains(err.Error(), "not a symlink") +} + +func (suite *LnkServiceTestSuite) TestRemoveFileFileNotManaged() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + lstatFunc: func(ctx context.Context, path string) (os.FileInfo, error) { + return &mockFileInfo{mode: os.ModeSymlink}, nil + }, + readlinkFunc: func(ctx context.Context, path string) (string, error) { + return "/test/repo/.bashrc", nil + }, + } + + gitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + configManager := &mockConfigManager{ + getManagedFileFunc: func(ctx context.Context, repoPath, host, relativePath string) (*models.ManagedFile, error) { + return nil, nil // File not managed + }, + } + + service := NewLnkServiceWithDeps(fileManager, gitManager, configManager, &mockPathResolver{}, suite.repoPath) + + err := service.RemoveFile(suite.ctx, ".bashrc", "") + suite.Error(err) + suite.Contains(err.Error(), "not managed by lnk") +} + +func (suite *LnkServiceTestSuite) TestPushChangesSuccess() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + } + + gitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, gitManager, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.PushChanges(suite.ctx, "test commit") + suite.NoError(err) +} + +func (suite *LnkServiceTestSuite) TestPushChangesGitManagerNotAvailable() { + service := NewLnkServiceWithDeps(&mockFileManager{}, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + err := service.PushChanges(suite.ctx, "test commit") + suite.Error(err) + suite.Contains(err.Error(), "git manager not available") +} + +func (suite *LnkServiceTestSuite) TestPullChangesSuccess() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return true, nil + }, + } + + gitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + configManager := &mockConfigManager{ + listManagedFilesFunc: func(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) { + return []models.ManagedFile{}, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, gitManager, configManager, &mockPathResolver{}, suite.repoPath) + + restored, err := service.PullChanges(suite.ctx, "") + suite.NoError(err) + suite.Len(restored, 0) +} + +func (suite *LnkServiceTestSuite) TestPullChangesGitManagerNotAvailable() { + service := NewLnkServiceWithDeps(&mockFileManager{}, nil, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.PullChanges(suite.ctx, "") + suite.Error(err) + suite.Contains(err.Error(), "git manager not available") +} + +func (suite *LnkServiceTestSuite) TestRestoreSymlinksForHostSuccess() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + if path == suite.repoPath { + return true, nil + } + if path == "/test/repo/.bashrc" { + return true, nil // Repository file exists + } + return false, nil // Symlink doesn't exist yet + }, + lstatFunc: func(ctx context.Context, path string) (os.FileInfo, error) { + return nil, os.ErrNotExist // Symlink doesn't exist + }, + } + + gitManager := &mockGitManager{ + isRepositoryFunc: func(ctx context.Context, repoPath string) (bool, error) { + return true, nil + }, + } + + configManager := &mockConfigManager{ + listManagedFilesFunc: func(ctx context.Context, repoPath, host string) ([]models.ManagedFile, error) { + return []models.ManagedFile{ + { + RelativePath: ".bashrc", + Host: "", + }, + }, nil + }, + } + + pathResolver := &mockPathResolver{ + getFileStoragePathInRepoFunc: func(repoPath, host, relativePath string) (string, error) { + return "/test/repo/.bashrc", nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, gitManager, configManager, pathResolver, suite.repoPath) + + restored, err := service.RestoreSymlinksForHost(suite.ctx, "") + suite.NoError(err) + suite.Len(restored, 1) + if len(restored) > 0 { + suite.Equal(".bashrc", restored[0].RelativePath) + } +} + +func (suite *LnkServiceTestSuite) TestRestoreSymlinksForHostRepoNotInitialized() { + fileManager := &mockFileManager{ + existsFunc: func(ctx context.Context, path string) (bool, error) { + return false, nil + }, + } + + service := NewLnkServiceWithDeps(fileManager, &mockGitManager{}, &mockConfigManager{}, &mockPathResolver{}, suite.repoPath) + + _, err := service.RestoreSymlinksForHost(suite.ctx, "") + suite.Error(err) + suite.Contains(err.Error(), "repository not initialized") +} + +func TestLnkServiceSuite(t *testing.T) { + suite.Run(t, new(LnkServiceTestSuite)) +}