1 Commits
Author SHA1 Message Date
clawbot df1d5cb8ff 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 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 without cleaning: cleaning drops a ".." with the directory before
it, which names another file when that directory is a symlink. README
"Database" lists when scan writes each file.

Unverified: the line on a terminal has no test of its own.
Rule suppressed: TestDatabasePath carries nolint:paralleltest, as it
changes the working directory.

Model: opus-5-5
2026-10-16 12:34:49 +00:00
4 changed files with 101 additions and 10 deletions
+4 -3
View File
@@ -269,9 +269,10 @@ All three subcommands operate on a single SQLite database file:
directory.
- Each subcommand first prints one line on stderr naming the database it uses,
before anything else it prints (progress displays included), whether or not
stderr is a terminal. The line gives the path made absolute against the
working directory, escaped like the paths in a report (see "Report output
format"), and says where the path came from:
stderr is a terminal. The line gives the path with the working directory put
in front of it when it is relative, and otherwise as set (a `..` in it stays,
so the line names the same file), escaped like the paths in a report (see
"Report output format"), and says where the path came from:
```
scan: new database /var/lib/sfdupes/db.sqlite (default)
+15 -5
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"golang.org/x/sys/unix"
@@ -92,21 +93,30 @@ var errScanRunning = errors.New("another scan is running")
// databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the
// path made absolute against the working directory, and where the path
// came from, "default" or "from SFDUPES_DATABASE", for the line each
// subcommand prints first on stderr.
// path, with the working directory put in front of it when it is
// relative, and where the path came from, "default" or "from
// SFDUPES_DATABASE", for the line each subcommand prints first on
// stderr.
func databasePath() (string, string, error) {
path, source := defaultDatabasePath, "default"
if p := os.Getenv(databaseEnv); p != "" {
path, source = p, "from "+databaseEnv
}
abs, err := filepath.Abs(path)
if filepath.IsAbs(path) {
return path, source, nil
}
wd, err := os.Getwd()
if err != nil {
return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
}
return abs, source, nil
// Not filepath.Abs or filepath.Join: they clean the path, which
// drops each ".." together with the directory name before it. When
// that directory is a symlink, the cleaned path names another file.
// The working directory ends in "/" only when it is the root.
return strings.TrimSuffix(wd, "/") + "/" + path, source, nil
}
// scanParams are the connection parameters for scan: read-write, with
+16 -2
View File
@@ -45,7 +45,8 @@ func openTestDB(t *testing.T) *sql.DB {
//nolint:paralleltest // changes the process-wide working directory
func TestDatabasePath(t *testing.T) {
// README §Database: SFDUPES_DATABASE when set and non-empty, the
// default otherwise, made absolute against the working directory.
// default otherwise, with the working directory put in front of a
// relative path. Nothing is cleaned, so a ".." stays.
dir := t.TempDir()
t.Chdir(dir)
@@ -55,7 +56,10 @@ func TestDatabasePath(t *testing.T) {
}{
{"", defaultDatabasePath, "default"},
{"/custom/place.sqlite", "/custom/place.sqlite", fromEnv},
{"sub/place.sqlite", filepath.Join(dir, "sub/place.sqlite"), fromEnv},
{"/custom/link/../place.sqlite", "/custom/link/../place.sqlite",
fromEnv},
{"sub/place.sqlite", dir + "/sub/place.sqlite", fromEnv},
{"link/../place.sqlite", dir + "/link/../place.sqlite", fromEnv},
}
for _, tc := range cases {
@@ -67,6 +71,16 @@ func TestDatabasePath(t *testing.T) {
databaseEnv, tc.env, path, source, err, tc.path, tc.source)
}
}
// In the root directory the path gets one slash in front, not two.
t.Chdir("/")
t.Setenv(databaseEnv, "place.sqlite")
path, _, err := databasePath()
if err != nil || path != "/place.sqlite" {
t.Errorf("databasePath() in / = %q, %v; want %q",
path, err, "/place.sqlite")
}
}
func TestOpenScanDatabaseCreates(t *testing.T) {
+66
View File
@@ -352,6 +352,72 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
}
}
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 := t.TempDir()
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 TestRunNamesDefaultDatabase(t *testing.T) {
// README §Database: with SFDUPES_DATABASE empty, each subcommand's
// first line on stderr names the default database. A database may