package core import ( "bytes" "errors" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) type errReader struct{} func (errReader) Read(_ []byte) (int, error) { return 0, errors.New("boom") } type errWriter struct{} func (errWriter) Write(_ []byte) (int, error) { return 0, errors.New("boom") } func TestRunGitPretendWritesStatus(t *testing.T) { stdout := &bytes.Buffer{} app := &App{Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true} err := app.runGit("/tmp", "status") require.NoError(t, err) require.Contains(t, stdout.String(), "Would execute git status in /tmp") } func TestActionVerb(t *testing.T) { app := &App{Pretend: true} require.Equal(t, "Would execute", app.actionVerb()) app.Pretend = false require.Equal(t, "Executing", app.actionVerb()) } func TestSayStatusHonorsQuiet(t *testing.T) { stdout := &bytes.Buffer{} app := &App{Stdout: stdout, Quiet: true} app.sayStatus("git", "status") require.Empty(t, stdout.String()) app.Quiet = false app.sayStatus("git", "status") require.Contains(t, stdout.String(), "git: status") } func TestUnlinkPath(t *testing.T) { t.Run("missing destination", func(t *testing.T) { err := unlinkPath(filepath.Join(t.TempDir(), "does-not-exist")) require.NoError(t, err) }) t.Run("regular file is preserved", func(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "regular") require.NoError(t, os.WriteFile(target, []byte("x"), 0o644)) err := unlinkPath(target) require.NoError(t, err) require.FileExists(t, target) }) t.Run("symlink is removed", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "dest") require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) require.NoError(t, os.Symlink(source, destination)) err := unlinkPath(destination) require.NoError(t, err) _, statErr := os.Lstat(destination) require.ErrorIs(t, statErr, os.ErrNotExist) }) } func TestLinkPath(t *testing.T) { t.Run("existing symlink to same source is no-op", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "dest") require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) absSource, err := filepath.Abs(source) require.NoError(t, err) require.NoError(t, os.Symlink(absSource, destination)) app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} err = app.linkPath(source, destination) require.NoError(t, err) }) t.Run("conflict without force errors", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "dest") require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644)) app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} err := app.linkPath(source, destination) require.Error(t, err) require.Contains(t, err.Error(), "exists") }) t.Run("force replaces existing destination", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") destination := filepath.Join(dir, "dest") require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) require.NoError(t, os.WriteFile(destination, []byte("existing"), 0o644)) app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil), Force: true} err := app.linkPath(source, destination) require.NoError(t, err) info, statErr := os.Lstat(destination) require.NoError(t, statErr) require.True(t, info.Mode()&os.ModeSymlink != 0) }) t.Run("create destination parent error includes context", func(t *testing.T) { dir := t.TempDir() source := filepath.Join(dir, "source") blocker := filepath.Join(dir, "blocker") require.NoError(t, os.WriteFile(source, []byte("x"), 0o644)) require.NoError(t, os.WriteFile(blocker, []byte("x"), 0o644)) app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} err := app.linkPath(source, filepath.Join(blocker, "dest")) require.Error(t, err) require.Contains(t, err.Error(), "create destination parent") }) } func TestReadSubdirsAndMatchesIgnoredDir(t *testing.T) { dir := t.TempDir() meta := filepath.Join(dir, ".homesick_subdir") require.NoError(t, os.WriteFile(meta, []byte(" .config/myapp \n\n"), 0o644)) subdirs, err := readSubdirs(meta) require.NoError(t, err) require.Equal(t, []string{filepath.Clean(".config/myapp")}, subdirs) castleHome := filepath.Join(dir, "castle", "home") candidate := filepath.Join(castleHome, ".config") ignored, err := matchesIgnoredDir(castleHome, candidate, subdirs) require.NoError(t, err) require.True(t, ignored) notIgnored, err := matchesIgnoredDir(castleHome, filepath.Join(castleHome, ".vim"), subdirs) require.NoError(t, err) require.False(t, notIgnored) } func TestReadSubdirsReadErrorIncludesContext(t *testing.T) { _, err := readSubdirs(t.TempDir()) require.Error(t, err) require.Contains(t, err.Error(), "read subdirs") } func TestPullAndPushDefaultCastlePretend(t *testing.T) { dir := t.TempDir() stdout := &bytes.Buffer{} app := &App{ HomeDir: dir, ReposDir: filepath.Join(dir, ".homesick", "repos"), Stdout: stdout, Stderr: bytes.NewBuffer(nil), Pretend: true, } require.NoError(t, app.Pull("")) require.NoError(t, app.Push("")) out := stdout.String() require.Contains(t, out, "git pull") require.Contains(t, out, "git push") require.Contains(t, out, filepath.Join(app.ReposDir, "dotfiles")) } func TestGenerateRequiresPath(t *testing.T) { app := &App{Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} err := app.Generate(" ") require.Error(t, err) require.Contains(t, err.Error(), "generate requires PATH") } func TestLinkAndUnlinkDefaultCastle(t *testing.T) { dir := t.TempDir() homeDir := filepath.Join(dir, "home") reposDir := filepath.Join(homeDir, ".homesick", "repos") castleHome := filepath.Join(reposDir, "dotfiles", "home") require.NoError(t, os.MkdirAll(castleHome, 0o755)) source := filepath.Join(castleHome, ".vimrc") require.NoError(t, os.WriteFile(source, []byte("set number\n"), 0o644)) app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} require.NoError(t, app.Link("")) destination := filepath.Join(homeDir, ".vimrc") info, err := os.Lstat(destination) require.NoError(t, err) require.True(t, info.Mode()&os.ModeSymlink != 0) require.NoError(t, app.Unlink("")) _, err = os.Lstat(destination) require.ErrorIs(t, err, os.ErrNotExist) } func TestLinkAndUnlinkCastleMissingError(t *testing.T) { dir := t.TempDir() app := &App{ HomeDir: filepath.Join(dir, "home"), ReposDir: filepath.Join(dir, "home", ".homesick", "repos"), Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil), } err := app.LinkCastle("missing") require.Error(t, err) require.Contains(t, err.Error(), "could not symlink") err = app.UnlinkCastle("missing") require.Error(t, err) require.Contains(t, err.Error(), "could not symlink") } func TestConfirmDestroyResponses(t *testing.T) { stdout := &bytes.Buffer{} app := &App{Stdout: stdout, Stdin: strings.NewReader("yes\n")} ok, err := app.confirmDestroy("dotfiles") require.NoError(t, err) require.True(t, ok) require.Contains(t, stdout.String(), "Destroy castle \"dotfiles\"?") stdout.Reset() app.Stdin = strings.NewReader("n\n") ok, err = app.confirmDestroy("dotfiles") require.NoError(t, err) require.False(t, ok) } func TestConfirmDestroyReadError(t *testing.T) { app := &App{Stdout: bytes.NewBuffer(nil), Stdin: errReader{}} ok, err := app.confirmDestroy("dotfiles") require.Error(t, err) require.False(t, ok) require.Contains(t, err.Error(), "read destroy confirmation") } func TestConfirmDestroyWriteError(t *testing.T) { app := &App{Stdout: errWriter{}, Stdin: strings.NewReader("yes\n")} ok, err := app.confirmDestroy("dotfiles") require.Error(t, err) require.False(t, ok) require.Contains(t, err.Error(), "write destroy prompt") } func TestExecAllWrapsCastleError(t *testing.T) { dir := t.TempDir() homeDir := filepath.Join(dir, "home") reposDir := filepath.Join(homeDir, ".homesick", "repos") require.NoError(t, os.MkdirAll(filepath.Join(reposDir, "broken", ".git"), 0o755)) app := &App{HomeDir: homeDir, ReposDir: reposDir, Stdout: bytes.NewBuffer(nil), Stderr: bytes.NewBuffer(nil)} err := app.ExecAll([]string{"exit 3"}) require.Error(t, err) require.Contains(t, err.Error(), "exec_all failed for \"broken\"") }