Name the database each command uses on stderr (closes #111)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
scan, report and trees now print one line on stderr before anything else, naming the database and whether its path is the default or comes from SFDUPES_DATABASE; scan also says when the file is new. report and trees print it before opening the database, so a missing-database error follows it. A relative path gets the working directory put in front of it, with its symlinks resolved as the kernel reports it, and is not cleaned: a ".." after a symlink names another file than the cleaned path does. README "Database" lists when scan writes each file. Unverified: the line on a terminal has no test of its own. Model: opus-5-5
This commit was merged in pull request #118.
This commit is contained in:
+256
-7
@@ -285,22 +285,29 @@ func assertFatalOutput(t *testing.T, stderr, stdout string) {
|
||||
|
||||
func TestRunMissingDatabaseIsFatal(t *testing.T) {
|
||||
// README §Database: report and trees need an existing database; a
|
||||
// missing one exits 1 with a message telling the user to run scan.
|
||||
// missing one exits 1 with a message telling the user to run scan,
|
||||
// after the line naming the database.
|
||||
for _, name := range []string{cmdReport, cmdTrees} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
var stdout bytes.Buffer
|
||||
|
||||
code := run([]string{name}, &stdout, &stderr)
|
||||
// run gets os.Stderr, where the subcommand prints its line,
|
||||
// so the capture shows the two in the order printed.
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run([]string{name}, &stdout, os.Stderr)
|
||||
if code != exitFatal {
|
||||
t.Errorf("run(%s) = %d, want %d", name, code, exitFatal)
|
||||
}
|
||||
|
||||
want := "sfdupes: " + path + ": no database (run \"sfdupes " +
|
||||
want := name + ": database " + path + " (from " + databaseEnv +
|
||||
")\n" +
|
||||
"sfdupes: " + path + ": no database (run \"sfdupes " +
|
||||
"scan\" first, or set " + databaseEnv + ")\n"
|
||||
if got := stderr.String(); got != want {
|
||||
if got := stderr(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -311,6 +318,246 @@ func TestRunMissingDatabaseIsFatal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNamesRelativeDatabase(t *testing.T) {
|
||||
// README §Database: each subcommand's first line on stderr names the
|
||||
// database, a relative SFDUPES_DATABASE made absolute against the
|
||||
// working directory. scan calls it new until a scan has created it.
|
||||
path := testDBPath(t)
|
||||
t.Chdir(filepath.Dir(path))
|
||||
t.Setenv(databaseEnv, filepath.Base(path))
|
||||
|
||||
dir := t.TempDir()
|
||||
from := " (from " + databaseEnv + ")\n"
|
||||
|
||||
want := "scan: new database " + path + from
|
||||
if got := scanOK(t, dir); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("first scan stderr = %q, want it to start %q", got, want)
|
||||
}
|
||||
|
||||
want = "scan: database " + path + from
|
||||
if got := scanOK(t, dir); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("second scan stderr = %q, want it to start %q", got, want)
|
||||
}
|
||||
|
||||
for _, name := range []string{cmdReport, cmdTrees} {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run([]string{name}, io.Discard, os.Stderr)
|
||||
|
||||
want = name + ": database " + path + from
|
||||
if got := stderr(); code != exitOK || !strings.HasPrefix(got, want) {
|
||||
t.Errorf("run(%s) = %d with stderr %q, want %d and it to "+
|
||||
"start %q", name, code, got, exitOK, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
|
||||
// README §Database: the path names the file exactly. With link a
|
||||
// symlink to the directory real/sub, link/../db.sqlite names
|
||||
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
||||
// use that file, relative or absolute, and its first line must name
|
||||
// the path as set, with the working directory put in front of a
|
||||
// relative one.
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Chdir(dir)
|
||||
|
||||
err = os.MkdirAll(filepath.Join(dir, "real", "sub"), 0o750)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Symlink(filepath.Join(dir, "real", "sub"),
|
||||
filepath.Join(dir, "link"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
named := dir + "/link/../db.sqlite"
|
||||
from := " (from " + databaseEnv + ")\n"
|
||||
tree := t.TempDir()
|
||||
|
||||
t.Setenv(databaseEnv, "link/../db.sqlite")
|
||||
|
||||
want := "scan: new database " + named + from
|
||||
if got := scanOK(t, tree); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("relative scan stderr = %q, want it to start %q", got, want)
|
||||
}
|
||||
|
||||
// The absolute path names the database the relative one created.
|
||||
t.Setenv(databaseEnv, named)
|
||||
|
||||
want = "scan: database " + named + from
|
||||
if got := scanOK(t, tree); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("absolute scan stderr = %q, want it to start %q", got, want)
|
||||
}
|
||||
|
||||
for _, name := range []string{cmdReport, cmdTrees} {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run([]string{name}, io.Discard, os.Stderr)
|
||||
|
||||
want = name + ": database " + named + from
|
||||
if got := stderr(); code != exitOK || !strings.HasPrefix(got, want) {
|
||||
t.Errorf("run(%s) = %d with stderr %q, want %d and it to "+
|
||||
"start %q", name, code, got, exitOK, want)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "real", "db.sqlite"))
|
||||
if err != nil {
|
||||
t.Errorf("database the path names: %v", err)
|
||||
}
|
||||
|
||||
// With no file at the cleaned path, report and trees succeeded on
|
||||
// the database in real.
|
||||
_, err = os.Stat(filepath.Join(dir, "db.sqlite"))
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("database at the cleaned path: stat error %v, want it "+
|
||||
"missing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScanFromSymlinkedWorkingDirectory(t *testing.T) {
|
||||
// README §Database: a relative path gets the working directory, its
|
||||
// symlinks resolved, put in front of it. With home/proj a symlink
|
||||
// to data/proj, entered as the working directory through the
|
||||
// symlink, ../new/db.sqlite names data/new/db.sqlite. scan must
|
||||
// create that directory and file, and nothing beside home/proj.
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Join(dir, "data", "proj"), 0o750)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Mkdir(filepath.Join(dir, "home"), 0o750)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = os.Symlink(filepath.Join(dir, "data", "proj"),
|
||||
filepath.Join(dir, "home", "proj"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// t.Chdir also sets PWD to the path through the symlink, as a
|
||||
// shell does after cd.
|
||||
t.Chdir(filepath.Join(dir, "home", "proj"))
|
||||
t.Setenv(databaseEnv, "../new/db.sqlite")
|
||||
|
||||
want := "scan: new database " + dir + "/data/proj/../new/db.sqlite" +
|
||||
" (from " + databaseEnv + ")\n"
|
||||
if got := scanOK(t, t.TempDir()); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("scan stderr = %q, want it to start %q", got, want)
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "data", "new", "db.sqlite"))
|
||||
if err != nil {
|
||||
t.Errorf("database the path names: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Lstat(filepath.Join(dir, "home", "new"))
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("home/new: stat error %v, want it missing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEscapesDatabasePath(t *testing.T) {
|
||||
// README §Database: each subcommand's first line escapes the
|
||||
// database path like the paths in a report, so a tab, newline or
|
||||
// backslash in it keeps the line one line.
|
||||
dir := t.TempDir()
|
||||
t.Setenv(databaseEnv, dir+"/a\tb\nc\\d.sqlite")
|
||||
|
||||
escaped := dir + `/a\tb\nc\\d.sqlite`
|
||||
from := " (from " + databaseEnv + ")\n"
|
||||
|
||||
want := "scan: new database " + escaped + from
|
||||
if got := scanOK(t, t.TempDir()); !strings.HasPrefix(got, want) {
|
||||
t.Errorf("scan stderr = %q, want it to start %q", got, want)
|
||||
}
|
||||
|
||||
for _, name := range []string{cmdReport, cmdTrees} {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run([]string{name}, io.Discard, os.Stderr)
|
||||
|
||||
want = name + ": database " + escaped + from
|
||||
if got := stderr(); code != exitOK || !strings.HasPrefix(got, want) {
|
||||
t.Errorf("run(%s) = %d with stderr %q, want %d and it to "+
|
||||
"start %q", name, code, got, exitOK, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNamesDefaultDatabase(t *testing.T) {
|
||||
// README §Database: with SFDUPES_DATABASE empty, each subcommand's
|
||||
// first line on stderr names the default database. A database may
|
||||
// exist at the default path on the machine running the test, so
|
||||
// nothing here may create or read one: scan gets an operand that
|
||||
// does not exist, which stops it before it takes the lock, and
|
||||
// report and trees a cancelled context, which stops them before
|
||||
// they read anything.
|
||||
t.Setenv(databaseEnv, "")
|
||||
|
||||
// Whether scan calls the database new depends on that machine too.
|
||||
scanWant := "scan: database " + defaultDatabasePath + " (default)\n"
|
||||
|
||||
_, err := os.Stat(defaultDatabasePath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
scanWant = "scan: new database " + defaultDatabasePath +
|
||||
" (default)\n"
|
||||
}
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "nope")
|
||||
|
||||
cases := map[string]struct {
|
||||
run func() error
|
||||
want string
|
||||
}{
|
||||
cmdScan: {
|
||||
run: func() error {
|
||||
return runScan(t.Context(), []string{missing}, 1, false)
|
||||
},
|
||||
want: scanWant,
|
||||
},
|
||||
cmdReport: {
|
||||
run: func() error {
|
||||
return runReport(cancelledContext(t), io.Discard)
|
||||
},
|
||||
want: "report: database " + defaultDatabasePath + " (default)\n",
|
||||
},
|
||||
cmdTrees: {
|
||||
run: func() error {
|
||||
return runTrees(cancelledContext(t), io.Discard)
|
||||
},
|
||||
want: "trees: database " + defaultDatabasePath + " (default)\n",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
err = tc.run()
|
||||
if err == nil {
|
||||
t.Errorf("%s: error = nil, want it stopped", name)
|
||||
}
|
||||
|
||||
if got := stderr(); got != tc.want {
|
||||
t.Errorf("%s stderr = %q, want %q", name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUsageErrors(t *testing.T) {
|
||||
// Usage errors keep exiting 2 with cobra's own report on stderr.
|
||||
cases := map[string]struct {
|
||||
@@ -661,7 +908,8 @@ func TestRunReportSucceeds(t *testing.T) {
|
||||
t.Errorf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "report: 2 records read, 1 duplicate groups, 1 dupe files, " +
|
||||
want = "report: database " + path + " (from " + databaseEnv + ")\n" +
|
||||
"report: 2 records read, 1 duplicate groups, 1 dupe files, " +
|
||||
"300 B reclaimable\n"
|
||||
if got := stderr(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
@@ -694,7 +942,8 @@ func TestRunTreesSucceeds(t *testing.T) {
|
||||
t.Errorf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "trees: 2 records read, 1 duplicate tree groups, 1 dupe trees, " +
|
||||
want = "trees: database " + path + " (from " + databaseEnv + ")\n" +
|
||||
"trees: 2 records read, 1 duplicate tree groups, 1 dupe trees, " +
|
||||
"300 B reclaimable\n"
|
||||
if got := stderr(); got != want {
|
||||
t.Errorf("stderr = %q, want %q", got, want)
|
||||
|
||||
Reference in New Issue
Block a user