diff --git a/README.md b/README.md index f82360b..fc4d59e 100644 --- a/README.md +++ b/README.md @@ -300,9 +300,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 94e5c67..851fd30 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) - The usage line of `scan` no longer ends in `[flags]`. It is the line `README.md` shows under "Subcommands" (2026-11-11, https://git.eeqj.de/sneak/sfdupes/issues/169) diff --git a/internal/sfdupes/cli.go b/internal/sfdupes/cli.go index 387a0b5..9fced8e 100644 --- a/internal/sfdupes/cli.go +++ b/internal/sfdupes/cli.go @@ -31,6 +31,64 @@ 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 that each row stays one +line: a backslash as \\, a tab as \t, a newline as \n and a carriage +return as \r. +` + +// 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 + +const 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 + +const 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 + -wal file size in bytes, only while 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 the total size of the recorded files, in bytes +` + +const 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. @@ -196,15 +254,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 and -// --no-default-excludes 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 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 @@ -220,8 +284,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 { return checkScanWorkers(cmd, scanWorkers) }, diff --git a/internal/sfdupes/cli_test.go b/internal/sfdupes/cli_test.go index a56321e..2ab40c0 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 @@ -692,7 +695,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 @@ -742,6 +745,70 @@ 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 ", + }, escapes...), + }, + { + []string{cmdTrees}, + append([]string{ + "tab-separated", "\n first ", "\n dupe ", "\n files ", + "\n size ", "\n disk_size ", + }, 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"}, + }, + } + + 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()