diff --git a/README.md b/README.md index 211a62f..333d695 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ The tool does **not** collect: passwords, query result data, table contents, or ``` Usage: radar [options] +GLOBAL OPTIONS: + --help show help + --version, -V print the version + Options: -U string database user diff --git a/docs/index.md b/docs/index.md index 4a95cc4..d93145f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -89,6 +89,10 @@ The tool does **not** collect: passwords, query result data, table contents, or ``` Usage: radar [options] +GLOBAL OPTIONS: + --help show help + --version, -V print the version + Options: -U string database user diff --git a/radar.go b/radar.go index 74bffe6..832d983 100644 --- a/radar.go +++ b/radar.go @@ -34,6 +34,32 @@ import ( // Release builds set it to e.g. "v0.5.0"; unstamped dev builds report "dev". var version = "dev" +// errHelpRequested and errVersionRequested are returned by parseConfig when +// -help or -version was given instead of a collection request. main prints the +// corresponding output and exits successfully. +var ( + errHelpRequested = errors.New("help requested") + errVersionRequested = errors.New("version requested") +) + +// printUsage writes the help text: the usage line, the global options, then +// the collection options from the registered flag set. It carries nothing +// build-dependent, because it is pasted verbatim into README.md and +// docs/index.md. The write error is discarded because flag.PrintDefaults, +// which emits the rest of the same output, discards its own. +func printUsage(w io.Writer) { + _, _ = fmt.Fprint(w, `Usage: radar [options] + +GLOBAL OPTIONS: + --help show help + --version, -V print the version + +Options: +`) + flag.CommandLine.SetOutput(w) + flag.PrintDefaults() +} + // defaultDisabledTasks lists task names not run unless -include lists them. // pgstattuple_approx() reads heap pages of every user table. var defaultDisabledTasks = []string{"pgstattuple"} @@ -248,7 +274,14 @@ var ( // main is the radar entry point. func main() { cfg, err := parseConfig() - if err != nil { + switch { + case errors.Is(err, errHelpRequested): + printUsage(os.Stdout) + return + case errors.Is(err, errVersionRequested): + fmt.Printf("radar version %s\n", version) + return + case err != nil: errorLog.Println(err) flag.Usage() os.Exit(ExitUsageError) @@ -328,10 +361,7 @@ func main() { func parseConfig() (*Config, error) { cfg := &Config{} - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: radar [options]\n\nOptions:\n") - flag.PrintDefaults() - } + flag.Usage = func() { printUsage(os.Stderr) } flag.StringVar(&cfg.Host, "h", "", "database host") flag.IntVar(&cfg.Port, "p", DefaultPostgresPort, "database port") @@ -350,6 +380,17 @@ func parseConfig() (*Config, error) { flag.StringVar(&includeRaw, "include", "", "comma-separated default-disabled task names to enable (e.g. pgstattuple, disabled by default)") flag.BoolVar(&cfg.Verbose, "v", false, "verbose output (summary)") flag.BoolVar(&cfg.VeryVerbose, "vv", false, "very verbose output (detailed)") + + // Read after registration so printUsage can list the options above, and + // before parsing so that neither a collection flag nor a typo elsewhere + // stops radar answering. Help outranks version when both are given. + switch args := os.Args[1:]; { + case slices.Contains(args, "-help"), slices.Contains(args, "--help"): + return nil, errHelpRequested + case slices.Contains(args, "-version"), slices.Contains(args, "--version"), slices.Contains(args, "-V"): + return nil, errVersionRequested + } + flag.Parse() for _, raw := range strings.Split(excludeRaw, ",") { diff --git a/radar_test.go b/radar_test.go index 7180012..ec5493b 100644 --- a/radar_test.go +++ b/radar_test.go @@ -13,6 +13,7 @@ package main import ( "archive/zip" "bytes" + "errors" "flag" "io" "os" @@ -937,6 +938,114 @@ func TestLazyZipWriterNoWrite(t *testing.T) { } } +// TestGlobalOptions tests that -help and -version are answered whatever else +// is on the command line, that no other flag takes effect when they are, and +// that -help outranks -version when both are given. +func TestGlobalOptions(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + tests := []struct { + name string + args []string + want error + check func(*testing.T, *Config) + }{ + {name: "--help", args: []string{"radar", "--help"}, want: errHelpRequested}, + {name: "-help", args: []string{"radar", "-help"}, want: errHelpRequested}, + {name: "--version", args: []string{"radar", "--version"}, want: errVersionRequested}, + {name: "-version", args: []string{"radar", "-version"}, want: errVersionRequested}, + {name: "-V", args: []string{"radar", "-V"}, want: errVersionRequested}, + {name: "help outranks version", args: []string{"radar", "--version", "--help"}, want: errHelpRequested}, + {name: "help outranks version whatever the order", args: []string{"radar", "--help", "--version"}, want: errHelpRequested}, + {name: "help outranks an unparseable flag", args: []string{"radar", "-nosuchflag", "--help"}, want: errHelpRequested}, + {name: "version outranks an unparseable flag", args: []string{"radar", "-nosuchflag", "-V"}, want: errVersionRequested}, + {name: "help outranks a collection flag", args: []string{"radar", "-d", "testdb", "--help"}, want: errHelpRequested}, + {name: "neither given", args: []string{"radar", "-d", "testdb"}}, + { + name: "-h is the database host", + args: []string{"radar", "-h", "localhost"}, + check: func(t *testing.T, cfg *Config) { + if cfg.Host != "localhost" { + t.Errorf("host = %q, want %q", cfg.Host, "localhost") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = tt.args + + cfg, err := parseConfig() + if !errors.Is(err, tt.want) { + t.Fatalf("parseConfig() error = %v, want %v", err, tt.want) + } + if tt.want != nil { + if cfg != nil { + t.Errorf("expected no config alongside %v, got %+v", tt.want, cfg) + } + return + } + if cfg == nil { + t.Fatal("expected a config when neither option was given") + } + if tt.check != nil { + tt.check(t, cfg) + } + }) + } +} + +// TestPrintUsage tests that the help text lists the global options above the +// collection flags. It must carry nothing build-dependent, because it is +// pasted verbatim into README.md and docs/index.md. +func TestPrintUsage(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + flag.CommandLine = flag.NewFlagSet("radar", flag.ContinueOnError) + os.Args = []string{"radar", "--help"} + if _, err := parseConfig(); !errors.Is(err, errHelpRequested) { + t.Fatalf("parseConfig() error = %v, want errHelpRequested", err) + } + + var buf bytes.Buffer + printUsage(&buf) + got := buf.String() + + want := []string{ + "Usage: radar [options]", + "GLOBAL OPTIONS:", + "--help show help", + "--version, -V print the version", + "Options:", + "-sslmode string", + "database host", + } + for _, w := range want { + if !strings.Contains(got, w) { + t.Errorf("usage output missing %q\ngot:\n%s", w, got) + } + } + + if strings.Contains(got, version) { + t.Errorf("usage output must not carry the build version\ngot:\n%s", got) + } + + // The global options are answered before parsing, so registering them + // would list a second, unreachable copy among the collection flags. + for _, name := range []string{"help", "version", "V"} { + if flag.CommandLine.Lookup(name) != nil { + t.Errorf("-%s must not be a registered flag", name) + } + } + if f := flag.CommandLine.Lookup("h"); f == nil || f.Usage != "database host" { + t.Error("-h must stay bound to the database host") + } +} + // TestPGEnvFallbacks tests PGPORT and PGDATABASE environment variable fallbacks. func TestPGEnvFallbacks(t *testing.T) { oldArgs := os.Args diff --git a/test-radar.sh b/test-radar.sh index 0bff0eb..37664bd 100755 --- a/test-radar.sh +++ b/test-radar.sh @@ -18,6 +18,8 @@ NC='\033[0m' # Binary is pre-built by run-ci-local.sh and copied into container by Dockerfile echo "Using pre-built radar binary..." ./radar --help > /dev/null 2>&1 || { echo -e "${RED}✗ radar binary not found or not executable${NC}"; exit 1; } +./radar --help | grep -q '^Usage: radar \[options\]$' || { echo -e "${RED}✗ radar --help did not write usage to stdout${NC}"; exit 1; } +./radar --version | grep -q '^radar version ' || { echo -e "${RED}✗ radar --version did not report a version${NC}"; exit 1; } echo "" echo "Initializing PostgreSQL 18..."