diff --git a/README.md b/README.md index f9905fa..266f208 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,8 @@ Goals, in order: again. `scan` is designed to be cronned; the reports run at any time against the last completed scan. 4. **Clean stream separation.** Everything on stdout is machine-readable - data. All progress, warnings, and summaries go to stderr. Never mix - them. + data. All progress, warnings, summaries, and help and usage text go + to stderr. Never mix them. ### Constraints @@ -149,8 +149,14 @@ Three subcommands, all implemented: sfdupes scan [--workers N] [-x] PATH... sfdupes report > dupes.tsv sfdupes trees > dupetrees.tsv +sfdupes --version ``` +`sfdupes --version` (or `-v`) prints one line, `sfdupes VERSION`, to +stdout and exits 0, writing nothing to stderr. `-h` or `--help`, alone +or after a subcommand, prints the help text to stderr and exits 0, +writing nothing to stdout. + ### Database All three subcommands operate on a single SQLite database file: diff --git a/TODO.md b/TODO.md index 18e9bc3..e4ec1ba 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,9 @@ # Completed Steps +- `--version` prints `sfdupes VERSION` to stdout; README documents it and + `--help` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/15) + - `report` and `trees` stream the records instead of holding them all in memory; the schema gains the `files_signature` index (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/14) diff --git a/main.go b/main.go index 3a8ce57..cfe016f 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ // sfdupes scan [--workers N] [-x] PATH... // sfdupes report > dupes.tsv // sfdupes trees > dupetrees.tsv +// sfdupes --version // // See README.md for the complete specification. package main @@ -101,15 +102,29 @@ func run(args []string, stdout, stderr io.Writer) int { } // newRootCommand builds the command tree. Everything on stdout is -// machine-readable data; all human-facing output (help, usage, errors) -// goes to stderr. +// machine-readable data, the version line included; all human-facing +// output (help, usage, errors) goes to stderr. func newRootCommand(stdout, stderr io.Writer) *cobra.Command { + var showVersion bool + + printVersion := runE(func(context.Context, []string) error { + _, err := fmt.Fprintf(stdout, "sfdupes %s\n", Version) + if err != nil { + return fmt.Errorf("write stdout: %w", err) + } + + return nil + }) + root := &cobra.Command{ - Use: "sfdupes", - Short: "Find candidate duplicate files by size and head/tail/content SHA-256", - Version: Version, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + Use: "sfdupes", + Short: "Find candidate duplicate files by size and head/tail/content SHA-256", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if showVersion { + return printVersion(cmd, args) + } + // A missing subcommand prints usage and exits 2: cobra // prints the usage text for the returned error, and run // maps everything that is not a fatal error to exit 2. @@ -122,6 +137,11 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command { root.SetErr(stderr) root.CompletionOptions.DisableDefaultCmd = true + // Cobra's built-in version flag prints through the help writer, + // stderr; this one prints to stdout. + root.Flags().BoolVarP(&showVersion, "version", "v", false, + "print the version to stdout") + var ( scanWorkers int scanOneFS bool @@ -163,13 +183,13 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command { return root } -// runE adapts a subcommand implementation to cobra's RunE. Cobra -// prints the error and the command's usage text for every error RunE -// returns, but a subcommand that ran and failed has no usage problem -// to report: both are silenced here, and the error is marked fatal so -// that run reports it on stderr and exits 1 rather than 2. The command's -// context is handed to the implementation: cancelling it unwinds the -// scan's worker pools. +// runE adapts a subcommand implementation, or the version print, to +// cobra's RunE. Cobra prints the error and the command's usage text for +// every error RunE returns, but a subcommand that ran and failed has no +// usage problem to report: both are silenced here, and the error is +// marked fatal so that run reports it on stderr and exits 1 rather than +// 2. The command's context is handed to the implementation: cancelling +// it unwinds the scan's worker pools. func runE( fn func(ctx context.Context, args []string) error, ) func(*cobra.Command, []string) error { diff --git a/main_test.go b/main_test.go index d6716de..bddb2a9 100644 --- a/main_test.go +++ b/main_test.go @@ -295,34 +295,72 @@ func TestRunUsageErrors(t *testing.T) { } } -// TestRunHelpAndVersionSucceed checks that the two informational flags -// exit 0 and keep their human-facing output on stderr. -func TestRunHelpAndVersionSucceed(t *testing.T) { +func TestRunHelp(t *testing.T) { t.Parallel() - assertHumanOutput(t, "--help") - assertHumanOutput(t, "--version") + // README §Subcommands: help goes to stderr, exits 0, and leaves + // stdout empty. + cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}} + + for _, args := range cases { + var stdout, stderr bytes.Buffer + + code := run(args, &stdout, &stderr) + if code != exitOK { + t.Errorf("run(%v) = %d, want %d", args, code, exitOK) + } + + if !strings.Contains(stderr.String(), usageMarker) { + t.Errorf("run(%v) stderr = %q, want the help text", + args, stderr.String()) + } + + if got := stdout.String(); got != "" { + t.Errorf("run(%v) stdout = %q, want nothing (data only)", + args, got) + } + } } -// assertHumanOutput runs sfdupes with one informational flag and checks -// that it succeeds with its output on stderr and stdout untouched -// (README design goal 4). -func assertHumanOutput(t *testing.T, arg string) { - t.Helper() +func TestRunVersion(t *testing.T) { + t.Parallel() - var stdout, stderr bytes.Buffer + // README §Subcommands: the version is one line on stdout, with + // nothing on stderr, and exits 0. + for _, arg := range []string{"--version", "-v"} { + var stdout, stderr bytes.Buffer - code := run([]string{arg}, &stdout, &stderr) - if code != exitOK { - t.Errorf("run(%s) = %d, want %d", arg, code, exitOK) + code := run([]string{arg}, &stdout, &stderr) + if code != exitOK { + t.Errorf("run(%s) = %d, want %d", arg, code, exitOK) + } + + want := "sfdupes " + Version + "\n" + if got := stdout.String(); got != want { + t.Errorf("run(%s) stdout = %q, want %q", arg, got, want) + } + + if got := stderr.String(); got != "" { + t.Errorf("run(%s) stderr = %q, want nothing", arg, got) + } + } +} + +func TestRunVersionWriteFailureIsFatal(t *testing.T) { + t.Parallel() + + // README §Error handling: a stdout write failure exits 1, reported + // in one line on stderr. + var stderr bytes.Buffer + + code := run([]string{"--version"}, failingWriter{}, &stderr) + if code != exitFatal { + t.Errorf("run(--version) = %d, want %d", code, exitFatal) } - if stderr.Len() == 0 { - t.Errorf("run(%s) wrote nothing to stderr", arg) - } - - if got := stdout.String(); got != "" { - t.Errorf("stdout = %q, want nothing (data only)", got) + want := "sfdupes: write stdout: " + errWriteFailed.Error() + "\n" + if got := stderr.String(); got != want { + t.Errorf("stderr = %q, want %q", got, want) } }