diff --git a/main.go b/main.go index 7aff4a8..c439d31 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "strings" ) -const version = "0.1.1" +const version = "0.1.2" func main() { if len(os.Args) < 2 { diff --git a/ops_test.go b/ops_test.go new file mode 100644 index 0000000..0112dd7 --- /dev/null +++ b/ops_test.go @@ -0,0 +1,306 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +func TestLinkFile(t *testing.T) { + tmp := t.TempDir() + + src := filepath.Join(tmp, "source.txt") + dst := filepath.Join(tmp, "link.txt") + + // Create source file + if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil { + t.Fatalf("failed to create source: %v", err) + } + + // Test: create symlink + if err := linkFile(src, dst); err != nil { + t.Errorf("linkFile failed: %v", err) + } + + // Verify symlink + info, err := os.Lstat(dst) + if err != nil { + t.Fatalf("failed to stat link: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Error("dst is not a symlink") + } + + // Test: idempotent - same symlink again should succeed + if err := linkFile(src, dst); err != nil { + t.Errorf("linkFile second time failed: %v", err) + } + + // Test: conflict - different source should error + src2 := filepath.Join(tmp, "source2.txt") + if err := os.WriteFile(src2, []byte("world"), 0o644); err != nil { + t.Fatalf("failed to create source2: %v", err) + } + + if err := linkFile(src2, dst); err == nil { + t.Error("linkFile should fail with conflicting symlink") + } + + // Test: conflict - non-symlink file should error + dst2 := filepath.Join(tmp, "regular.txt") + if err := os.WriteFile(dst2, []byte("content"), 0o644); err != nil { + t.Fatalf("failed to create regular file: %v", err) + } + + if err := linkFile(src, dst2); err == nil { + t.Error("linkFile should fail when target exists and is not a symlink") + } +} + +func TestFindStaleLinks(t *testing.T) { + tmp := t.TempDir() + filesDir := filepath.Join(tmp, "files") + targetDir := filepath.Join(tmp, "target") + + if err := os.MkdirAll(filesDir, 0o755); err != nil { + t.Fatalf("failed to create files dir: %v", err) + } + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + + // Create a file in files/ + src := filepath.Join(filesDir, "test.txt") + if err := os.WriteFile(src, []byte("content"), 0o644); err != nil { + t.Fatalf("failed to create source: %v", err) + } + + // Create a subdirectory with a file + subDir := filepath.Join(filesDir, "subdir") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("failed to create subdir: %v", err) + } + src2 := filepath.Join(subDir, "nested.txt") + if err := os.WriteFile(src2, []byte("nested"), 0o644); err != nil { + t.Fatalf("failed to create nested source: %v", err) + } + + // Create symlinks + dst := filepath.Join(targetDir, "test.txt") + if err := os.Symlink(src, dst); err != nil { + t.Fatalf("failed to create symlink: %v", err) + } + + targetSubDir := filepath.Join(targetDir, "subdir") + if err := os.MkdirAll(targetSubDir, 0o755); err != nil { + t.Fatalf("failed to create target subdir: %v", err) + } + dst2 := filepath.Join(targetSubDir, "nested.txt") + if err := os.Symlink(src2, dst2); err != nil { + t.Fatalf("failed to create nested symlink: %v", err) + } + + // Test: no stale links when sources exist + stale, err := findStaleLinks(filesDir, targetDir) + if err != nil { + t.Errorf("findStaleLinks failed: %v", err) + } + if len(stale) != 0 { + t.Errorf("expected 0 stale links, got %d", len(stale)) + } + + // Note: The current implementation walks filesDir, so it can't detect + // stale links for files that have been deleted from the repo. + // This is a known limitation - we'd need to walk the target directory + // to find orphaned symlinks, but that has performance issues with + // large target directories (like ~). +} + +func TestFindStaleLinks_IgnoresNonRepoSymlinks(t *testing.T) { + tmp := t.TempDir() + filesDir := filepath.Join(tmp, "files") + targetDir := filepath.Join(tmp, "target") + + if err := os.MkdirAll(filesDir, 0o755); err != nil { + t.Fatalf("failed to create files dir: %v", err) + } + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + + // Create a file outside the repo + external := filepath.Join(tmp, "external.txt") + if err := os.WriteFile(external, []byte("external"), 0o644); err != nil { + t.Fatalf("failed to create external file: %v", err) + } + + // Create a symlink to external file (should be ignored by findStaleLinks) + dst := filepath.Join(targetDir, "link.txt") + if err := os.Symlink(external, dst); err != nil { + t.Fatalf("failed to create symlink: %v", err) + } + + // Test: should not report external symlinks as stale + stale, err := findStaleLinks(filesDir, targetDir) + if err != nil { + t.Errorf("findStaleLinks failed: %v", err) + } + if len(stale) != 0 { + t.Errorf("expected 0 stale links (external symlinks ignored), got %d", len(stale)) + } +} + +func TestApplyPackage(t *testing.T) { + tmp := t.TempDir() + filesDir := filepath.Join(tmp, "files") + targetDir := filepath.Join(tmp, "target") + + // Create files structure + subDir := filepath.Join(filesDir, "config") + if err := os.MkdirAll(subDir, 0o755); err != nil { + t.Fatalf("failed to create subdir: %v", err) + } + + file1 := filepath.Join(filesDir, "readme.txt") + if err := os.WriteFile(file1, []byte("readme"), 0o644); err != nil { + t.Fatalf("failed to create file1: %v", err) + } + + file2 := filepath.Join(subDir, "app.conf") + if err := os.WriteFile(file2, []byte("config"), 0o644); err != nil { + t.Fatalf("failed to create file2: %v", err) + } + + cfg := &packageConfig{} // empty config, no ignores + + // Apply package + if err := applyPackage(filesDir, targetDir, cfg); err != nil { + t.Errorf("applyPackage failed: %v", err) + } + + // Verify symlinks exist + targetFile1 := filepath.Join(targetDir, "readme.txt") + info, err := os.Lstat(targetFile1) + if err != nil { + t.Errorf("target file1 not found: %v", err) + } else if info.Mode()&os.ModeSymlink == 0 { + t.Error("target file1 is not a symlink") + } + + targetFile2 := filepath.Join(targetDir, "config", "app.conf") + info, err = os.Lstat(targetFile2) + if err != nil { + t.Errorf("target file2 not found: %v", err) + } else if info.Mode()&os.ModeSymlink == 0 { + t.Error("target file2 is not a symlink") + } + + // Verify content is accessible through symlink + content, err := os.ReadFile(targetFile1) + if err != nil { + t.Errorf("failed to read through symlink: %v", err) + } + if string(content) != "readme" { + t.Errorf("wrong content through symlink: %s", string(content)) + } +} + +func TestApplyPackage_WithIgnores(t *testing.T) { + tmp := t.TempDir() + filesDir := filepath.Join(tmp, "files") + targetDir := filepath.Join(tmp, "target") + + if err := os.MkdirAll(filesDir, 0o755); err != nil { + t.Fatalf("failed to create files dir: %v", err) + } + + // Create files, some to be ignored + mainFile := filepath.Join(filesDir, "main.txt") + if err := os.WriteFile(mainFile, []byte("main"), 0o644); err != nil { + t.Fatalf("failed to create main file: %v", err) + } + + cacheDir := filepath.Join(filesDir, "cache") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatalf("failed to create cache dir: %v", err) + } + cacheFile := filepath.Join(cacheDir, "data.tmp") + if err := os.WriteFile(cacheFile, []byte("temp"), 0o644); err != nil { + t.Fatalf("failed to create cache file: %v", err) + } + + // Create config with ignore pattern + cfg := &packageConfig{ + compiledIgnores: []*regexp.Regexp{}, + } + re, _ := globToRegexp("cache/**") + cfg.compiledIgnores = append(cfg.compiledIgnores, re) + + // Apply package + if err := applyPackage(filesDir, targetDir, cfg); err != nil { + t.Errorf("applyPackage failed: %v", err) + } + + // Verify main file is linked + targetMain := filepath.Join(targetDir, "main.txt") + if _, err := os.Lstat(targetMain); err != nil { + t.Errorf("main file should be linked: %v", err) + } + + // Verify cache file is not linked (the directory may exist since we walk it + // before checking ignore patterns, but it should be empty) + targetCacheFile := filepath.Join(targetDir, "cache", "data.tmp") + if _, err := os.Lstat(targetCacheFile); !os.IsNotExist(err) { + t.Error("cache/data.tmp should not exist in target") + } +} + +func TestRestoreOne(t *testing.T) { + tmp := t.TempDir() + filesDir := filepath.Join(tmp, "files") + targetDir := filepath.Join(tmp, "target") + + if err := os.MkdirAll(filesDir, 0o755); err != nil { + t.Fatalf("failed to create files dir: %v", err) + } + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("failed to create target dir: %v", err) + } + + // Create source file in repo + src := filepath.Join(filesDir, "test.txt") + if err := os.WriteFile(src, []byte("original"), 0o644); err != nil { + t.Fatalf("failed to create source: %v", err) + } + + // Create symlink in target + dst := filepath.Join(targetDir, "test.txt") + if err := os.Symlink(src, dst); err != nil { + t.Fatalf("failed to create symlink: %v", err) + } + + // Restore (removes symlink, copies file) + filesAbs, _ := filepath.Abs(filesDir) + if err := restoreOne(src, dst, filesAbs, false); err != nil { + t.Errorf("restoreOne failed: %v", err) + } + + // Verify symlink is gone + info, err := os.Lstat(dst) + if err != nil { + t.Fatalf("failed to stat restored file: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 { + t.Error("file should not be a symlink after restore") + } + + // Verify content is preserved + content, err := os.ReadFile(dst) + if err != nil { + t.Errorf("failed to read restored file: %v", err) + } + if string(content) != "original" { + t.Errorf("wrong content after restore: %s", string(content)) + } +}