// License: GPLv3 Copyright: 2025, Kovid Goyal, package dnd import ( "os" "path/filepath" "sort" "testing" ) // openDir opens a directory for use as an *os.File, closing it on test cleanup. func openDir(t *testing.T, path string) *os.File { t.Helper() d, err := os.Open(path) if err != nil { t.Fatalf("openDir %s: %v", path, err) } t.Cleanup(func() { d.Close() }) return d } // buildTree creates a directory tree described by a map of relative path -> // content. Each entry creates a regular file with the given content (parent // directories are created automatically). Symlink entries are expressed via // the separate symlinks map: relative link name -> target string. func buildTree(t *testing.T, base string, files map[string]string, symlinks map[string]string) { t.Helper() for rel, content := range files { full := filepath.Join(base, rel) if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte(content), 0644); err != nil { t.Fatal(err) } } for name, target := range symlinks { full := filepath.Join(base, name) if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { t.Fatal(err) } if err := os.Symlink(target, full); err != nil { t.Fatal(err) } } } // sortedStrings returns a sorted copy of the slice. func sortedStrings(s []string) []string { out := append([]string(nil), s...) sort.Strings(out) return out } // TestFindOverwrites_NoOverlap verifies that an empty result is returned when // source and destination have no names in common. func TestFindOverwrites_NoOverlap(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) buildTree(t, src, map[string]string{"a.txt": "a", "b.txt": "b"}, nil) buildTree(t, dst, map[string]string{"c.txt": "c"}, nil) srcDir := openDir(t, src) dstDir := openDir(t, dst) got, err := find_overwrites(srcDir, dstDir) if err != nil { t.Fatalf("find_overwrites: %v", err) } if len(got) != 0 { t.Errorf("expected no overwrites, got %v", got) } } // TestFindOverwrites_FileOverlap verifies that files existing in both trees // are reported. func TestFindOverwrites_FileOverlap(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) buildTree(t, src, map[string]string{"shared.txt": "src", "only_src.txt": "x"}, nil) buildTree(t, dst, map[string]string{"shared.txt": "dst", "only_dst.txt": "y"}, nil) srcDir := openDir(t, src) dstDir := openDir(t, dst) got, err := find_overwrites(srcDir, dstDir) if err != nil { t.Fatalf("find_overwrites: %v", err) } if len(got) != 1 || got[0] != "shared.txt" { t.Errorf("expected [shared.txt], got %v", got) } } // TestFindOverwrites_DirsNotReported verifies that matching *directories* in // both trees are not reported as overwrites — only non-directory conflicts are. func TestFindOverwrites_DirsNotReported(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) // Both have "subdir/" — it should NOT appear in the overwrite list. os.MkdirAll(filepath.Join(src, "subdir"), 0755) os.MkdirAll(filepath.Join(dst, "subdir"), 0755) buildTree(t, src, map[string]string{"subdir/file.txt": "s"}, nil) buildTree(t, dst, map[string]string{"subdir/file.txt": "d"}, nil) srcDir := openDir(t, src) dstDir := openDir(t, dst) got, err := find_overwrites(srcDir, dstDir) if err != nil { t.Fatalf("find_overwrites: %v", err) } // Only the nested file should be reported, not "subdir". if len(got) != 1 || got[0] != "subdir/file.txt" { t.Errorf("expected [subdir/file.txt], got %v", got) } } // TestFindOverwrites_NestedTree tests a multi-level tree with a mix of // regular files, nested directories, and symlinks. func TestFindOverwrites_NestedTree(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) // src layout: // top.txt (overwrite — exists in dst too) // only_src.txt // sub/ // nested.txt (overwrite) // deep/ // file.txt (overwrite) // link -> target (symlink overwrite) os.MkdirAll(filepath.Join(src, "sub", "deep"), 0755) buildTree(t, src, map[string]string{ "top.txt": "src", "only_src.txt": "x", "sub/nested.txt": "src", "sub/deep/file.txt": "src", }, map[string]string{"link": "target"}) // dst layout: // top.txt (shared file) // only_dst.txt // sub/ // nested.txt (shared file) // deep/ // file.txt (shared file) // link -> other (symlink — same name, different target) os.MkdirAll(filepath.Join(dst, "sub", "deep"), 0755) buildTree(t, dst, map[string]string{ "top.txt": "dst", "only_dst.txt": "y", "sub/nested.txt": "dst", "sub/deep/file.txt": "dst", }, map[string]string{"link": "other"}) srcDir := openDir(t, src) dstDir := openDir(t, dst) got, err := find_overwrites(srcDir, dstDir) if err != nil { t.Fatalf("find_overwrites: %v", err) } want := []string{"link", "sub/deep/file.txt", "sub/nested.txt", "top.txt"} if got2 := sortedStrings(got); !equalStringSlices(got2, want) { t.Errorf("find_overwrites: got %v, want %v", got2, want) } } // TestFindOverwrites_SymlinksTransferredAsIs verifies that a symlink in the // source with the same name as a symlink in the destination is reported as an // overwrite regardless of the symlink targets, and that symlinks are not // followed. func TestFindOverwrites_SymlinksTransferredAsIs(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) // Both have a symlink named "link" pointing to different targets. os.Symlink("target_a", filepath.Join(src, "link")) os.Symlink("target_b", filepath.Join(dst, "link")) srcDir := openDir(t, src) dstDir := openDir(t, dst) got, err := find_overwrites(srcDir, dstDir) if err != nil { t.Fatalf("find_overwrites: %v", err) } if len(got) != 1 || got[0] != "link" { t.Errorf("expected [link], got %v", got) } } // TestFindOverwrites_DirVsFile reports the case where src has a directory and // dst has a file with the same name (or vice versa). func TestFindOverwrites_DirVsFile(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) // src has a directory named "conflict"; dst has a regular file with the same name. os.MkdirAll(filepath.Join(src, "conflict"), 0755) os.WriteFile(filepath.Join(dst, "conflict"), []byte("file"), 0644) srcDir := openDir(t, src) dstDir := openDir(t, dst) got, err := find_overwrites(srcDir, dstDir) if err != nil { t.Fatalf("find_overwrites: %v", err) } // A directory in src vs a file in dst should be reported. if len(got) != 1 || got[0] != "conflict" { t.Errorf("expected [conflict], got %v", got) } } // --- rename_contents tests --- // TestRenameContents_SimpleFiles verifies that plain files are moved from src // to dst. func TestRenameContents_SimpleFiles(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) buildTree(t, src, map[string]string{"a.txt": "aaa", "b.txt": "bbb"}, nil) srcDir := openDir(t, src) dstDir := openDir(t, dst) if err := rename_contents(srcDir, dstDir); err != nil { t.Fatalf("rename_contents: %v", err) } for _, name := range []string{"a.txt", "b.txt"} { if _, err := os.Stat(filepath.Join(src, name)); !os.IsNotExist(err) { t.Errorf("%s should have been moved out of src", name) } if _, err := os.Stat(filepath.Join(dst, name)); err != nil { t.Errorf("%s should exist in dst: %v", name, err) } } } // TestRenameContents_MergesNestedDirs verifies that when both src and dst // already have a subdirectory with the same name, their contents are merged // recursively. func TestRenameContents_MergesNestedDirs(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(filepath.Join(src, "sub"), 0755) os.MkdirAll(filepath.Join(dst, "sub"), 0755) buildTree(t, src, map[string]string{ "sub/from_src.txt": "src", }, nil) buildTree(t, dst, map[string]string{ "sub/from_dst.txt": "dst", }, nil) srcDir := openDir(t, src) dstDir := openDir(t, dst) if err := rename_contents(srcDir, dstDir); err != nil { t.Fatalf("rename_contents: %v", err) } // Both files must now live under dst/sub/. for _, name := range []string{"from_src.txt", "from_dst.txt"} { if _, err := os.Stat(filepath.Join(dst, "sub", name)); err != nil { t.Errorf("dst/sub/%s missing: %v", name, err) } } } // TestRenameContents_NestedMultiLevel verifies correct merging across multiple // nesting levels. func TestRenameContents_NestedMultiLevel(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") // Build a 3-level nested source tree. os.MkdirAll(filepath.Join(src, "a", "b"), 0755) os.MkdirAll(filepath.Join(dst, "a", "b"), 0755) buildTree(t, src, map[string]string{ "top.txt": "top", "a/mid.txt": "mid", "a/b/deep.txt": "deep", }, nil) buildTree(t, dst, map[string]string{ "a/existing.txt": "existing", }, nil) srcDir := openDir(t, src) dstDir := openDir(t, dst) if err := rename_contents(srcDir, dstDir); err != nil { t.Fatalf("rename_contents: %v", err) } expected := []string{"top.txt", "a/mid.txt", "a/b/deep.txt", "a/existing.txt"} for _, rel := range expected { if _, err := os.Stat(filepath.Join(dst, rel)); err != nil { t.Errorf("dst/%s missing: %v", rel, err) } } } // TestRenameContents_SymlinksMovedAsIs verifies that symlinks are moved as-is // (not followed), preserving both the link and its original target string. func TestRenameContents_SymlinksMovedAsIs(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(src, 0755) os.MkdirAll(dst, 0755) // A symlink pointing to a non-existent target — if followed it would fail. os.Symlink("does_not_exist", filepath.Join(src, "link")) srcDir := openDir(t, src) dstDir := openDir(t, dst) if err := rename_contents(srcDir, dstDir); err != nil { t.Fatalf("rename_contents: %v", err) } // Symlink must have arrived in dst. target, err := os.Readlink(filepath.Join(dst, "link")) if err != nil { t.Fatalf("readlink dst/link: %v", err) } if target != "does_not_exist" { t.Errorf("symlink target: got %q, want %q", target, "does_not_exist") } // Must no longer be in src. if _, err := os.Lstat(filepath.Join(src, "link")); !os.IsNotExist(err) { t.Error("link should have been moved out of src") } } // TestRenameContents_SymlinksInSubdirMovedAsIs checks that symlinks inside a // nested directory are also moved without following. func TestRenameContents_SymlinksInSubdirMovedAsIs(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(filepath.Join(src, "sub"), 0755) os.MkdirAll(filepath.Join(dst, "sub"), 0755) os.WriteFile(filepath.Join(src, "sub", "file.txt"), []byte("data"), 0644) // Symlink with an absolute target to ensure it is not resolved. os.Symlink("/absolute/path", filepath.Join(src, "sub", "abslink")) srcDir := openDir(t, src) dstDir := openDir(t, dst) if err := rename_contents(srcDir, dstDir); err != nil { t.Fatalf("rename_contents: %v", err) } target, err := os.Readlink(filepath.Join(dst, "sub", "abslink")) if err != nil { t.Fatalf("readlink dst/sub/abslink: %v", err) } if target != "/absolute/path" { t.Errorf("symlink target: got %q, want %q", target, "/absolute/path") } } // TestRenameContents_DirExistsInDest verifies that directories already // existing in dest are not treated as overwrites — their contents are merged // and no error is returned. func TestRenameContents_DirExistsInDest(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src") dst := filepath.Join(tmp, "dst") os.MkdirAll(filepath.Join(src, "shared"), 0755) os.MkdirAll(filepath.Join(dst, "shared"), 0755) buildTree(t, src, map[string]string{"shared/new.txt": "new"}, nil) buildTree(t, dst, map[string]string{"shared/old.txt": "old"}, nil) srcDir := openDir(t, src) dstDir := openDir(t, dst) if err := rename_contents(srcDir, dstDir); err != nil { t.Fatalf("rename_contents should succeed when dir exists in dest: %v", err) } if _, err := os.Stat(filepath.Join(dst, "shared", "new.txt")); err != nil { t.Errorf("dst/shared/new.txt missing: %v", err) } if _, err := os.Stat(filepath.Join(dst, "shared", "old.txt")); err != nil { t.Errorf("dst/shared/old.txt missing: %v", err) } } // equalStringSlices returns true when two sorted slices are equal. func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }