Name the database each command uses on stderr (closes #111)
check / check (push) Canceled after 0s

scan, report and trees now print one line on stderr before anything
else, naming the database as an absolute path and saying whether the
path is the default or comes from SFDUPES_DATABASE; scan also says when
no database file exists yet. report and trees print it before opening
the database, so a missing-database error follows it. databasePath in
db.go now returns the absolute path and its source, and every command
uses that absolute path throughout, so errors and the lock file name it
too. README "Database" lists when scan writes each file, from the lock
file to the -wal and -shm files going away.

Unverified: the line on a terminal has no test of its own; it is
printed before any progress display, the same way in both cases.

Model: opus-5-5
This commit is contained in:
2026-10-16 12:02:46 +00:00
parent 198cefa152
commit ff14c28ee7
9 changed files with 226 additions and 35 deletions
+109 -7
View File
@@ -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,99 @@ 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 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 +761,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 +795,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)