diff --git a/cmd/add.go b/cmd/add.go index 44824f1..e816243 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -24,9 +24,9 @@ func newAddCmd() *cobra.Command { } 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") + 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, basename) + 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 6eb3790..08d9d6f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -22,18 +22,18 @@ func newInitCmd() *cobra.Command { } 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") + printf(cmd, "šŸŽÆ \033[1mInitialized lnk repository\033[0m\n") + printf(cmd, " šŸ“¦ Cloned from: \033[36m%s\033[0m\n", remote) + printf(cmd, " šŸ“ Location: \033[90m~/.config/lnk\033[0m\n") + printf(cmd, "\nšŸ’” \033[33mNext steps:\033[0m\n") + printf(cmd, " • Run \033[1mlnk pull\033[0m to restore symlinks\n") + printf(cmd, " • 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") + printf(cmd, "šŸŽÆ \033[1mInitialized empty lnk repository\033[0m\n") + printf(cmd, " šŸ“ Location: \033[90m~/.config/lnk\033[0m\n") + printf(cmd, "\nšŸ’” \033[33mNext steps:\033[0m\n") + printf(cmd, " • Run \033[1mlnk add \033[0m to start managing dotfiles\n") + printf(cmd, " • Add a remote with: \033[1mgit remote add origin \033[0m\n") } return nil diff --git a/cmd/pull.go b/cmd/pull.go index b21f065..dcb888b 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -21,20 +21,20 @@ func newPullCmd() *cobra.Command { } 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)) + printf(cmd, "ā¬‡ļø \033[1;32mSuccessfully pulled changes\033[0m\n") + printf(cmd, " šŸ”— Restored \033[1m%d symlink", len(restored)) if len(restored) > 1 { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "s") + printf(cmd, "s") } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\033[0m:\n") + printf(cmd, "\033[0m:\n") for _, file := range restored { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " ✨ \033[36m%s\033[0m\n", file) + printf(cmd, " ✨ \033[36m%s\033[0m\n", file) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n šŸŽ‰ Your dotfiles are synced and ready!\n") + printf(cmd, "\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") + printf(cmd, "ā¬‡ļø \033[1;32mSuccessfully pulled changes\033[0m\n") + printf(cmd, " āœ… All symlinks already in place\n") + printf(cmd, " šŸŽ‰ Everything is up to date!\n") } return nil diff --git a/cmd/push.go b/cmd/push.go index 240acb6..eb992be 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -25,10 +25,10 @@ func newPushCmd() *cobra.Command { return fmt.Errorf("failed to push changes: %w", err) } - _, _ = 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") + printf(cmd, "šŸš€ \033[1;32mSuccessfully pushed changes\033[0m\n") + printf(cmd, " šŸ’¾ Commit: \033[90m%s\033[0m\n", message) + printf(cmd, " šŸ“” Synced to remote\n") + printf(cmd, " ✨ Your dotfiles are up to date!\n") return nil }, } diff --git a/cmd/rm.go b/cmd/rm.go index df12eae..8642a62 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -24,9 +24,9 @@ func newRemoveCmd() *cobra.Command { } 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") + printf(cmd, "šŸ—‘ļø \033[1mRemoved %s from lnk\033[0m\n", basename) + printf(cmd, " ā†©ļø \033[90m~/.config/lnk/%s\033[0m → \033[36m%s\033[0m\n", basename, filePath) + printf(cmd, " šŸ“„ Original file restored\n") return nil }, } diff --git a/cmd/status.go b/cmd/status.go index 340155a..b35249f 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -21,32 +21,32 @@ func newStatusCmd() *cobra.Command { } 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) + printf(cmd, "āœ… \033[1;32mRepository is up to date\033[0m\n") + printf(cmd, " šŸ“” 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") + printf(cmd, "šŸ“Š \033[1mRepository Status\033[0m\n") + printf(cmd, " šŸ“” Remote: \033[36m%s\033[0m\n", status.Remote) + printf(cmd, "\n") 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) + printf(cmd, " ā¬†ļø \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" } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " ā¬‡ļø \033[1;31m%d %s behind\033[0m - run \033[1mlnk pull\033[0m\n", status.Behind, commitText) + printf(cmd, " ā¬‡ļø \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") + printf(cmd, "\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") + printf(cmd, "\nšŸ’” Run \033[1mlnk pull\033[0m to get latest changes") } } diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 0000000..e4ed7d8 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,12 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// 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...) +} diff --git a/test/integration_test.go b/test/integration_test.go new file mode 100644 index 0000000..df2186f --- /dev/null +++ b/test/integration_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 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) +} + +// Test core initialization functionality +func (suite *LnkIntegrationTestSuite) 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 *LnkIntegrationTestSuite) 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 *LnkIntegrationTestSuite) 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 *LnkIntegrationTestSuite) 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 *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) +} + +// Test symlink restoration (pull functionality) +func (suite *LnkIntegrationTestSuite) 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 *LnkIntegrationTestSuite) 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 *LnkIntegrationTestSuite) 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 TestLnkIntegrationSuite(t *testing.T) { + suite.Run(t, new(LnkIntegrationTestSuite)) +}