check / check (push) Waiting to run
The old README was one dense specification: long sentences, lists chained with commas, exceptions ahead of the rules, and release history in the user text. This rewrite leads with what the program is and how to use it, then gives a reference for the settings, each subcommand, the database, the output formats, the progress display and the exit codes. Non-goals come before the reference; the constraints, the entrypoints and the build come last. Every statement was checked against the code on next and against the built binary on small trees. The section names the code comments cite are kept. Five comments in main_test.go cite Settings, and one each in scan_test.go, trees_test.go and exclude_test.go cites what the README now says. TODO.md records the step. Model: fable-5-1
432 lines
12 KiB
Go
432 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// treesOpenLine is the plain line of trees' open phase when it opens
|
|
// the database at once; it prints it as the phase starts and as it ends.
|
|
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
|
|
"remain unknown"
|
|
|
|
// smokeTreeRecs mirrors the tree of the test under README "Definition
|
|
// of done": /d/t1 and /d/t2 are identical, /d/t3 differs from them only
|
|
// by one filename.
|
|
func smokeTreeRecs() []scanRec {
|
|
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
|
|
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
|
|
|
|
return []scanRec{
|
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
|
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
|
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t2/f1"},
|
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t2/sub/f2"},
|
|
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t3/f1"},
|
|
{size: 100, head: f2Head, tail: f2Tail, content: f2Content,
|
|
path: "/d/t3/sub/f2renamed"},
|
|
}
|
|
}
|
|
|
|
// dbTree builds the directory hierarchy from the records in db the way
|
|
// trees does, and returns the super-root and every directory.
|
|
func dbTree(t *testing.T, db *sql.DB) (*treeNode, []*treeNode) {
|
|
t.Helper()
|
|
|
|
tree := newTreeBuilder()
|
|
|
|
err := loadFileRows(t.Context(), db, func(int64) {}, tree.add)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return tree.finish()
|
|
}
|
|
|
|
// treeOf writes recs into a fresh database and builds the directory
|
|
// hierarchy from it the way trees does.
|
|
func treeOf(t *testing.T, recs []scanRec) (*treeNode, []*treeNode) {
|
|
t.Helper()
|
|
|
|
db := openTestDB(t)
|
|
|
|
err := applyChanges(t.Context(), db, recs, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return dbTree(t, db)
|
|
}
|
|
|
|
// nodeByPath finds the directory node with the given path.
|
|
func nodeByPath(t *testing.T, dirs []*treeNode, path string) *treeNode {
|
|
t.Helper()
|
|
|
|
for _, d := range dirs {
|
|
if d.path == path {
|
|
return d
|
|
}
|
|
}
|
|
|
|
t.Fatalf("no directory node with path %q", path)
|
|
|
|
return nil
|
|
}
|
|
|
|
// groupPaths flattens tree groups into their member path lists.
|
|
func groupPaths(groups [][]*treeNode) [][]string {
|
|
out := make([][]string, 0, len(groups))
|
|
for _, g := range groups {
|
|
paths := make([]string, 0, len(g))
|
|
for _, n := range g {
|
|
paths = append(paths, n.path)
|
|
}
|
|
|
|
out = append(out, paths)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func TestTreeCounts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, dirs := treeOf(t, smokeTreeRecs())
|
|
|
|
d := nodeByPath(t, dirs, "/d")
|
|
if d.fileCount != 6 || d.totalSize != 9300 {
|
|
t.Errorf("/d: fileCount %d size %d, want 6 9300",
|
|
d.fileCount, d.totalSize)
|
|
}
|
|
|
|
t1 := nodeByPath(t, dirs, "/d/t1")
|
|
if t1.fileCount != 2 || t1.totalSize != 3100 {
|
|
t.Errorf("/d/t1: fileCount %d size %d, want 2 3100",
|
|
t1.fileCount, t1.totalSize)
|
|
}
|
|
|
|
sub := nodeByPath(t, dirs, "/d/t1/sub")
|
|
if sub.fileCount != 1 || sub.totalSize != 100 {
|
|
t.Errorf("/d/t1/sub: fileCount %d size %d, want 1 100",
|
|
sub.fileCount, sub.totalSize)
|
|
}
|
|
}
|
|
|
|
func TestTreeRootPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The root directory's path is "/", never empty, and its
|
|
// children's paths start with a single slash.
|
|
_, dirs := treeOf(t, []scanRec{{path: "/f"}, {path: "/srv/g"}})
|
|
|
|
got := make([]string, 0, len(dirs))
|
|
for _, d := range dirs {
|
|
got = append(got, d.path)
|
|
}
|
|
|
|
slices.Sort(got)
|
|
|
|
want := []string{"/", "/srv"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("directory paths = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestTreeNamesSortingBeforeSlash(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// In path order "/a/b-x/f" and "/a/b.txt" come between the file
|
|
// "/a/b" and "/a/b/f", because "-" and "." sort before "/". Each
|
|
// directory must still be built once, whole, so /a matches /c.
|
|
recs := make([]scanRec, 0, 8)
|
|
|
|
for _, top := range []string{"/a", "/c"} {
|
|
for _, p := range []string{"/b", "/b-x/f", "/b.txt", "/b/f"} {
|
|
content := testHash("c")
|
|
if p == "/b-x/f" {
|
|
content = testHash("other")
|
|
}
|
|
|
|
recs = append(recs, scanRec{
|
|
size: 1, head: testHash("h"), tail: testHash("t"),
|
|
content: content, path: top + p,
|
|
})
|
|
}
|
|
}
|
|
|
|
super, dirs := treeOf(t, recs)
|
|
|
|
got := make([]string, 0, len(dirs))
|
|
for _, d := range dirs {
|
|
got = append(got, d.path)
|
|
}
|
|
|
|
slices.Sort(got)
|
|
|
|
want := []string{"/", "/a", "/a/b", "/a/b-x", "/c", "/c/b", "/c/b-x"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("directory paths = %q, want %q", got, want)
|
|
}
|
|
|
|
groups := collectTreeGroups(dirs, super, nil)
|
|
|
|
gotGroups := groupPaths(groups)
|
|
wantGroups := [][]string{{"/a", "/c"}}
|
|
|
|
if !slices.EqualFunc(gotGroups, wantGroups, slices.Equal) {
|
|
t.Fatalf("groups = %v, want %v", gotGroups, wantGroups)
|
|
}
|
|
|
|
if groups[0][0].fileCount != 4 || groups[0][0].totalSize != 4 {
|
|
t.Errorf("group totals: %d files %d bytes, want 4 4",
|
|
groups[0][0].fileCount, groups[0][0].totalSize)
|
|
}
|
|
}
|
|
|
|
func TestRunTreesEscapesPaths(t *testing.T) {
|
|
t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs()))
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
code := run([]string{cmdTrees}, &stdout, &stderr)
|
|
if code != exitOK {
|
|
t.Fatalf("run(trees) = %d, want %d; stderr: %s",
|
|
code, exitOK, stderr.String())
|
|
}
|
|
|
|
want := "first\tdupe\tfiles\tsize\n" +
|
|
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\n"
|
|
if got := stdout.String(); got != want {
|
|
t.Errorf("stdout = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestRunTreesProgressNamesEachPhase runs trees on the smoke-test
|
|
// records with stderr not a terminal, and checks every line it prints
|
|
// there. After the line naming the database, each phase prints a line
|
|
// naming it with its place among the four as it starts and another as
|
|
// it ends. read counts the 6 records before it reads them, group counts
|
|
// the 8 directories, and write the 1 group. The summary comes last, and
|
|
// stdout holds the report alone.
|
|
func TestRunTreesProgressNamesEachPhase(t *testing.T) {
|
|
path := seedDatabase(t, smokeTreeRecs())
|
|
t.Setenv(databaseEnv, path)
|
|
|
|
stderr := captureStderr(t)
|
|
|
|
var stdout bytes.Buffer
|
|
|
|
code := run([]string{cmdTrees}, &stdout, os.Stderr)
|
|
if code != exitOK {
|
|
t.Fatalf("run(trees) = %d, want %d; stderr: %s",
|
|
code, exitOK, stderr())
|
|
}
|
|
|
|
report := "first\tdupe\tfiles\tsize\n/d/t1\t/d/t2\t2\t3100\n"
|
|
if got := stdout.String(); got != report {
|
|
t.Errorf("stdout = %q, want %q", got, report)
|
|
}
|
|
|
|
// How each line starts; the rate at the end of a phase varies.
|
|
want := []string{
|
|
"trees: database " + path + " (from " + databaseEnv + ")",
|
|
treesOpenLine,
|
|
treesOpenLine,
|
|
"[2/4] read: counting the records, elapsed 0s, remain unknown",
|
|
"[2/4] read: [6/6] 100%, ",
|
|
"[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, remain unknown",
|
|
"[3/4] group: [8/8] 100%, ",
|
|
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, remain unknown",
|
|
"[4/4] write: [1/1] 100%, ",
|
|
"trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ",
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
|
|
if len(lines) != len(want) {
|
|
t.Fatalf("stderr lines = %q, want %d lines starting %q",
|
|
lines, len(want), want)
|
|
}
|
|
|
|
for i := range want {
|
|
if !strings.HasPrefix(lines[i], want[i]) {
|
|
t.Errorf("stderr line %d = %q, want it to start %q",
|
|
i+1, lines[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWriteTreeGroupsOnTerminal writes the smoke-test report to the
|
|
// terminal its write phase's display is drawn on, as a plain "sfdupes
|
|
// trees" does: the terminal must show the header and the row whole,
|
|
// each on a line of its own.
|
|
//
|
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
|
func TestWriteTreeGroupsOnTerminal(t *testing.T) {
|
|
stderr := captureStderr(t)
|
|
super, dirs := treeOf(t, smokeTreeRecs())
|
|
dupes := collectTreeGroups(dirs, super, nil)
|
|
|
|
// No timed draws: the display is drawn as it starts and as it ends.
|
|
p := startProgress(progressParams{
|
|
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
|
|
}, terminalWidth, time.Hour, time.Now)
|
|
|
|
_, _, err := writeTreeGroups(os.Stderr, dupes, p)
|
|
|
|
p.finish()
|
|
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A terminal shows each line as the text after its last carriage
|
|
// return.
|
|
var shown []string
|
|
|
|
for line := range strings.SplitSeq(stderr(), "\n") {
|
|
shown = append(shown, line[strings.LastIndex(line, "\r")+1:])
|
|
}
|
|
|
|
want := []string{"first\tdupe\tfiles\tsize", "/d/t1\t/d/t2\t2\t3100"}
|
|
if len(shown) < len(want) || !slices.Equal(shown[:len(want)], want) {
|
|
t.Errorf("terminal shows lines %q, want %q first", shown, want)
|
|
}
|
|
}
|
|
|
|
func TestTreeDigests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, dirs := treeOf(t, smokeTreeRecs())
|
|
|
|
t1 := nodeByPath(t, dirs, "/d/t1")
|
|
t2 := nodeByPath(t, dirs, "/d/t2")
|
|
t3 := nodeByPath(t, dirs, "/d/t3")
|
|
|
|
if t1.digest != t2.digest {
|
|
t.Error("identical trees /d/t1 and /d/t2 have different digests")
|
|
}
|
|
|
|
// t3 differs only in a filename; names are part of the digest.
|
|
if t1.digest == t3.digest {
|
|
t.Error("/d/t3 digest equals /d/t1 despite a renamed file")
|
|
}
|
|
|
|
sub1 := nodeByPath(t, dirs, "/d/t1/sub")
|
|
sub3 := nodeByPath(t, dirs, "/d/t3/sub")
|
|
|
|
if sub1.digest == sub3.digest {
|
|
t.Error("subdirs with differently-named files share a digest")
|
|
}
|
|
}
|
|
|
|
func TestTreeDigestContentSensitivity(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
same, c := testHash("same"), testHash("c")
|
|
recs := []scanRec{
|
|
{size: 10, head: same, tail: same, content: c, path: "/r/a/f"},
|
|
{size: 10, head: testHash("DIFF"), tail: same, content: c, path: "/r/b/f"},
|
|
}
|
|
|
|
_, dirs := treeOf(t, recs)
|
|
|
|
a := nodeByPath(t, dirs, "/r/a")
|
|
b := nodeByPath(t, dirs, "/r/b")
|
|
|
|
if a.digest == b.digest {
|
|
t.Error("trees with different file content share a digest")
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsMaximal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
super, dirs := treeOf(t, smokeTreeRecs())
|
|
|
|
groups := collectTreeGroups(dirs, super, nil)
|
|
|
|
want := [][]string{{"/d/t1", "/d/t2"}}
|
|
if got := groupPaths(groups); !slices.EqualFunc(got, want,
|
|
slices.Equal) {
|
|
t.Fatalf("groups = %v, want %v", got, want)
|
|
}
|
|
|
|
// The /d/t1/sub vs /d/t2/sub group must be suppressed as implied
|
|
// by its parents' group; the winning group reports one copy's
|
|
// recursive totals.
|
|
if groups[0][0].fileCount != 2 || groups[0][0].totalSize != 3100 {
|
|
t.Errorf("group totals: %d files %d bytes, want 2 3100",
|
|
groups[0][0].fileCount, groups[0][0].totalSize)
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsDeterministic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
recs := smokeTreeRecs()
|
|
|
|
super, dirs := treeOf(t, recs)
|
|
|
|
forward := groupPaths(collectTreeGroups(dirs, super, nil))
|
|
|
|
reversed := slices.Clone(recs)
|
|
slices.Reverse(reversed)
|
|
|
|
superR, dirsR := treeOf(t, reversed)
|
|
|
|
backward := groupPaths(collectTreeGroups(dirsR, superR, nil))
|
|
if !slices.EqualFunc(forward, backward, slices.Equal) {
|
|
t.Fatalf("output depends on record order: %v vs %v",
|
|
forward, backward)
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsSiblings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Identical sibling dirs share a parent, so their group cannot be
|
|
// implied by a parent group and must be reported.
|
|
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
|
recs := []scanRec{
|
|
{size: 10, head: h, tail: tl, content: c, path: "/p/x1/f"},
|
|
{size: 10, head: h, tail: tl, content: c, path: "/p/x2/f"},
|
|
}
|
|
|
|
super, dirs := treeOf(t, recs)
|
|
|
|
got := groupPaths(collectTreeGroups(dirs, super, nil))
|
|
|
|
want := [][]string{{"/p/x1", "/p/x2"}}
|
|
if !slices.EqualFunc(got, want, slices.Equal) {
|
|
t.Fatalf("groups = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCollectTreeGroupsDifferingParents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// /p/a and /q/b contain identical x subtrees, but /p/a has an
|
|
// extra file, so the parents' digests differ and the x group must
|
|
// be reported.
|
|
h, tl, c, e := testHash("h"), testHash("t"), testHash("c"), testHash("e")
|
|
recs := []scanRec{
|
|
{size: 10, head: h, tail: tl, content: c, path: "/p/a/x/f"},
|
|
{size: 99, head: e, tail: e, content: e, path: "/p/a/extra"},
|
|
{size: 10, head: h, tail: tl, content: c, path: "/q/b/x/f"},
|
|
}
|
|
|
|
super, dirs := treeOf(t, recs)
|
|
|
|
got := groupPaths(collectTreeGroups(dirs, super, nil))
|
|
|
|
want := [][]string{{"/p/a/x", "/q/b/x"}}
|
|
if !slices.EqualFunc(got, want, slices.Equal) {
|
|
t.Fatalf("groups = %v, want %v", got, want)
|
|
}
|
|
}
|