Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 46 additions & 5 deletions radar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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, ",") {
Expand Down
109 changes: 109 additions & 0 deletions radar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package main
import (
"archive/zip"
"bytes"
"errors"
"flag"
"io"
"os"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test-radar.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
Loading