Add sfdupes info: show the database's details (closes #117)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
info prints the database's path and source, the sizes of the database file and its -wal file, when the database file last changed, the schema version and the record counts, one per line as a name, a tab and the value. It opens the database as report does and shows an open and a count phase on stderr. The schema version and the counts come from one read-only transaction, the counts from one query. seedDatabase now closes test databases out of WAL mode, as scan does; otherwise info's own connection creates an empty -wal file. The open phase's name and text became constants for the linter, and the scan command moved to newScanCommand to keep newRootCommand short enough. Judgement call: the lines are written after the last display ends, so they skip the display's stdout writer. Model: opus-5-5
This commit is contained in:
+168
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// infoOpenLine is the plain line of info's open phase when it opens the
|
||||
// database at once; it prints it as the phase starts and as it ends.
|
||||
const infoOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
|
||||
"eta unknown"
|
||||
|
||||
// infoCountLine is the plain line of info's count phase when the count
|
||||
// takes under a second; it prints it as the phase starts and as it
|
||||
// ends.
|
||||
const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " +
|
||||
"eta unknown"
|
||||
|
||||
// infoRecs are four records: a duplicate pair with every hash, a file
|
||||
// of 20 MiB with head and tail hashes but no content hash yet, and a
|
||||
// file never hashed.
|
||||
func infoRecs() []scanRec {
|
||||
return []scanRec{
|
||||
{size: 5, head: "h", tail: "t", content: "c", path: "/d/a"},
|
||||
{size: 5, head: "h", tail: "t", content: "c", path: "/d/b"},
|
||||
{size: 20 << 20, head: "bh", tail: "bt", path: "/d/big"},
|
||||
{size: 7, path: "/d/unique"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunInfo runs info on an empty and on a filled database, with
|
||||
// stdout and stderr the same file that is not a terminal, and checks
|
||||
// everything written there: the line naming the database, each phase's
|
||||
// line as it starts and as it ends, and then the details, each on a
|
||||
// line of its own. No -wal file is beside a database scan has closed.
|
||||
func TestRunInfo(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
recs []scanRec
|
||||
want string // the details after the database file's time
|
||||
}{
|
||||
"empty": {
|
||||
want: "schema version\t1\n" +
|
||||
"records\t0\n" +
|
||||
"records with head and tail hashes\t0\n" +
|
||||
"records with content hash\t0\n" +
|
||||
"size of recorded files\t0 (0 B)\n",
|
||||
},
|
||||
"filled": {
|
||||
recs: infoRecs(),
|
||||
want: "schema version\t1\n" +
|
||||
"records\t4\n" +
|
||||
"records with head and tail hashes\t3\n" +
|
||||
"records with content hash\t2\n" +
|
||||
"size of recorded files\t20971537 (20.0 MiB)\n",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := seedDatabase(t, tc.recs)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
file, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stderr := captureStderr(t)
|
||||
|
||||
code := run([]string{cmdInfo}, os.Stderr, os.Stderr)
|
||||
if code != exitOK {
|
||||
t.Fatalf("run(info) = %d, want %d; stderr: %s",
|
||||
code, exitOK, stderr())
|
||||
}
|
||||
|
||||
named := path + " (from " + databaseEnv + ")\n"
|
||||
want := "info: database " + named +
|
||||
infoOpenLine + "\n" + infoOpenLine + "\n" +
|
||||
infoCountLine + "\n" + infoCountLine + "\n" +
|
||||
"database\t" + named +
|
||||
fmt.Sprintf("database file size\t%d (%s)\n",
|
||||
file.Size(), humanBytes(file.Size())) +
|
||||
"modified\t" + file.ModTime().UTC().Format(time.RFC3339) +
|
||||
"\n" + tc.want
|
||||
|
||||
if got := stderr(); got != want {
|
||||
t.Errorf("stdout and stderr = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunInfoDuringScan runs info while a scan holds the lock and has
|
||||
// the database open in WAL mode, with records committed to the -wal
|
||||
// file only. info must count those records and show the -wal file's
|
||||
// size, also when the database path is a symlink: SQLite keeps the
|
||||
// -wal file beside the symlink's target.
|
||||
func TestRunInfoDuringScan(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
link := filepath.Join(t.TempDir(), "link.sqlite")
|
||||
|
||||
err := os.Symlink(path, link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := openScanDatabase(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
err = applyChanges(t.Context(), db, infoRecs(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
holdScanLock(t, path)
|
||||
|
||||
wal, err := os.Stat(path + "-wal")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, named := range []string{path, link} {
|
||||
t.Run(filepath.Base(named), func(t *testing.T) {
|
||||
t.Setenv(databaseEnv, named)
|
||||
|
||||
stdout := runStdout(t, cmdInfo)
|
||||
|
||||
for _, want := range []string{
|
||||
"database\t" + named + " (from " + databaseEnv + ")\n",
|
||||
fmt.Sprintf("\n-wal file size\t%d (%s)\n",
|
||||
wal.Size(), humanBytes(wal.Size())),
|
||||
"\nrecords\t4\n",
|
||||
} {
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("stdout = %q, want it to hold %q", stdout, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInfoNeedsOnlyReadAccess(t *testing.T) {
|
||||
// README "`info` mode": like report, info needs only read access to
|
||||
// the database file and none to its directory.
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
scanFixture(t)
|
||||
makeReadOnly(t, path)
|
||||
|
||||
stdout := runStdout(t, cmdInfo)
|
||||
|
||||
if want := "\nrecords\t2\n"; !strings.Contains(stdout, want) {
|
||||
t.Errorf("stdout = %q, want it to hold %q", stdout, want)
|
||||
}
|
||||
|
||||
if strings.Contains(stdout, "-wal") {
|
||||
t.Errorf("stdout = %q, want no -wal file between scans", stdout)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user