From 82277f84bd2b9afc817b89338897ef1e4144c222 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 13 Nov 2026 18:59:08 +0000 Subject: [PATCH] Print help that was asked for to stdout (closes #179) --help, -h and the help subcommand now print to stdout and exit 0, so the help text can be paged and searched. The usage text of a usage error stays on stderr with exit 2. Cobra prints both through the command's output writer, which stays stderr; the help function points it at stdout before printing. README "Settings" and goal 4 say so, and the help tests read stdout. Model: opus-5-5 --- README.md | 14 ++++++---- internal/sfdupes/cli.go | 18 ++++++++++-- internal/sfdupes/cli_test.go | 48 ++++++++++++++++++-------------- internal/sfdupes/dwim_test.go | 14 +++++----- internal/sfdupes/exclude_test.go | 4 +-- 5 files changed, 59 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 354a9c2..9b778a5 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ In order: time, against what the database holds. 4. **Keep the streams apart.** Everything on stdout is machine-readable data. Everything for a human reader, such as progress and warnings, goes to stderr. + The one exception is help that was asked for, which goes to stdout. ### Subcommands @@ -324,12 +325,13 @@ The reference below is also the program's specification. "Operands"). - `--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. 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` and `sfdupes dwim --help` also list the - default excludes of the system they run on, and the filesystem types the walk - never records anything on. +- `--help`, `-h`, alone or after a subcommand, prints the help text to stdout + and exits 0. It writes nothing to stderr. `sfdupes help` and + `sfdupes help COMMAND` do the same. 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` and `sfdupes dwim --help` also list the default + excludes of the system they run on, and the filesystem types the walk never + records anything on. ### Duplicate detection diff --git a/internal/sfdupes/cli.go b/internal/sfdupes/cli.go index d994c21..5497731 100644 --- a/internal/sfdupes/cli.go +++ b/internal/sfdupes/cli.go @@ -201,8 +201,9 @@ func run(args []string, stdout, stderr io.Writer) int { } // newRootCommand builds the command tree. Everything on stdout is -// machine-readable data, the version line included; all human-facing -// output (help, usage, errors) goes to stderr. +// machine-readable data, the version line included, or help that was +// asked for. The rest of the human-facing output (usage, errors) goes +// to stderr. func newRootCommand(stdout, stderr io.Writer) *cobra.Command { var showVersion bool @@ -235,11 +236,22 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command { return errNoSubcommand }, } + // Cobra prints the usage text of a usage error to the command's + // output writer, so that writer is stderr. root.SetOut(stderr) root.SetErr(stderr) root.CompletionOptions.DisableDefaultCmd = true - // Cobra's built-in version flag prints through the help writer, + // Cobra's default help also prints to the output writer. Help that + // was asked for, with --help, -h or the help subcommand, goes to + // stdout instead. + printHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + cmd.SetOut(stdout) + printHelp(cmd, args) + }) + + // Cobra's built-in version flag prints to the output writer, // stderr; this one prints to stdout. root.Flags().BoolVarP(&showVersion, "version", "v", false, "print the version to stdout") diff --git a/internal/sfdupes/cli_test.go b/internal/sfdupes/cli_test.go index cb7c653..28595f7 100644 --- a/internal/sfdupes/cli_test.go +++ b/internal/sfdupes/cli_test.go @@ -26,6 +26,9 @@ const usageMarker = "Usage:" // helpFlag asks for the help text. const helpFlag = "--help" +// helpCommand is the subcommand that prints the help text. +const helpCommand = "help" + // workersFlag is the scan and dwim flag that sets the worker count. const workersFlag = "--workers" @@ -700,9 +703,12 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) { func TestRunHelp(t *testing.T) { t.Parallel() - // README §Settings: help goes to stderr, exits 0, and leaves stdout + // README §Settings: help goes to stdout, exits 0, and leaves stderr // empty. - cases := [][]string{{helpFlag}, {"-h"}, {cmdScan, helpFlag}} + cases := [][]string{ + {helpFlag}, {"-h"}, {helpCommand}, + {cmdScan, helpFlag}, {helpCommand, cmdScan}, + } for _, args := range cases { var stdout, stderr bytes.Buffer @@ -712,14 +718,13 @@ func TestRunHelp(t *testing.T) { 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 !strings.Contains(stdout.String(), usageMarker) { + t.Errorf("run(%v) stdout = %q, want the help text", + args, stdout.String()) } - if got := stdout.String(); got != "" { - t.Errorf("run(%v) stdout = %q, want nothing (data only)", - args, got) + if got := stderr.String(); got != "" { + t.Errorf("run(%v) stderr = %q, want nothing", args, got) } // README §Location: the help of sfdupes and of scan says where @@ -729,9 +734,9 @@ func TestRunHelp(t *testing.T) { "scan and dwim store", databaseEnv, rootDatabasePath, stateHomeEnv, "~/.local/state", "not an absolute path is an error", } { - if !strings.Contains(stderr.String(), want) { - t.Errorf("run(%v) stderr = %q, want it to name %q", - args, stderr.String(), want) + if !strings.Contains(stdout.String(), want) { + t.Errorf("run(%v) stdout = %q, want it to name %q", + args, stdout.String(), want) } } } @@ -741,8 +746,8 @@ func TestRunHelp(t *testing.T) { run([]string{cmdScan, "-h"}, &stdout, &stderr) - if !strings.Contains(stderr.String(), excludeFlag+" PATH") { - t.Errorf("scan -h stderr = %q, want %s PATH", stderr.String(), + if !strings.Contains(stdout.String(), excludeFlag+" PATH") { + t.Errorf("scan -h stdout = %q, want %s PATH", stdout.String(), excludeFlag) } } @@ -751,7 +756,8 @@ func TestRunScanUsageLine(t *testing.T) { t.Parallel() // README §Subcommands shows this usage line for scan, and the help - // and the usage error both print it. + // and the usage error both print it. TestRunHelp and + // TestRunUsageErrors check which stream each goes to. const want = "\n sfdupes scan [--workers N] [-x] [--no-default-excludes] " + "[--exclude PATH]... PATH...\n" @@ -764,13 +770,13 @@ func TestRunScanUsageLine(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - var stdout, stderr bytes.Buffer + var output bytes.Buffer - run(args, &stdout, &stderr) + run(args, &output, &output) - if !strings.Contains(stderr.String(), want) { - t.Errorf("run(%v) stderr = %q, want the usage line %q", - args, stderr.String(), want) + if !strings.Contains(output.String(), want) { + t.Errorf("run(%v) output = %q, want the usage line %q", + args, output.String(), want) } }) } @@ -836,9 +842,9 @@ func TestRunHelpDescribesOutput(t *testing.T) { } for _, want := range tc.want { - if !strings.Contains(stderr.String(), want) { + if !strings.Contains(stdout.String(), want) { t.Errorf("run(%v) help lacks %q:\n%s", - args, want, stderr.String()) + args, want, stdout.String()) } } } diff --git a/internal/sfdupes/dwim_test.go b/internal/sfdupes/dwim_test.go index d903c45..acca2fd 100644 --- a/internal/sfdupes/dwim_test.go +++ b/internal/sfdupes/dwim_test.go @@ -190,21 +190,21 @@ func TestRunDwimHelpListsScanFlags(t *testing.T) { run([]string{cmdDwim, "-h"}, &stdout, &stderr) - if !strings.Contains(stderr.String(), want) { - t.Errorf("dwim -h stderr = %q, want the usage line %q", - stderr.String(), want) + if !strings.Contains(stdout.String(), want) { + t.Errorf("dwim -h stdout = %q, want the usage line %q", + stdout.String(), want) } - if !strings.Contains(stderr.String(), excludeFlag+" PATH") { - t.Errorf("dwim -h stderr = %q, want %s PATH", stderr.String(), + if !strings.Contains(stdout.String(), excludeFlag+" PATH") { + t.Errorf("dwim -h stdout = %q, want %s PATH", stdout.String(), excludeFlag) } // README "Settings": the help lists the default excludes and the // filesystem types the walk never records anything on. help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS)) - if !strings.Contains(stderr.String(), help) { - t.Errorf("dwim -h stderr = %q, want it to hold %q", stderr.String(), + if !strings.Contains(stdout.String(), help) { + t.Errorf("dwim -h stdout = %q, want it to hold %q", stdout.String(), help) } } diff --git a/internal/sfdupes/exclude_test.go b/internal/sfdupes/exclude_test.go index 062950e..afbd0f9 100644 --- a/internal/sfdupes/exclude_test.go +++ b/internal/sfdupes/exclude_test.go @@ -196,8 +196,8 @@ func TestDefaultExcludesHelp(t *testing.T) { run([]string{cmdScan, "-h"}, &stdout, &stderr) help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS)) - if !strings.Contains(stderr.String(), help) { - t.Errorf("scan --help = %q, want it to hold %q", stderr.String(), help) + if !strings.Contains(stdout.String(), help) { + t.Errorf("scan --help = %q, want it to hold %q", stdout.String(), help) } }