From db56fd0e4c46f6f666ea0147a0dda8122781e4b3 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 11 Nov 2026 07:22:28 +0100 Subject: [PATCH] Say in --help where the database is and what each report prints (closes #177) (#189) --- README.md | 13 +++--- TODO.md | 5 +++ internal/sfdupes/cli.go | 80 +++++++++++++++++++++++++++++++++--- internal/sfdupes/cli_test.go | 72 +++++++++++++++++++++++++++++++- 4 files changed, 158 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dc51fc8..2db44c6 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,8 @@ records the files it finds in an SQLite database. SQLite keeps a whole database in one file. The database is kept between runs. A rescan reads only new and changed files. That makes `scan` cheap enough to run every day from `cron`, the system's job scheduler. `report`, `trees` and `info` work from the database and -can run at any time. `report` and `trees` leave out a copy that was deleted -after the scan. `dwim` scans `/` and then prints the `trees` report, for a first -look at a new machine with one command. +can run at any time. `dwim` scans `/` and then prints the `trees` report, for a +first look at a new machine with one command. ## Getting Started @@ -317,9 +316,11 @@ The reference below is also the program's specification. - `--version`, `-v` prints `sfdupes VERSION` (see "Install") to stdout and exits 0. It writes nothing to stderr. - `--help`, `-h`, alone or after a subcommand, prints the help text to stderr - and exits 0. It writes nothing to stdout. `sfdupes scan --help` also lists the - default excludes of the system it runs on, and the filesystem types the walk - never records anything on. + and exits 0. It writes nothing to stdout. The help of `sfdupes` and of `scan` + names the database. The help of `report`, `trees`, `info` and `dwim` says what + each prints. `sfdupes scan --help` also lists the default excludes of the + system it runs on, and the filesystem types the walk never records anything + on. ### Duplicate detection diff --git a/TODO.md b/TODO.md index 8fb200d..9b7d422 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,11 @@ Newest first. An entry ends with its date and its issue or branch, where those are known. +- The help of `sfdupes` and of `scan` names the database, its default path and + `SFDUPES_DATABASE`. The help of `report` and `trees` lists the columns, the + order of the rows and the escapes. The help of `info` lists its lines. The + help of `dwim` says that it scans `/`, should run as root and keeps the scan + in the database (2026-11-11, https://git.eeqj.de/sneak/sfdupes/issues/177) - New `scan` flag `--exclude PATH`, which may be repeated. The walk neither enters nor records an excluded path, and keeps the records below it. An operand at or below one is a fatal error (2026-11-11, diff --git a/internal/sfdupes/cli.go b/internal/sfdupes/cli.go index d37754d..f1d5b00 100644 --- a/internal/sfdupes/cli.go +++ b/internal/sfdupes/cli.go @@ -32,6 +32,69 @@ const ( cmdDwim = "dwim" ) +// databaseHelp is the paragraph of the root and scan help that names +// the database (README "Location"). +const databaseHelp = "scan and dwim store what they find in a database " + + "file. report, trees\nand info read it. The file is $" + databaseEnv + + " when that is set and\nnot empty, and otherwise " + + defaultDatabasePath + ", where creating it\nneeds root.\n" + +// escapesHelp is the paragraph of the report and trees help on how +// paths are escaped (README "Report output format"). +const escapesHelp = `Four characters in a path are escaped, so each row stays one line: + + \\ a backslash + \t a tab + \n a newline + \r a carriage return +` + +// reportHelp, treesHelp, infoHelp and dwimHelp follow the one-line +// summary in the help of their subcommands. +const ( + reportHelp = `The report is tab-separated text on stdout: a header line, then one +row per duplicate file. The columns are: + + first the path that sorts first, byte by byte, in its group of copies + dupe another copy of that file + size the file's size in bytes + +Rows are ordered by size, largest first, then by path. + +` + escapesHelp + + treesHelp = `The report is tab-separated text on stdout: a header line, then one +row per duplicate directory tree. The columns are: + + first the path that sorts first, byte by byte, in its group of copies + dupe another copy of that tree + files the number of regular files in one copy + size their total size in bytes + disk_size the bytes the dupe copy takes on disk + +Rows are ordered by size, largest first, then by path. + +` + escapesHelp + + infoHelp = `The details go to stdout, one per line. Each line holds a name and its +value, separated by a tab: + + database the path, and where it came from + database file size in bytes, then rounded, as 2147483648 (2.0 GiB) + -wal file size in bytes, then rounded; only if a -wal file exists + modified when the database file last changed, in UTC + schema version the version of the database's layout + records the number of records + size of recorded files their total size, in bytes, then rounded +` + + dwimHelp = `dwim runs what "sfdupes scan /" runs, then what "sfdupes trees" runs. +Run it as root, so that the scan can read every file. The scan is stored +in the database, as scan stores it, so "sfdupes trees" prints the report +again later. +` +) + // errNoSubcommand is returned by the root command when it is invoked // without a subcommand. That is a usage error, and the usage text // cobra prints for it is the whole message. @@ -202,15 +265,21 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command { }), } + root.Long = root.Short + ".\n\n" + databaseHelp + reportCmd.Long = reportCmd.Short + ".\n\n" + reportHelp + treesCmd.Long = treesCmd.Short + ".\n\n" + treesHelp + infoCmd.Long = infoCmd.Short + ".\n\n" + infoHelp + dwimCmd.Long = dwimCmd.Short + ".\n\n" + dwimHelp + root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd, dwimCmd) return root } // newScanCommand builds the scan subcommand, with its --workers, -x, -// --no-default-excludes and --exclude flags. Its help lists the default -// excludes of the system it runs on, and the filesystem types it never -// records anything on. +// --no-default-excludes and --exclude flags. Its help names the +// database, and lists the default excludes of the system it runs on and +// the filesystem types it never records anything on. func newScanCommand() *cobra.Command { var ( scanWorkers int @@ -228,8 +297,9 @@ func newScanCommand() *cobra.Command { // append its generic "[flags]" to it. DisableFlagsInUseLine: true, Short: short, - Long: short + ".\n\n" + defaultExcludesHelp(runtime.GOOS), - Args: cobra.MinimumNArgs(1), + Long: short + ".\n\n" + databaseHelp + "\n" + + defaultExcludesHelp(runtime.GOOS), + Args: cobra.MinimumNArgs(1), PreRunE: func(cmd *cobra.Command, _ []string) error { err := checkScanWorkers(cmd, scanWorkers) if err != nil { diff --git a/internal/sfdupes/cli_test.go b/internal/sfdupes/cli_test.go index dc1bf26..c9267e4 100644 --- a/internal/sfdupes/cli_test.go +++ b/internal/sfdupes/cli_test.go @@ -22,6 +22,9 @@ import ( // error prints and a runtime failure must not. const usageMarker = "Usage:" +// helpFlag asks for the help text. +const helpFlag = "--help" + // TestMain stops the tests at once, saying why, when their temporary // directory is not on a persistent filesystem: scan records nothing // there (README "Rules for the walk"), so most scan tests would fail @@ -693,7 +696,7 @@ func TestRunHelp(t *testing.T) { // README §Settings: help goes to stderr, exits 0, and leaves stdout // empty. - cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}} + cases := [][]string{{helpFlag}, {"-h"}, {cmdScan, helpFlag}} for _, args := range cases { var stdout, stderr bytes.Buffer @@ -754,6 +757,73 @@ func TestRunScanUsageLine(t *testing.T) { } } +func TestRunHelpDescribesDatabaseAndOutput(t *testing.T) { + t.Parallel() + + // README §Settings: the help names the database, and says what + // report, trees, info and dwim print. + database := []string{ + "scan and dwim store", databaseEnv, defaultDatabasePath, "needs root", + } + escapes := []string{`\\`, `\t`, `\n`, `\r`} + + // The subcommand whose help is printed; none for the root. + cases := []struct { + subcommand []string + want []string + }{ + {nil, database}, + {[]string{cmdScan}, database}, + { + []string{cmdReport}, + append([]string{ + "tab-separated", "\n first ", "\n dupe ", "\n size ", + "largest first", + }, escapes...), + }, + { + []string{cmdTrees}, + append([]string{ + "tab-separated", "\n first ", "\n dupe ", "\n files ", + "\n size ", "\n disk_size ", "largest first", + }, escapes...), + }, + { + []string{cmdInfo}, + []string{ + "\n database ", "\n database file size ", + "\n -wal file size ", "\n modified ", + "\n schema version ", "\n records ", + "\n size of recorded files ", + }, + }, + { + []string{cmdDwim}, + []string{ + "sfdupes scan /", "as root", "sfdupes trees", "database", + }, + }, + } + + for _, tc := range cases { + var stdout, stderr bytes.Buffer + + args := slices.Concat(tc.subcommand, []string{helpFlag}) + + code := run(args, &stdout, &stderr) + if code != exitOK { + t.Fatalf("run(%v) = %d, want %d", args, code, exitOK) + } + + for _, want := range tc.want { + if !strings.Contains(stderr.String(), want) { + t.Errorf("run(%v) help lacks %q:\n%s", + args, want, stderr.String()) + } + } + } +} + func TestRunVersion(t *testing.T) { t.Parallel()