diff --git a/Makefile b/Makefile index 33f11490..d2bc14e2 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ timecraft.src.go = \ $(wildcard */*/*.go) \ $(wildcard */*/*/*.go) -timecraft: go.mod flatbuffers $(timecraft.src.go) +timecraft: go.mod $(timecraft.src.go) $(GO) build -o timecraft clean: @@ -32,7 +32,7 @@ generate: flatbuffers flatbuffers: go.mod $(format.src.go) $(GO) build ./format/... -test: timecraft testdata +test: testdata $(GO) test ./... testdata: $(testdata.go.wasm) diff --git a/config.go b/config.go index d6fd56ff..a5abea49 100644 --- a/config.go +++ b/config.go @@ -35,12 +35,6 @@ var ( configPath human.Path = "~/.timecraft/config.yaml" ) -func init() { - if v := os.Getenv("TIMECRAFTCONFIG"); v != "" { - configPath = human.Path(v) - } -} - func config(ctx context.Context, args []string) error { var ( edit bool @@ -50,7 +44,10 @@ func config(ctx context.Context, args []string) error { flagSet := newFlagSet("timecraft config", configUsage) boolVar(flagSet, &edit, "edit") customVar(flagSet, &output, "o", "output") - parseFlags(flagSet, args) + + if _, err := parseFlags(flagSet, args); err != nil { + return err + } r, path, err := openConfig() if err != nil { @@ -259,11 +256,11 @@ func (c *configuration) newRuntime(ctx context.Context) wazero.Runtime { // user but still go ahead with the runtime instantiation. path, err := cachePath.Resolve() if err != nil { - fmt.Fprintf(os.Stderr, "ERR: resolving timecraft cache location: %s\n", err) + perrorf("ERR: resolving timecraft cache location: %s", err) } else { cache, err = createCacheDirectory(path) if err != nil { - fmt.Fprintf(os.Stderr, "ERR: creating timecraft cache directory: %s\n", err) + perrorf("ERR: creating timecraft cache directory: %s", err) } else { config = config.WithCompilationCache(cache) } diff --git a/describe.go b/describe.go index 8873bc62..e73444fd 100644 --- a/describe.go +++ b/describe.go @@ -58,10 +58,14 @@ func describe(ctx context.Context, args []string) error { flagSet := newFlagSet("timecraft describe", describeUsage) customVar(flagSet, &output, "o", "output") - args = parseFlags(flagSet, args) + args, err := parseFlags(flagSet, args) + if err != nil { + return err + } if len(args) == 0 { - return errors.New(`expected a resource type as argument`) + perror(`expected a resource type as argument`) + return exitCode(2) } resource, err := findResource("describe", args[0]) diff --git a/export.go b/export.go index dd9c00ef..5eaf91d2 100644 --- a/export.go +++ b/export.go @@ -28,14 +28,19 @@ Options: func export(ctx context.Context, args []string) error { flagSet := newFlagSet("timecraft export", exportUsage) - args = parseFlags(flagSet, args) + args, err := parseFlags(flagSet, args) + if err != nil { + return err + } if len(args) != 3 { - return usageError(`Expected resource type, id, and output file as argument` + useCmd("export")) + perrorf(`Expected resource type, id, and output file as argument` + useCmd("export")) + return exitCode(2) } resource, err := findResource("describe", args[0]) if err != nil { - return usageError(err.Error()) + perror(err) + return exitCode(2) } config, err := loadConfig() if err != nil { diff --git a/export_test.go b/export_test.go index 32e71899..bc558a03 100644 --- a/export_test.go +++ b/export_test.go @@ -10,53 +10,53 @@ import ( var export = tests{ "show the export command help with the short option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "export", "-h") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "export", "-h") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft export ") assert.Equal(t, stderr, "") }, "show the export command help with the long option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "export", "--help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "export", "--help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft export ") assert.Equal(t, stderr, "") }, "export without a resource type": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "export") - assert.ExitError(t, err, 2) + stdout, stderr, exitCode := timecraft(t, "export") + assert.Equal(t, exitCode, 2) assert.Equal(t, stdout, "") assert.HasPrefix(t, stderr, "Expected resource type, id, and output file as argument") }, "export without a resource id": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "export", "profile") - assert.ExitError(t, err, 2) + stdout, stderr, exitCode := timecraft(t, "export", "profile") + assert.Equal(t, exitCode, 2) assert.Equal(t, stdout, "") assert.HasPrefix(t, stderr, "Expected resource type, id, and output file as argument") }, "export without an output file": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "export", "profile", "74080192e42e") - assert.ExitError(t, err, 2) + stdout, stderr, exitCode := timecraft(t, "export", "profile", "74080192e42e") + assert.Equal(t, exitCode, 2) assert.Equal(t, stdout, "") assert.HasPrefix(t, stderr, "Expected resource type, id, and output file as argument") }, "export a module to stdout": func(t *testing.T) { - stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") - assert.OK(t, err) + stdout, processID, exitCode := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "sleeping for 1ns\n") assert.NotEqual(t, processID, "") - moduleID, stderr, err := timecraft(t, "get", "mod", "-q") - assert.OK(t, err) + moduleID, stderr, exitCode := timecraft(t, "get", "mod", "-q") + assert.Equal(t, exitCode, 0) assert.Equal(t, stderr, "") moduleID = strings.TrimSuffix(moduleID, "\n") - moduleData, stderr, err := timecraft(t, "export", "mod", moduleID, "-") - assert.OK(t, err) + moduleData, stderr, exitCode := timecraft(t, "export", "mod", moduleID, "-") + assert.Equal(t, exitCode, 0) assert.Equal(t, stderr, "") sleepWasm, err := os.ReadFile("./testdata/go/sleep.wasm") diff --git a/get.go b/get.go index d1aa560b..a4561f9a 100644 --- a/get.go +++ b/get.go @@ -124,14 +124,19 @@ func get(ctx context.Context, args []string) error { flagSet := newFlagSet("timecraft get", getUsage) customVar(flagSet, &output, "o", "output") boolVar(flagSet, &quiet, "q", "quiet") - args = parseFlags(flagSet, args) + args, err := parseFlags(flagSet, args) + if err != nil { + return err + } if len(args) != 1 { - return usageError(`Expected exactly one resource type as argument` + useCmd("get")) + perrorf(`Expected exactly one resource type as argument` + useCmd("get")) + return exitCode(2) } resource, err := findResource("get", args[0]) if err != nil { - return usageError(err.Error()) + perror(err) + return exitCode(2) } config, err := loadConfig() if err != nil { diff --git a/get_test.go b/get_test.go index 906c6509..5f431173 100644 --- a/get_test.go +++ b/get_test.go @@ -8,103 +8,103 @@ import ( var get = tests{ "show the get command help with the short option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get", "-h") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "get", "-h") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft get ") assert.Equal(t, stderr, "") }, "show the get command help with the long option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get", "--help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "get", "--help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft get ") assert.Equal(t, stderr, "") }, "get without a resource type": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get") - assert.ExitError(t, err, 2) + stdout, stderr, exitCode := timecraft(t, "get") + assert.Equal(t, exitCode, 2) assert.Equal(t, stdout, "") assert.HasPrefix(t, stderr, "Expected exactly one resource type as argument") }, "get configs on an empty time machine": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get", "configs") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "get", "configs") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "CONFIG ID RUNTIME MODULES SIZE\n") assert.Equal(t, stderr, "") }, "get logs on an empty time machine": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get", "logs") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "get", "logs") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "PROCESS ID SEGMENTS START SIZE\n") assert.Equal(t, stderr, "") }, "get modules on an empty time machine": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get", "modules") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "get", "modules") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "MODULE ID MODULE NAME SIZE\n") assert.Equal(t, stderr, "") }, "get process on an empty time machine": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get", "processes") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "get", "processes") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "PROCESS ID START\n") assert.Equal(t, stderr, "") }, "get runtimes on an empty time machine": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "get", "runtimes") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "get", "runtimes") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "RUNTIME ID RUNTIME NAME VERSION\n") assert.Equal(t, stderr, "") }, "get config after run": func(t *testing.T) { - stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") - assert.OK(t, err) + stdout, processID, exitCode := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "sleeping for 1ns\n") assert.NotEqual(t, processID, "") - configID, stderr, err := timecraft(t, "get", "conf", "-q") - assert.OK(t, err) + configID, stderr, exitCode := timecraft(t, "get", "conf", "-q") + assert.Equal(t, exitCode, 0) assert.NotEqual(t, configID, "") assert.Equal(t, stderr, "") }, "get log after run": func(t *testing.T) { - stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") - assert.OK(t, err) + stdout, processID, exitCode := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "sleeping for 1ns\n") - logID, stderr, err := timecraft(t, "get", "log", "-q") - assert.OK(t, err) + logID, stderr, exitCode := timecraft(t, "get", "log", "-q") + assert.Equal(t, exitCode, 0) assert.Equal(t, logID, processID) assert.Equal(t, stderr, "") }, "get process after run": func(t *testing.T) { - stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") - assert.OK(t, err) + stdout, processID, exitCode := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "sleeping for 1ns\n") - procID, stderr, err := timecraft(t, "get", "proc", "-q") - assert.OK(t, err) + procID, stderr, exitCode := timecraft(t, "get", "proc", "-q") + assert.Equal(t, exitCode, 0) assert.Equal(t, procID, processID) assert.Equal(t, stderr, "") }, "get runtime after run": func(t *testing.T) { - stdout, processID, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") - assert.OK(t, err) + stdout, processID, exitCode := timecraft(t, "run", "./testdata/go/sleep.wasm", "1ns") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "sleeping for 1ns\n") assert.NotEqual(t, processID, "") - runtimeID, stderr, err := timecraft(t, "get", "rt", "-q") - assert.OK(t, err) + runtimeID, stderr, exitCode := timecraft(t, "get", "rt", "-q") + assert.Equal(t, exitCode, 0) assert.NotEqual(t, runtimeID, "") assert.Equal(t, stderr, "") }, diff --git a/help.go b/help.go index 8d998675..7c407515 100644 --- a/help.go +++ b/help.go @@ -34,7 +34,11 @@ For a description of each command, run 'timecraft help '.` func help(ctx context.Context, args []string) error { flagSet := newFlagSet("timecraft help", helpUsage) - args = parseFlags(flagSet, args) + + args, err := parseFlags(flagSet, args) + if err != nil { + return err + } if len(args) == 0 { args = []string{"help"} } @@ -66,7 +70,8 @@ func help(ctx context.Context, args []string) error { case "version": msg = versionUsage default: - return usageError("timecraft help %s: unknown command", cmd) + perrorf("timecraft help %s: unknown command", cmd) + return exitCode(2) } fmt.Println(strings.TrimSpace(msg)) diff --git a/help_test.go b/help_test.go index a75de9c7..3bad7cab 100644 --- a/help_test.go +++ b/help_test.go @@ -8,97 +8,97 @@ import ( var help = tests{ "calling help with an unknown command causes an error": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "whatever") - assert.ExitError(t, err, 2) + stdout, stderr, exitCode := timecraft(t, "help", "whatever") + assert.Equal(t, exitCode, 2) assert.Equal(t, stdout, "") assert.Equal(t, stderr, "timecraft help whatever: unknown command\n") }, "passing an unsupported flag to the command causes an error": func(t *testing.T) { - _, _, err := timecraft(t, "help", "-_") - assert.ExitError(t, err, 2) + _, _, exitCode := timecraft(t, "help", "-_") + assert.Equal(t, exitCode, 2) }, "show the help command help with the short option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "-h") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "-h") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") assert.Equal(t, stderr, "") }, "show the help command help with the long option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "--help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "--help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") assert.Equal(t, stderr, "") }, "show the help command help after a command name": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "get", "--help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "get", "--help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") assert.Equal(t, stderr, "") }, "timecraft help config": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "config") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "config") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft config ") assert.Equal(t, stderr, "") }, "timecraft help describe": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "describe") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "describe") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft describe ") assert.Equal(t, stderr, "") }, "timecraft help export": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "export") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "export") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft export ") assert.Equal(t, stderr, "") }, "timecraft help get": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "get") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "get") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft get ") assert.Equal(t, stderr, "") }, "timecraft help help": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") assert.Equal(t, stderr, "") }, "timecraft help profile": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "profile") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "profile") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft profile ") assert.Equal(t, stderr, "") }, "timecraft help run": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "run") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "run") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft run ") assert.Equal(t, stderr, "") }, "timecraft help replay": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "replay") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "replay") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft replay ") assert.Equal(t, stderr, "") }, "timecraft help version": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "help", "version") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "help", "version") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft version\n") assert.Equal(t, stderr, "") }, diff --git a/main.go b/main.go index 9792558a..94bca2df 100644 --- a/main.go +++ b/main.go @@ -13,5 +13,5 @@ func init() { } func main() { - os.Exit(root(context.Background(), os.Args[1:]...)) + os.Exit(Root(context.Background(), os.Args[1:]...)) } diff --git a/main_test.go b/main_test.go index 0649e2db..93c654a8 100644 --- a/main_test.go +++ b/main_test.go @@ -4,11 +4,12 @@ import ( "bytes" "context" "os" - "os/exec" "path/filepath" + "strings" "sync" "testing" + main "github.com/stealthrocket/timecraft" "golang.org/x/exp/maps" "golang.org/x/exp/slices" "gopkg.in/yaml.v3" @@ -71,7 +72,7 @@ func (suite tests) run(t *testing.T) { } } -func timecraft(t *testing.T, args ...string) (stdout, stderr string, err error) { +func timecraft(t *testing.T, args ...string) (stdout, stderr string, exitCode int) { ctx := context.Background() deadline, ok := t.Deadline() if ok { @@ -81,16 +82,53 @@ func timecraft(t *testing.T, args ...string) (stdout, stderr string, err error) } outbuf := acquireBuffer() - errbuf := acquireBuffer() defer releaseBuffer(outbuf) + + errbuf := acquireBuffer() defer releaseBuffer(errbuf) - cmd := exec.CommandContext(ctx, "./timecraft", args...) - cmd.Stdout = outbuf - cmd.Stderr = errbuf + wg := sync.WaitGroup{} + defer wg.Wait() + + defaultStdout := os.Stdout + defaultStderr := os.Stderr + defer func() { + os.Stdout = defaultStdout + os.Stderr = defaultStderr + }() + + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer stdoutR.Close() + defer stdoutW.Close() + + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer stderrR.Close() + defer stderrW.Close() + + wg.Add(2) + go func() { defer wg.Done(); _, _ = outbuf.ReadFrom(stdoutR) }() + go func() { defer wg.Done(); _, _ = errbuf.ReadFrom(stderrR) }() + + os.Stdout = stdoutW + os.Stderr = stderrW + + exitCode = main.Root(ctx, args...) + stdoutW.Close() + stderrW.Close() + wg.Wait() + + stdout = outbuf.String() + stdout = strings.TrimPrefix(stdout, "\n") - err = cmd.Run() - return outbuf.String(), errbuf.String(), err + stderr = errbuf.String() + stderr = strings.TrimPrefix(stderr, "\n") + return } var buffers sync.Pool diff --git a/profile.go b/profile.go index 9e04883e..98656109 100644 --- a/profile.go +++ b/profile.go @@ -70,8 +70,11 @@ func profile(ctx context.Context, args []string) error { customVar(flagSet, &duration, "d", "duration") customVar(flagSet, &startTime, "t", "start-time") boolVar(flagSet, &quiet, "q", "quiet") - args = parseFlags(flagSet, args) + args, err := parseFlags(flagSet, args) + if err != nil { + return err + } if len(args) != 1 { return errors.New(`expected exactly one process id as argument`) } @@ -191,7 +194,7 @@ func profile(ctx context.Context, args []string) error { } if p != nil { path := exports[typ] - fmt.Fprintf(os.Stderr, "==> writing %s profile to %s\n", typ, path) + perrorf("==> writing %s profile to %s", typ, path) if err := wzprof.WriteProfile(path, p); err != nil { return err } diff --git a/replay.go b/replay.go index ad196846..b49db6d3 100644 --- a/replay.go +++ b/replay.go @@ -31,8 +31,11 @@ func replay(ctx context.Context, args []string) error { flagSet := newFlagSet("timecraft replay", replayUsage) boolVar(flagSet, &trace, "T", "trace") - args = parseFlags(flagSet, args) + args, err := parseFlags(flagSet, args) + if err != nil { + return err + } if len(args) != 1 { return errors.New(`expected exactly one process id as argument`) } diff --git a/root.go b/root.go index c13eb2fd..d13e5a75 100644 --- a/root.go +++ b/root.go @@ -18,6 +18,7 @@ package main import ( "context" + "errors" "flag" "fmt" _ "net/http/pprof" @@ -48,8 +49,12 @@ Example: For a list of commands available, run 'timecraft help'.` -// root is the timecraft entrypoint. -func root(ctx context.Context, args ...string) int { +// Root is the timecraft entrypoint. +func Root(ctx context.Context, args ...string) int { + if v := os.Getenv("TIMECRAFTCONFIG"); v != "" { + configPath = human.Path(v) + } + var ( // Secret options, we don't document them since they are only used for // development. Since they are not part of the public interface we may @@ -61,7 +66,13 @@ func root(ctx context.Context, args ...string) int { flagSet := newFlagSet("timecraft", helpUsage) customVar(flagSet, &cpuProfile, "cpuprofile") customVar(flagSet, &memProfile, "memprofile") - _ = flagSet.Parse(args) + + if err := flagSet.Parse(args); err != nil { + if !errors.Is(err, flag.ErrHelp) { + fmt.Println(err) + } + return 0 + } if args = flagSet.Args(); len(args) == 0 { fmt.Println(rootUsage) @@ -72,7 +83,7 @@ func root(ctx context.Context, args ...string) int { path, _ := cpuProfile.Resolve() f, err := os.Create(path) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: could not create CPU profile: %s\n", err) + perrorf("WARN: could not create CPU profile: %s", err) } else { defer f.Close() _ = pprof.StartCPUProfile(f) @@ -85,7 +96,7 @@ func root(ctx context.Context, args ...string) int { defer func() { f, err := os.Create(path) if err != nil { - fmt.Fprintf(os.Stderr, "WARN: could not create memory profile: %s\n", err) + perrorf("WARN: could not create memory profile: %s", err) } defer f.Close() runtime.GC() @@ -118,16 +129,17 @@ func root(ctx context.Context, args ...string) int { err = unknown(ctx, cmd) } + if errors.Is(err, flag.ErrHelp) { + return 0 + } + switch e := err.(type) { case nil: return 0 case exitCode: return int(e) - case usage: - fmt.Fprintf(os.Stderr, "%s\n", e) - return 2 default: - fmt.Fprintf(os.Stderr, "ERR: timecraft %s: %s\n", cmd, err) + perrorf("ERR: timecraft %s: %s", cmd, err) return 1 } } @@ -140,20 +152,6 @@ func (e exitCode) Error() string { return fmt.Sprintf("exit: %d", e) } -// usage is an error type returned from command functions to indicate a usage -// error. -// -// Usage erors cause the program to exist with status code 2. -type usage string - -func usageError(msg string, args ...any) error { - return usage(fmt.Sprintf(msg, args...)) -} - -func (e usage) Error() string { - return string(e) -} - func setEnum[T ~string](enum *T, typ string, value string, options ...string) error { for _, option := range options { if option == value { @@ -230,9 +228,16 @@ func (m stringMap) Set(value string) error { return nil } +func perror(args ...any) { + fmt.Fprintln(os.Stderr, args...) +} + +func perrorf(msg string, args ...any) { + fmt.Fprintf(os.Stderr, msg+"\n", args...) +} + func newFlagSet(cmd, usage string) *flag.FlagSet { - usage = strings.TrimSpace(usage) - flagSet := flag.NewFlagSet(cmd, flag.ExitOnError) + flagSet := flag.NewFlagSet(cmd, flag.ContinueOnError) flagSet.Usage = func() { fmt.Println(usage) } customVar(flagSet, &configPath, "c", "config") return flagSet @@ -240,15 +245,17 @@ func newFlagSet(cmd, usage string) *flag.FlagSet { // parseFlags is a greedy parser which consumes all options known to f and // returns the remaining arguments. -func parseFlags(f *flag.FlagSet, args []string) []string { - var unknownArgs []string +func parseFlags(f *flag.FlagSet, args []string) ([]string, error) { + var vals []string for { - // The flag set is constructed with ExitOnError, it should never error. if err := f.Parse(args); err != nil { - panic(err) + if errors.Is(err, flag.ErrHelp) { + return nil, err + } + return nil, exitCode(2) } if args = f.Args(); len(args) == 0 { - return unknownArgs + return vals, nil } i := slices.IndexFunc(args, func(s string) bool { return strings.HasPrefix(s, "-") @@ -261,7 +268,7 @@ func parseFlags(f *flag.FlagSet, args []string) []string { if i == 0 { panic("parsing command line arguments did not error on " + args[0]) } - unknownArgs = append(unknownArgs, args[:i]...) + vals = append(vals, args[:i]...) args = args[i:] } } diff --git a/root_test.go b/root_test.go index e3fb7485..70585dd2 100644 --- a/root_test.go +++ b/root_test.go @@ -8,22 +8,22 @@ import ( var root = tests{ "invoking timecraft without a command prints the introduction message": func(t *testing.T) { - stdout, stderr, err := timecraft(t) - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t) + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "timecraft - WebAssembly Time Machine\n") assert.Equal(t, stderr, "") }, "show the timecraft help with the short option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "-h") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "-h") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") assert.Equal(t, stderr, "") }, "show the timecraft help with the long option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "--help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "--help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft ") assert.Equal(t, stderr, "") }, diff --git a/run.go b/run.go index 688bfb76..fa08402b 100644 --- a/run.go +++ b/run.go @@ -59,7 +59,10 @@ func run(ctx context.Context, args []string) error { boolVar(flagSet, &flyBlind, "fly-blind") customVar(flagSet, &batchSize, "record-batch-size") customVar(flagSet, &compression, "record-compression") - _ = flagSet.Parse(args) + + if err := flagSet.Parse(args); err != nil { + return err + } envs = append(os.Environ(), envs...) args = flagSet.Args() diff --git a/run_test.go b/run_test.go index f487fd50..6235c3a7 100644 --- a/run_test.go +++ b/run_test.go @@ -9,15 +9,15 @@ import ( var run = tests{ "show the run command help with the short option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "run", "-h") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "run", "-h") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft run ") assert.Equal(t, stderr, "") }, "show the run command help with the long option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "run", "--help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "run", "--help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft run ") assert.Equal(t, stderr, "") }, @@ -25,8 +25,8 @@ var run = tests{ "running with a configuration file which does not exist uses the default location": func(t *testing.T) { t.Setenv("TIMECRAFTCONFIG", filepath.Join(t.TempDir(), "path", "to", "nowehere.yaml")) - stdout, stderr, err := timecraft(t, "run", "./testdata/go/sleep.wasm", "0") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "run", "./testdata/go/sleep.wasm", "0") + assert.Equal(t, exitCode, 0) assert.Equal(t, stdout, "sleeping for 0s\n") assert.NotEqual(t, stderr, "") }, diff --git a/unknown.go b/unknown.go index 8926521f..a4cacca2 100644 --- a/unknown.go +++ b/unknown.go @@ -9,5 +9,6 @@ For a list of commands available, run 'timecraft help.' ` func unknown(ctx context.Context, cmd string) error { - return usageError(unknownCommand, cmd) + perrorf(unknownCommand, cmd) + return exitCode(2) } diff --git a/unknown_test.go b/unknown_test.go index 8f247430..259bc36e 100644 --- a/unknown_test.go +++ b/unknown_test.go @@ -8,8 +8,8 @@ import ( var unknown = tests{ "an error is reported when invoking an unknown command": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "whatever") - assert.ExitError(t, err, 2) + stdout, stderr, exitCode := timecraft(t, "whatever") + assert.Equal(t, exitCode, 2) assert.Equal(t, stdout, "") assert.HasPrefix(t, stderr, "timecraft whatever: unknown command\n") }, diff --git a/version.go b/version.go index 2ee44cf8..f0b0dd0d 100644 --- a/version.go +++ b/version.go @@ -15,7 +15,9 @@ Options: func version(ctx context.Context, args []string) error { flagSet := newFlagSet("timecraft version", versionUsage) - parseFlags(flagSet, args) + if _, err := parseFlags(flagSet, args); err != nil { + return err + } fmt.Printf("timecraft %s\n", currentVersion()) return nil } diff --git a/version_test.go b/version_test.go index b46329df..73b2507c 100644 --- a/version_test.go +++ b/version_test.go @@ -9,29 +9,29 @@ import ( var version = tests{ "show the version command help with the short option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "version", "-h") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "version", "-h") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft version\n") assert.Equal(t, stderr, "") }, "show the version command help with the long option": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "version", "--help") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "version", "--help") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "Usage:\ttimecraft version\n") assert.Equal(t, stderr, "") }, "the version starts with the prefix timecraft": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "version") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "version") + assert.Equal(t, exitCode, 0) assert.HasPrefix(t, stdout, "timecraft ") assert.Equal(t, stderr, "") }, "the version number is not empty": func(t *testing.T) { - stdout, stderr, err := timecraft(t, "version") - assert.OK(t, err) + stdout, stderr, exitCode := timecraft(t, "version") + assert.Equal(t, exitCode, 0) assert.Equal(t, stderr, "") _, version, _ := strings.Cut(string(stdout), " ") @@ -39,7 +39,7 @@ var version = tests{ }, "passing an unsupported flag to the command causes an error": func(t *testing.T) { - _, _, err := timecraft(t, "version", "-_") - assert.ExitError(t, err, 2) + _, _, exitCode := timecraft(t, "version", "-_") + assert.Equal(t, exitCode, 2) }, }