diff --git a/cmd/add.go b/cmd/add.go index b700a81..44824f1 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -8,28 +8,26 @@ import ( "github.com/yarlson/lnk/internal/core" ) -var addCmd = &cobra.Command{ - Use: "add ", - Short: "✨ Add a file to lnk management", - Long: "Moves a file to the lnk repository and creates a symlink in its place.", - Args: cobra.ExactArgs(1), - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] +func newAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "✨ Add a file to lnk management", + Long: "Moves a file to the lnk repository and creates a symlink in its place.", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + filePath := args[0] - lnk := core.NewLnk() - if err := lnk.Add(filePath); err != nil { - return fmt.Errorf("failed to add file: %w", err) - } + lnk := core.NewLnk() + if err := lnk.Add(filePath); err != nil { + return fmt.Errorf("failed to add file: %w", err) + } - basename := filepath.Base(filePath) - fmt.Printf("✨ \033[1mAdded %s to lnk\033[0m\n", basename) - fmt.Printf(" šŸ”— \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename) - fmt.Printf(" šŸ“ Use \033[1mlnk push\033[0m to sync to remote\n") - return nil - }, -} - -func init() { - rootCmd.AddCommand(addCmd) + basename := filepath.Base(filePath) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "✨ \033[1mAdded %s to lnk\033[0m\n", basename) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ”— \033[90m%s\033[0m → \033[36m~/.config/lnk/%s\033[0m\n", filePath, basename) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“ Use \033[1mlnk push\033[0m to sync to remote\n") + return nil + }, + } } diff --git a/cmd/init.go b/cmd/init.go index d007617..6eb3790 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -7,39 +7,39 @@ import ( "github.com/yarlson/lnk/internal/core" ) -var initCmd = &cobra.Command{ - Use: "init", - Short: "šŸŽÆ Initialize a new lnk repository", - Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - remote, _ := cmd.Flags().GetString("remote") +func newInitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "šŸŽÆ Initialize a new lnk repository", + Long: "Creates the lnk directory and initializes a Git repository for managing dotfiles.", + SilenceUsage: true, + 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) - } + lnk := core.NewLnk() + if err := lnk.InitWithRemote(remote); err != nil { + return fmt.Errorf("failed to initialize lnk: %w", err) + } - if remote != "" { - fmt.Printf("šŸŽÆ \033[1mInitialized lnk repository\033[0m\n") - fmt.Printf(" šŸ“¦ Cloned from: \033[36m%s\033[0m\n", remote) - fmt.Printf(" šŸ“ Location: \033[90m~/.config/lnk\033[0m\n") - fmt.Printf("\nšŸ’” \033[33mNext steps:\033[0m\n") - fmt.Printf(" • Run \033[1mlnk pull\033[0m to restore symlinks\n") - fmt.Printf(" • Use \033[1mlnk add \033[0m to manage new files\n") - } else { - fmt.Printf("šŸŽÆ \033[1mInitialized empty lnk repository\033[0m\n") - fmt.Printf(" šŸ“ Location: \033[90m~/.config/lnk\033[0m\n") - fmt.Printf("\nšŸ’” \033[33mNext steps:\033[0m\n") - fmt.Printf(" • Run \033[1mlnk add \033[0m to start managing dotfiles\n") - fmt.Printf(" • Add a remote with: \033[1mgit remote add origin \033[0m\n") - } + if remote != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "šŸŽÆ \033[1mInitialized lnk repository\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“¦ Cloned from: \033[36m%s\033[0m\n", remote) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“ Location: \033[90m~/.config/lnk\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nšŸ’” \033[33mNext steps:\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Run \033[1mlnk pull\033[0m to restore symlinks\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Use \033[1mlnk add \033[0m to manage new files\n") + } else { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "šŸŽÆ \033[1mInitialized empty lnk repository\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“ Location: \033[90m~/.config/lnk\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nšŸ’” \033[33mNext steps:\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Run \033[1mlnk add \033[0m to start managing dotfiles\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " • Add a remote with: \033[1mgit remote add origin \033[0m\n") + } - return nil - }, -} - -func init() { - initCmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") - rootCmd.AddCommand(initCmd) + return nil + }, + } + + cmd.Flags().StringP("remote", "r", "", "Clone from remote URL instead of creating empty repository") + return cmd } diff --git a/cmd/pull.go b/cmd/pull.go index 403c5fc..b21f065 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -7,39 +7,37 @@ import ( "github.com/yarlson/lnk/internal/core" ) -var pullCmd = &cobra.Command{ - Use: "pull", - Short: "ā¬‡ļø Pull changes from remote and restore symlinks", - Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - lnk := core.NewLnk() - restored, err := lnk.Pull() - if err != nil { - return fmt.Errorf("failed to pull changes: %w", err) - } - - if len(restored) > 0 { - fmt.Printf("ā¬‡ļø \033[1;32mSuccessfully pulled changes\033[0m\n") - fmt.Printf(" šŸ”— Restored \033[1m%d symlink", len(restored)) - if len(restored) > 1 { - fmt.Printf("s") +func newPullCmd() *cobra.Command { + return &cobra.Command{ + Use: "pull", + Short: "ā¬‡ļø Pull changes from remote and restore symlinks", + Long: "Fetches changes from remote repository and automatically restores symlinks for all managed files.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + lnk := core.NewLnk() + restored, err := lnk.Pull() + if err != nil { + return fmt.Errorf("failed to pull changes: %w", err) } - fmt.Printf("\033[0m:\n") - for _, file := range restored { - fmt.Printf(" ✨ \033[36m%s\033[0m\n", file) + + if len(restored) > 0 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ā¬‡ļø \033[1;32mSuccessfully pulled changes\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ”— Restored \033[1m%d symlink", len(restored)) + if len(restored) > 1 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "s") + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[0m:\n") + for _, file := range restored { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " ✨ \033[36m%s\033[0m\n", file) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n šŸŽ‰ Your dotfiles are synced and ready!\n") + } else { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ā¬‡ļø \033[1;32mSuccessfully pulled changes\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " āœ… All symlinks already in place\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸŽ‰ Everything is up to date!\n") } - fmt.Printf("\n šŸŽ‰ Your dotfiles are synced and ready!\n") - } else { - fmt.Printf("ā¬‡ļø \033[1;32mSuccessfully pulled changes\033[0m\n") - fmt.Printf(" āœ… All symlinks already in place\n") - fmt.Printf(" šŸŽ‰ Everything is up to date!\n") - } - return nil - }, -} - -func init() { - rootCmd.AddCommand(pullCmd) + return nil + }, + } } diff --git a/cmd/push.go b/cmd/push.go index 440f913..240acb6 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -7,31 +7,29 @@ import ( "github.com/yarlson/lnk/internal/core" ) -var pushCmd = &cobra.Command{ - Use: "push [message]", - Short: "šŸš€ Push local changes to remote repository", - Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.", - Args: cobra.MaximumNArgs(1), - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - message := "lnk: sync configuration files" - if len(args) > 0 { - message = args[0] - } +func newPushCmd() *cobra.Command { + return &cobra.Command{ + Use: "push [message]", + Short: "šŸš€ Push local changes to remote repository", + Long: "Stages all changes, creates a sync commit with the provided message, and pushes to remote.", + Args: cobra.MaximumNArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + message := "lnk: sync configuration files" + if len(args) > 0 { + message = args[0] + } - lnk := core.NewLnk() - if err := lnk.Push(message); err != nil { - return fmt.Errorf("failed to push changes: %w", err) - } + lnk := core.NewLnk() + if err := lnk.Push(message); err != nil { + return fmt.Errorf("failed to push changes: %w", err) + } - fmt.Printf("šŸš€ \033[1;32mSuccessfully pushed changes\033[0m\n") - fmt.Printf(" šŸ’¾ Commit: \033[90m%s\033[0m\n", message) - fmt.Printf(" šŸ“” Synced to remote\n") - fmt.Printf(" ✨ Your dotfiles are up to date!\n") - return nil - }, -} - -func init() { - rootCmd.AddCommand(pushCmd) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "šŸš€ \033[1;32mSuccessfully pushed changes\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ’¾ Commit: \033[90m%s\033[0m\n", message) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“” Synced to remote\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " ✨ Your dotfiles are up to date!\n") + return nil + }, + } } diff --git a/cmd/rm.go b/cmd/rm.go index 7ef0669..df12eae 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -8,28 +8,26 @@ import ( "github.com/yarlson/lnk/internal/core" ) -var rmCmd = &cobra.Command{ - Use: "rm ", - Short: "šŸ—‘ļø Remove a file from lnk management", - Long: "Removes a symlink and restores the original file from the lnk repository.", - Args: cobra.ExactArgs(1), - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - filePath := args[0] +func newRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "rm ", + Short: "šŸ—‘ļø Remove a file from lnk management", + Long: "Removes a symlink and restores the original file from the lnk repository.", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + filePath := args[0] - lnk := core.NewLnk() - if err := lnk.Remove(filePath); err != nil { - return fmt.Errorf("failed to remove file: %w", err) - } + lnk := core.NewLnk() + if err := lnk.Remove(filePath); err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } - basename := filepath.Base(filePath) - fmt.Printf("šŸ—‘ļø \033[1mRemoved %s from lnk\033[0m\n", basename) - fmt.Printf(" ā†©ļø \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath) - fmt.Printf(" šŸ“„ Original file restored\n") - return nil - }, -} - -func init() { - rootCmd.AddCommand(rmCmd) + basename := filepath.Base(filePath) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "šŸ—‘ļø \033[1mRemoved %s from lnk\033[0m\n", basename) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " ā†©ļø \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“„ Original file restored\n") + return nil + }, + } } diff --git a/cmd/root.go b/cmd/root.go index 7bc8bd0..0802e59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,10 +12,12 @@ var ( buildTime = "unknown" ) -var rootCmd = &cobra.Command{ - Use: "lnk", - Short: "šŸ”— Dotfiles, linked. No fluff.", - Long: `šŸ”— Lnk - Git-native dotfiles management that doesn't suck. +// NewRootCommand creates a new root command (testable) +func NewRootCommand() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "lnk", + Short: "šŸ”— Dotfiles, linked. No fluff.", + Long: `šŸ”— Lnk - Git-native dotfiles management that doesn't suck. Move your dotfiles to ~/.config/lnk, symlink them back, and use Git like normal. That's it. @@ -28,17 +30,29 @@ That's it. lnk pull # Get latest changes šŸŽÆ Simple, fast, and Git-native.`, - SilenceUsage: true, + SilenceUsage: true, + Version: fmt.Sprintf("%s (built %s)", version, buildTime), + } + + // Add subcommands + rootCmd.AddCommand(newInitCmd()) + rootCmd.AddCommand(newAddCmd()) + rootCmd.AddCommand(newRemoveCmd()) + rootCmd.AddCommand(newStatusCmd()) + rootCmd.AddCommand(newPushCmd()) + rootCmd.AddCommand(newPullCmd()) + + return rootCmd } // SetVersion sets the version information for the CLI func SetVersion(v, bt string) { version = v buildTime = bt - rootCmd.Version = fmt.Sprintf("%s (built %s)", version, buildTime) } func Execute() { + rootCmd := NewRootCommand() if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/cmd/status.go b/cmd/status.go index 7199442..340155a 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -7,52 +7,50 @@ import ( "github.com/yarlson/lnk/internal/core" ) -var statusCmd = &cobra.Command{ - Use: "status", - Short: "šŸ“Š Show repository sync status", - Long: "Display how many commits ahead/behind the local repository is relative to the remote.", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - lnk := core.NewLnk() - status, err := lnk.Status() - if err != nil { - return fmt.Errorf("failed to get status: %w", err) - } +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "šŸ“Š Show repository sync status", + Long: "Display how many commits ahead/behind the local repository is relative to the remote.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + lnk := core.NewLnk() + status, err := lnk.Status() + if err != nil { + return fmt.Errorf("failed to get status: %w", err) + } - if status.Ahead == 0 && status.Behind == 0 { - fmt.Printf("āœ… \033[1;32mRepository is up to date\033[0m\n") - fmt.Printf(" šŸ“” Synced with \033[36m%s\033[0m\n", status.Remote) - } else { - fmt.Printf("šŸ“Š \033[1mRepository Status\033[0m\n") - fmt.Printf(" šŸ“” Remote: \033[36m%s\033[0m\n", status.Remote) - fmt.Printf("\n") + if status.Ahead == 0 && status.Behind == 0 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "āœ… \033[1;32mRepository is up to date\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“” Synced with \033[36m%s\033[0m\n", status.Remote) + } else { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "šŸ“Š \033[1mRepository Status\033[0m\n") + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " šŸ“” Remote: \033[36m%s\033[0m\n", status.Remote) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n") - if status.Ahead > 0 { - commitText := "commit" - if status.Ahead > 1 { - commitText = "commits" + if status.Ahead > 0 { + commitText := "commit" + if status.Ahead > 1 { + commitText = "commits" + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " ā¬†ļø \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText) } - fmt.Printf(" ā¬†ļø \033[1;33m%d %s ahead\033[0m - ready to push\n", status.Ahead, commitText) - } - if status.Behind > 0 { - commitText := "commit" - if status.Behind > 1 { - commitText = "commits" + if status.Behind > 0 { + commitText := "commit" + if status.Behind > 1 { + commitText = "commits" + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), " ā¬‡ļø \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText) + } + + if status.Ahead > 0 && status.Behind == 0 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nšŸ’” Run \033[1mlnk push\033[0m to sync your changes") + } else if status.Behind > 0 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nšŸ’” Run \033[1mlnk pull\033[0m to get latest changes") } - fmt.Printf(" ā¬‡ļø \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText) } - if status.Ahead > 0 && status.Behind == 0 { - fmt.Printf("\nšŸ’” Run \033[1mlnk push\033[0m to sync your changes") - } else if status.Behind > 0 { - fmt.Printf("\nšŸ’” Run \033[1mlnk pull\033[0m to get latest changes") - } - } - - return nil - }, -} - -func init() { - rootCmd.AddCommand(statusCmd) + return nil + }, + } } diff --git a/test/cli_test.go b/test/cli_test.go new file mode 100644 index 0000000..eb27e2d --- /dev/null +++ b/test/cli_test.go @@ -0,0 +1,336 @@ +package test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/yarlson/lnk/cmd" +) + +type CLITestSuite struct { + suite.Suite + tempDir string + originalDir string + stdout *bytes.Buffer + stderr *bytes.Buffer +} + +func (suite *CLITestSuite) SetupTest() { + // Create temp directory and change to it + tempDir, err := os.MkdirTemp("", "lnk-cli-test-*") + suite.Require().NoError(err) + suite.tempDir = tempDir + + 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) + + // Capture output + suite.stdout = &bytes.Buffer{} + suite.stderr = &bytes.Buffer{} +} + +func (suite *CLITestSuite) TearDownTest() { + err := os.Chdir(suite.originalDir) + suite.Require().NoError(err) + err = os.RemoveAll(suite.tempDir) + suite.Require().NoError(err) +} + +func (suite *CLITestSuite) runCommand(args ...string) error { + rootCmd := cmd.NewRootCommand() + rootCmd.SetOut(suite.stdout) + rootCmd.SetErr(suite.stderr) + rootCmd.SetArgs(args) + return rootCmd.Execute() +} + +func (suite *CLITestSuite) TestInitCommand() { + err := suite.runCommand("init") + suite.NoError(err) + + // Check output + output := suite.stdout.String() + suite.Contains(output, "Initialized empty lnk repository") + suite.Contains(output, "Location:") + suite.Contains(output, "Next steps:") + suite.Contains(output, "lnk add ") + + // Verify actual effect + lnkDir := filepath.Join(suite.tempDir, "lnk") + suite.DirExists(lnkDir) + + gitDir := filepath.Join(lnkDir, ".git") + suite.DirExists(gitDir) +} + +func (suite *CLITestSuite) TestInitWithRemote() { + err := suite.runCommand("init", "-r", "https://github.com/user/dotfiles.git") + // This will fail because we don't have a real remote, but that's expected + suite.Error(err) + suite.Contains(err.Error(), "git clone failed") +} + +func (suite *CLITestSuite) TestAddCommand() { + // Initialize first + err := suite.runCommand("init") + suite.Require().NoError(err) + suite.stdout.Reset() + + // Create test file + testFile := filepath.Join(suite.tempDir, ".bashrc") + err = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) + suite.Require().NoError(err) + + // Test add command + err = suite.runCommand("add", testFile) + suite.NoError(err) + + // Check output + output := suite.stdout.String() + suite.Contains(output, "Added .bashrc to lnk") + suite.Contains(output, "→") + suite.Contains(output, "sync to remote") + + // Verify symlink was created + info, err := os.Lstat(testFile) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) + + // Verify file exists in repo + repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc") + suite.FileExists(repoFile) +} + +func (suite *CLITestSuite) TestRemoveCommand() { + // Setup: init and add a file + _ = suite.runCommand("init") + testFile := filepath.Join(suite.tempDir, ".vimrc") + _ = os.WriteFile(testFile, []byte("set number"), 0644) + _ = suite.runCommand("add", testFile) + suite.stdout.Reset() + + // Test remove command + err := suite.runCommand("rm", testFile) + suite.NoError(err) + + // Check output + output := suite.stdout.String() + suite.Contains(output, "Removed .vimrc from lnk") + suite.Contains(output, "→") + suite.Contains(output, "Original file restored") + + // Verify symlink is gone and regular file is restored + info, err := os.Lstat(testFile) + suite.NoError(err) + suite.Equal(os.FileMode(0), info.Mode()&os.ModeSymlink) // Not a symlink + + // Verify content is preserved + content, err := os.ReadFile(testFile) + suite.NoError(err) + suite.Equal("set number", string(content)) +} + +func (suite *CLITestSuite) TestStatusCommand() { + // Initialize first + err := suite.runCommand("init") + suite.Require().NoError(err) + suite.stdout.Reset() + + // Test status without remote - should fail + err = suite.runCommand("status") + suite.Error(err) + suite.Contains(err.Error(), "no remote configured") +} + +func (suite *CLITestSuite) TestErrorHandling() { + tests := []struct { + name string + args []string + wantErr bool + errContains string + outContains string + }{ + { + name: "add nonexistent file", + args: []string{"add", "/nonexistent/file"}, + wantErr: true, + errContains: "File does not exist", + }, + { + name: "status without init", + args: []string{"status"}, + wantErr: true, + errContains: "Lnk repository not initialized", + }, + { + name: "help command", + args: []string{"--help"}, + wantErr: false, + outContains: "Lnk - Git-native dotfiles management", + }, + { + name: "version command", + args: []string{"--version"}, + wantErr: false, + outContains: "lnk version", + }, + { + name: "init help", + args: []string{"init", "--help"}, + wantErr: false, + outContains: "Creates the lnk directory", + }, + { + name: "add help", + args: []string{"add", "--help"}, + wantErr: false, + outContains: "Moves a file to the lnk repository", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + suite.stdout.Reset() + suite.stderr.Reset() + + err := suite.runCommand(tt.args...) + + if tt.wantErr { + suite.Error(err, "Expected error for %s", tt.name) + if tt.errContains != "" { + suite.Contains(err.Error(), tt.errContains, "Wrong error message for %s", tt.name) + } + } else { + suite.NoError(err, "Unexpected error for %s", tt.name) + } + + if tt.outContains != "" { + output := suite.stdout.String() + suite.Contains(output, tt.outContains, "Expected output not found for %s", tt.name) + } + }) + } +} + +func (suite *CLITestSuite) TestCompleteWorkflow() { + // Test realistic user workflow + steps := []struct { + name string + args []string + setup func() + verify func(output string) + }{ + { + name: "initialize repository", + args: []string{"init"}, + verify: func(output string) { + suite.Contains(output, "Initialized empty lnk repository") + }, + }, + { + name: "add config file", + args: []string{"add", ".bashrc"}, + setup: func() { + testFile := filepath.Join(suite.tempDir, ".bashrc") + _ = os.WriteFile(testFile, []byte("export PATH=/usr/local/bin:$PATH"), 0644) + }, + verify: func(output string) { + suite.Contains(output, "Added .bashrc to lnk") + }, + }, + { + name: "add another file", + args: []string{"add", ".vimrc"}, + setup: func() { + testFile := filepath.Join(suite.tempDir, ".vimrc") + _ = os.WriteFile(testFile, []byte("set number"), 0644) + }, + verify: func(output string) { + suite.Contains(output, "Added .vimrc to lnk") + }, + }, + { + name: "remove file", + args: []string{"rm", ".vimrc"}, + verify: func(output string) { + suite.Contains(output, "Removed .vimrc from lnk") + }, + }, + } + + for _, step := range steps { + suite.Run(step.name, func() { + if step.setup != nil { + step.setup() + } + + suite.stdout.Reset() + suite.stderr.Reset() + + err := suite.runCommand(step.args...) + suite.NoError(err, "Step %s failed: %v", step.name, err) + + output := suite.stdout.String() + if step.verify != nil { + step.verify(output) + } + }) + } +} + +func (suite *CLITestSuite) TestRemoveUnmanagedFile() { + // Initialize repository + _ = suite.runCommand("init") + + // Create a regular file (not managed by lnk) + testFile := filepath.Join(suite.tempDir, ".regularfile") + _ = os.WriteFile(testFile, []byte("content"), 0644) + + // Try to remove it + err := suite.runCommand("rm", testFile) + suite.Error(err) + suite.Contains(err.Error(), "File is not managed by lnk") +} + +func (suite *CLITestSuite) TestAddDirectory() { + // Initialize repository + _ = suite.runCommand("init") + suite.stdout.Reset() + + // Create a directory with files + testDir := filepath.Join(suite.tempDir, ".config") + _ = os.MkdirAll(testDir, 0755) + configFile := filepath.Join(testDir, "app.conf") + _ = os.WriteFile(configFile, []byte("setting=value"), 0644) + + // Add the directory + err := suite.runCommand("add", testDir) + suite.NoError(err) + + // Check output + output := suite.stdout.String() + suite.Contains(output, "Added .config to lnk") + + // Verify directory is now a symlink + info, err := os.Lstat(testDir) + suite.NoError(err) + suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) + + // Verify directory exists in repo + repoDir := filepath.Join(suite.tempDir, "lnk", ".config") + suite.DirExists(repoDir) +} + +func TestCLISuite(t *testing.T) { + suite.Run(t, new(CLITestSuite)) +} diff --git a/test/core_test.go b/test/core_test.go new file mode 100644 index 0000000..1300140 --- /dev/null +++ b/test/core_test.go @@ -0,0 +1,316 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/yarlson/lnk/internal/core" +) + +type CoreTestSuite struct { + suite.Suite + tempDir string + originalDir string + lnk *core.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 = core.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) + + repoFile := filepath.Join(suite.tempDir, "lnk", ".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) + + // Verify directory exists in repo + repoDir := filepath.Join(suite.tempDir, "lnk", "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) + suite.Contains(lines, ".bashrc") + suite.Contains(lines, ".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) + suite.Contains(lines, ".ssh") + suite.NotContains(lines, ".bashrc") +} + +// 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 := core.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) +} + +func TestCoreSuite(t *testing.T) { + suite.Run(t, new(CoreTestSuite)) +} diff --git a/test/integration_test.go b/test/integration_test.go deleted file mode 100644 index b59bdff..0000000 --- a/test/integration_test.go +++ /dev/null @@ -1,718 +0,0 @@ -package test - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/suite" - "github.com/yarlson/lnk/internal/core" -) - -type LnkIntegrationTestSuite struct { - suite.Suite - tempDir string - originalDir string - lnk *core.Lnk -} - -func (suite *LnkIntegrationTestSuite) 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 = core.NewLnk() -} - -func (suite *LnkIntegrationTestSuite) 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) -} - -func (suite *LnkIntegrationTestSuite) TestInit() { - // Test that init creates the directory and Git repo - 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) - - // Verify it's a non-bare repo - configPath := filepath.Join(gitDir, "config") - suite.FileExists(configPath) - - // Verify the default branch is set to 'main' - cmd := exec.Command("git", "symbolic-ref", "HEAD") - cmd.Dir = lnkDir - output, err := cmd.Output() - suite.Require().NoError(err) - suite.Equal("refs/heads/main", strings.TrimSpace(string(output))) -} - -func (suite *LnkIntegrationTestSuite) TestAddFile() { - // 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) - - // Check that the original file is now a symlink - info, err := os.Lstat(testFile) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) - - // Check that the file exists in the repo - repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc") - suite.FileExists(repoFile) - - // Check that the content is preserved - repoContent, err := os.ReadFile(repoFile) - suite.Require().NoError(err) - suite.Equal(content, string(repoContent)) - - // Check that symlink points to the correct location - linkTarget, err := os.Readlink(testFile) - suite.Require().NoError(err) - expectedTarget, err := filepath.Rel(filepath.Dir(testFile), repoFile) - suite.Require().NoError(err) - suite.Equal(expectedTarget, linkTarget) - - // 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") -} - -func (suite *LnkIntegrationTestSuite) TestRemoveFile() { - // Initialize and add a file first - err := suite.lnk.Init() - suite.Require().NoError(err) - - testFile := filepath.Join(suite.tempDir, ".vimrc") - content := "set number" - err = os.WriteFile(testFile, []byte(content), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Add(testFile) - suite.Require().NoError(err) - - // Now remove the file - err = suite.lnk.Remove(testFile) - suite.Require().NoError(err) - - // Check that the 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 - - // Check that content is preserved - restoredContent, err := os.ReadFile(testFile) - suite.Require().NoError(err) - suite.Equal(content, string(restoredContent)) - - // Check that file is removed from repo - repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc") - suite.NoFileExists(repoFile) - - // Check that Git commit was made - commits, err := suite.lnk.GetCommits() - suite.Require().NoError(err) - suite.Len(commits, 2) // add + remove - suite.Contains(commits[0], "lnk: removed .vimrc") - suite.Contains(commits[1], "lnk: added .vimrc") -} - -func (suite *LnkIntegrationTestSuite) TestAddNonexistentFile() { - 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") -} - -func (suite *LnkIntegrationTestSuite) TestAddDirectory() { - 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) - - // Add files to the directory - testFile1 := filepath.Join(testDir, "file1.txt") - err = os.WriteFile(testFile1, []byte("content1"), 0644) - suite.Require().NoError(err) - - testFile2 := filepath.Join(testDir, "file2.txt") - err = os.WriteFile(testFile2, []byte("content2"), 0644) - suite.Require().NoError(err) - - // Add the directory - should now succeed - err = suite.lnk.Add(testDir) - suite.Require().NoError(err) - - // Check that the 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 directory exists in the repo - repoDir := filepath.Join(suite.tempDir, "lnk", "testdir") - suite.DirExists(repoDir) - - // Check that files are preserved - repoFile1 := filepath.Join(repoDir, "file1.txt") - repoFile2 := filepath.Join(repoDir, "file2.txt") - suite.FileExists(repoFile1) - suite.FileExists(repoFile2) - - content1, err := os.ReadFile(repoFile1) - suite.Require().NoError(err) - suite.Equal("content1", string(content1)) - - content2, err := os.ReadFile(repoFile2) - suite.Require().NoError(err) - suite.Equal("content2", string(content2)) - - // Check that .lnk file was created and contains the directory - lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") - suite.FileExists(lnkFile) - - lnkContent, err := os.ReadFile(lnkFile) - suite.Require().NoError(err) - suite.Contains(string(lnkContent), "testdir") - - // 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 testdir") -} - -func (suite *LnkIntegrationTestSuite) TestRemoveDirectory() { - // Initialize and add a directory first - err := suite.lnk.Init() - suite.Require().NoError(err) - - 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) - - err = suite.lnk.Add(testDir) - suite.Require().NoError(err) - - // Now remove the directory - err = suite.lnk.Remove(testDir) - suite.Require().NoError(err) - - // Check that the 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 - - // Check that content is preserved - restoredContent, err := os.ReadFile(testFile) - suite.Require().NoError(err) - suite.Equal(content, string(restoredContent)) - - // Check that directory is removed from repo - repoDir := filepath.Join(suite.tempDir, "lnk", "testdir") - suite.NoDirExists(repoDir) - - // Check that .lnk file no longer contains the directory - lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") - if suite.FileExists(lnkFile) { - lnkContent, err := os.ReadFile(lnkFile) - suite.Require().NoError(err) - suite.NotContains(string(lnkContent), "testdir") - } - - // Check that Git commit was made - commits, err := suite.lnk.GetCommits() - suite.Require().NoError(err) - suite.Len(commits, 2) // add + remove - suite.Contains(commits[0], "lnk: removed testdir") - suite.Contains(commits[1], "lnk: added testdir") -} - -func (suite *LnkIntegrationTestSuite) TestLnkFileTracking() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add a file - 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) - - // Add a directory - 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) - suite.Contains(lines, ".bashrc") - suite.Contains(lines, ".ssh") - - // Remove a file and check .lnk 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) - suite.Contains(lines, ".ssh") - suite.NotContains(lines, ".bashrc") -} - -func (suite *LnkIntegrationTestSuite) TestPullWithDirectories() { - // Initialize repo - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add remote for pull to work - err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") - suite.Require().NoError(err) - - // Create a directory and .lnk file in the repo directly to simulate a pull - repoDir := filepath.Join(suite.tempDir, "lnk", ".config") - err = os.MkdirAll(repoDir, 0755) - suite.Require().NoError(err) - - configFile := filepath.Join(repoDir, "app.conf") - content := "setting=value" - err = os.WriteFile(configFile, []byte(content), 0644) - suite.Require().NoError(err) - - // Create .lnk file - lnkFile := filepath.Join(suite.tempDir, "lnk", ".lnk") - err = os.WriteFile(lnkFile, []byte(".config\n"), 0644) - suite.Require().NoError(err) - - // Get home directory for the test - homeDir, err := os.UserHomeDir() - suite.Require().NoError(err) - - targetDir := filepath.Join(homeDir, ".config") - - // Clean up the test directory after the test - defer func() { - _ = os.RemoveAll(targetDir) - }() - - // Create a regular directory in home to simulate conflict scenario - err = os.MkdirAll(targetDir, 0755) - suite.Require().NoError(err) - err = os.WriteFile(filepath.Join(targetDir, "different.conf"), []byte("different"), 0644) - suite.Require().NoError(err) - - // Pull should restore symlinks and handle conflicts - restored, err := suite.lnk.Pull() - // In tests, pull will fail because we don't have real remotes, but that's expected - // We can still test the symlink restoration part - if err != nil { - suite.Contains(err.Error(), "git pull failed") - // Test symlink restoration directly - restored, err = suite.lnk.RestoreSymlinks() - suite.Require().NoError(err) - } - - // Should have restored the symlink - suite.GreaterOrEqual(len(restored), 1) - if len(restored) > 0 { - suite.Equal(".config", restored[0]) - } - - // Check that directory is back to being a symlink - info, err := os.Lstat(targetDir) - suite.Require().NoError(err) - suite.Equal(os.ModeSymlink, info.Mode()&os.ModeSymlink) - - // Check content is preserved from repo - repoContent, err := os.ReadFile(configFile) - suite.Require().NoError(err) - suite.Equal(content, string(repoContent)) -} - -func (suite *LnkIntegrationTestSuite) TestRemoveNonSymlink() { - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Create a regular file (not managed by lnk) - 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") -} - -func (suite *LnkIntegrationTestSuite) 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 := core.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) -} - -func (suite *LnkIntegrationTestSuite) TestInitWithRemote() { - // Test that init with remote adds the origin remote - err := suite.lnk.Init() - suite.Require().NoError(err) - - remoteURL := "https://github.com/user/dotfiles.git" - err = suite.lnk.AddRemote("origin", remoteURL) - suite.Require().NoError(err) - - // Verify the remote was added by checking git config - lnkDir := filepath.Join(suite.tempDir, "lnk") - cmd := exec.Command("git", "remote", "get-url", "origin") - cmd.Dir = lnkDir - - output, err := cmd.Output() - suite.Require().NoError(err) - suite.Equal(remoteURL, strings.TrimSpace(string(output))) -} - -func (suite *LnkIntegrationTestSuite) TestInitIdempotent() { - // Test that running init multiple times is safe - err := suite.lnk.Init() - suite.Require().NoError(err) - - lnkDir := filepath.Join(suite.tempDir, "lnk") - - // Add a file to the repo to ensure it's not lost - testFile := filepath.Join(lnkDir, "test.txt") - err = os.WriteFile(testFile, []byte("test content"), 0644) - suite.Require().NoError(err) - - // Run init again - should be idempotent - err = suite.lnk.Init() - suite.Require().NoError(err) - - // File should still exist - suite.FileExists(testFile) - content, err := os.ReadFile(testFile) - suite.Require().NoError(err) - suite.Equal("test content", string(content)) -} - -func (suite *LnkIntegrationTestSuite) TestInitWithExistingRemote() { - // Test init with remote when remote already exists (same URL) - remoteURL := "https://github.com/user/dotfiles.git" - - // First init with remote - err := suite.lnk.Init() - suite.Require().NoError(err) - err = suite.lnk.AddRemote("origin", remoteURL) - suite.Require().NoError(err) - - // Init again with same remote should be idempotent - err = suite.lnk.Init() - suite.Require().NoError(err) - err = suite.lnk.AddRemote("origin", remoteURL) - suite.Require().NoError(err) - - // Verify remote is still correct - lnkDir := filepath.Join(suite.tempDir, "lnk") - cmd := exec.Command("git", "remote", "get-url", "origin") - cmd.Dir = lnkDir - output, err := cmd.Output() - suite.Require().NoError(err) - suite.Equal(remoteURL, strings.TrimSpace(string(output))) -} - -func (suite *LnkIntegrationTestSuite) TestInitWithDifferentRemote() { - // Test init with different remote when remote already exists - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add first remote - err = suite.lnk.AddRemote("origin", "https://github.com/user/dotfiles.git") - suite.Require().NoError(err) - - // Try to add different remote - should error - err = suite.lnk.AddRemote("origin", "https://github.com/user/other-repo.git") - suite.Error(err) - suite.Contains(err.Error(), "already exists with different URL") -} - -func (suite *LnkIntegrationTestSuite) TestInitWithNonLnkRepo() { - // Test init when directory contains a non-lnk Git repository - lnkDir := filepath.Join(suite.tempDir, "lnk") - err := os.MkdirAll(lnkDir, 0755) - suite.Require().NoError(err) - - // Create a non-lnk git repo in the lnk directory - cmd := exec.Command("git", "init") - cmd.Dir = lnkDir - err = cmd.Run() - suite.Require().NoError(err) - - // Add some content to make it look like a real repo - testFile := filepath.Join(lnkDir, "important-file.txt") - err = os.WriteFile(testFile, []byte("important data"), 0644) - suite.Require().NoError(err) - - // Configure git and commit - cmd = exec.Command("git", "config", "user.name", "Test User") - cmd.Dir = lnkDir - err = cmd.Run() - suite.Require().NoError(err) - - cmd = exec.Command("git", "config", "user.email", "test@example.com") - cmd.Dir = lnkDir - err = cmd.Run() - suite.Require().NoError(err) - - cmd = exec.Command("git", "add", "important-file.txt") - cmd.Dir = lnkDir - err = cmd.Run() - suite.Require().NoError(err) - - cmd = exec.Command("git", "commit", "-m", "important commit") - cmd.Dir = lnkDir - err = cmd.Run() - suite.Require().NoError(err) - - // Now try to init lnk - should error to protect existing repo - err = suite.lnk.Init() - suite.Error(err) - suite.Contains(err.Error(), "contains an existing Git repository") - - // Verify the original file is still there - suite.FileExists(testFile) -} - -// TestSyncStatus tests the status command functionality -func (suite *LnkIntegrationTestSuite) TestSyncStatus() { - // Initialize repo with remote - err := suite.lnk.Init() - suite.Require().NoError(err) - - err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") - suite.Require().NoError(err) - - // Add a file to create some local changes - 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) - - // Get status - should show 1 commit ahead - status, err := suite.lnk.Status() - suite.Require().NoError(err) - suite.Equal(1, status.Ahead) - suite.Equal(0, status.Behind) - suite.Equal("origin/main", status.Remote) -} - -// TestSyncPush tests the push command functionality -func (suite *LnkIntegrationTestSuite) TestSyncPush() { - // Initialize repo - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add remote for push to work - err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") - suite.Require().NoError(err) - - // Add a file - testFile := filepath.Join(suite.tempDir, ".vimrc") - content := "set number" - err = os.WriteFile(testFile, []byte(content), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Add(testFile) - suite.Require().NoError(err) - - // Add another file for a second commit - testFile2 := filepath.Join(suite.tempDir, ".gitconfig") - content2 := "[user]\n name = Test User" - err = os.WriteFile(testFile2, []byte(content2), 0644) - suite.Require().NoError(err) - - err = suite.lnk.Add(testFile2) - suite.Require().NoError(err) - - // Modify one of the files to create uncommitted changes - repoFile := filepath.Join(suite.tempDir, "lnk", ".vimrc") - modifiedContent := "set number\nset relativenumber" - err = os.WriteFile(repoFile, []byte(modifiedContent), 0644) - suite.Require().NoError(err) - - // Push should stage all changes and create a sync commit - message := "Updated configuration files" - err = suite.lnk.Push(message) - // In tests, push will fail because we don't have real remotes, but that's expected - // The important part is that it stages and commits changes - if err != nil { - suite.Contains(err.Error(), "git push failed") - } - - // Check that a sync commit was made (even if push failed) - commits, err := suite.lnk.GetCommits() - suite.Require().NoError(err) - suite.GreaterOrEqual(len(commits), 3) // at least 2 add commits + 1 sync commit - suite.Contains(commits[0], message) // Latest commit should contain our message -} - -// TestSyncPull tests the pull command functionality -func (suite *LnkIntegrationTestSuite) TestSyncPull() { - // Initialize repo - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add remote for pull to work - err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") - suite.Require().NoError(err) - - // Pull should attempt to pull from remote (will fail in tests but that's expected) - _, err = suite.lnk.Pull() - // In tests, pull will fail because we don't have real remotes, but that's expected - suite.Error(err) - suite.Contains(err.Error(), "git pull failed") - - // Test RestoreSymlinks functionality separately - // Create a file in the repo directly - 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) - - // Test that RestoreSymlinks can be called (even if it doesn't restore anything in this test setup) - restored, err := suite.lnk.RestoreSymlinks() - suite.Require().NoError(err) - // In this test setup, it might not restore anything, and that's okay for Phase 1 - suite.GreaterOrEqual(len(restored), 0) -} - -// TestSyncStatusNoRemote tests status when no remote is configured -func (suite *LnkIntegrationTestSuite) TestSyncStatusNoRemote() { - // Initialize repo without remote - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Status should indicate no remote - _, err = suite.lnk.Status() - suite.Error(err) - suite.Contains(err.Error(), "no remote configured") -} - -// TestSyncPushWithModifiedFiles tests push when files are modified -func (suite *LnkIntegrationTestSuite) TestSyncPushWithModifiedFiles() { - // Initialize repo and add a file - err := suite.lnk.Init() - suite.Require().NoError(err) - - // Add remote for push to work - err = suite.lnk.AddRemote("origin", "https://github.com/test/dotfiles.git") - suite.Require().NoError(err) - - 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) - - // Modify the file in the repo (simulate editing managed file) - repoFile := filepath.Join(suite.tempDir, "lnk", ".bashrc") - modifiedContent := "export PATH=$PATH:/usr/local/bin\nexport EDITOR=vim" - err = os.WriteFile(repoFile, []byte(modifiedContent), 0644) - suite.Require().NoError(err) - - // Push should detect and commit the changes - message := "Updated bashrc with editor setting" - err = suite.lnk.Push(message) - // In tests, push will fail because we don't have real remotes, but that's expected - if err != nil { - suite.Contains(err.Error(), "git push failed") - } - - // Check that changes were committed (even if push failed) - commits, err := suite.lnk.GetCommits() - suite.Require().NoError(err) - suite.GreaterOrEqual(len(commits), 2) // add commit + sync commit - suite.Contains(commits[0], message) -} - -func TestLnkIntegrationSuite(t *testing.T) { - suite.Run(t, new(LnkIntegrationTestSuite)) -}