diff --git a/error.go b/error.go index 18225ef..33a4e14 100644 --- a/error.go +++ b/error.go @@ -10,3 +10,6 @@ type ParseError struct { // Unwrap returns the original cause of the error. func (p *ParseError) Unwrap() error { return p.error } + +// ExitCode returns the status that Kong should exit with if it fails with a ParseError. +func (p *ParseError) ExitCode() int { return exitUsageError } diff --git a/exit.go b/exit.go new file mode 100644 index 0000000..4925f48 --- /dev/null +++ b/exit.go @@ -0,0 +1,32 @@ +package kong + +import "errors" + +const ( + exitOk = 0 + exitNotOk = 1 + + // Semantic exit codes from https://github.com/square/exit?tab=readme-ov-file#about + exitUsageError = 80 +) + +// ExitCoder is an interface that may be implemented by an error value to +// provide an integer exit code. The method ExitCode should return an integer +// that is intended to be used as the exit code for the application. +type ExitCoder interface { + ExitCode() int +} + +// exitCodeFromError returns the exit code for the given error. +// If err implements the exitCoder interface, the ExitCode method is called. +// Otherwise, exitCodeFromError returns 0 if err is nil, and 1 if it is not. +func exitCodeFromError(err error) int { + var e ExitCoder + if errors.As(err, &e) { + return e.ExitCode() + } else if err == nil { + return exitOk + } + + return exitNotOk +} diff --git a/help_test.go b/help_test.go index ccbfb63..9162d60 100644 --- a/help_test.go +++ b/help_test.go @@ -786,10 +786,11 @@ func TestUsageOnError(t *testing.T) { Flag string `help:"A required flag." required` } w := &strings.Builder{} + exitCode := -1 p := mustNew(t, &cli, kong.Writers(w, w), kong.Description("Some description."), - kong.Exit(func(int) {}), + kong.Exit(func(code int) { exitCode = code }), kong.UsageOnError(), ) _, err := p.Parse([]string{}) @@ -806,6 +807,7 @@ Flags: test: error: missing flags: --flag=STRING ` assert.Equal(t, expected, w.String()) + assert.Equal(t, 80, exitCode) } func TestShortUsageOnError(t *testing.T) { @@ -813,10 +815,11 @@ func TestShortUsageOnError(t *testing.T) { Flag string `help:"A required flag." required` } w := &strings.Builder{} + exitCode := -1 p := mustNew(t, &cli, kong.Writers(w, w), kong.Description("Some description."), - kong.Exit(func(int) {}), + kong.Exit(func(code int) { exitCode = code }), kong.ShortUsageOnError(), ) _, err := p.Parse([]string{}) @@ -829,6 +832,7 @@ Run "test --help" for more information. test: error: missing flags: --flag=STRING ` assert.Equal(t, expected, w.String()) + assert.Equal(t, 80, exitCode) } func TestCustomShortUsageOnError(t *testing.T) { @@ -840,10 +844,11 @@ func TestCustomShortUsageOnError(t *testing.T) { fmt.Fprintln(ctx.Stdout, "🤷 wish I could help") return nil } + exitCode := -1 p := mustNew(t, &cli, kong.Writers(w, w), kong.Description("Some description."), - kong.Exit(func(int) {}), + kong.Exit(func(code int) { exitCode = code }), kong.ShortHelp(shortHelp), kong.ShortUsageOnError(), ) @@ -856,4 +861,5 @@ func TestCustomShortUsageOnError(t *testing.T) { test: error: missing flags: --flag=STRING ` assert.Equal(t, expected, w.String()) + assert.Equal(t, 80, exitCode) } diff --git a/kong.go b/kong.go index a5e3d99..e000553 100644 --- a/kong.go +++ b/kong.go @@ -311,35 +311,35 @@ func (k *Kong) extraFlags() []*Flag { // invalid one, which will report a normal error). func (k *Kong) Parse(args []string) (ctx *Context, err error) { ctx, err = Trace(k, args) - if err != nil { + if err != nil { // Trace is not expected to return an err return nil, err } if ctx.Error != nil { return nil, &ParseError{error: ctx.Error, Context: ctx} } if err = k.applyHook(ctx, "BeforeReset"); err != nil { - return nil, &ParseError{error: err, Context: ctx} + return nil, err } if err = ctx.Reset(); err != nil { return nil, &ParseError{error: err, Context: ctx} } if err = k.applyHook(ctx, "BeforeResolve"); err != nil { - return nil, &ParseError{error: err, Context: ctx} + return nil, err } if err = ctx.Resolve(); err != nil { return nil, &ParseError{error: err, Context: ctx} } if err = k.applyHook(ctx, "BeforeApply"); err != nil { - return nil, &ParseError{error: err, Context: ctx} + return nil, err } - if _, err = ctx.Apply(); err != nil { - return nil, &ParseError{error: err, Context: ctx} + if _, err = ctx.Apply(); err != nil { // Apply is not expected to return an err + return nil, err } if err = ctx.Validate(); err != nil { return nil, &ParseError{error: err, Context: ctx} } if err = k.applyHook(ctx, "AfterApply"); err != nil { - return nil, &ParseError{error: err, Context: ctx} + return nil, err } return ctx, nil } @@ -428,13 +428,15 @@ func (k *Kong) Errorf(format string, args ...any) *Kong { return k } -// Fatalf writes a message to Kong.Stderr with the application name prefixed then exits with a non-zero status. +// Fatalf writes a message to Kong.Stderr with the application name prefixed then exits with status 1. func (k *Kong) Fatalf(format string, args ...any) { k.Errorf(format, args...) k.Exit(1) } // FatalIfErrorf terminates with an error message if err != nil. +// If the error implements the ExitCoder interface, the ExitCode() method is called and +// the application exits with that status. Otherwise, the application exits with status 1. func (k *Kong) FatalIfErrorf(err error, args ...any) { if err == nil { return @@ -455,7 +457,8 @@ func (k *Kong) FatalIfErrorf(err error, args ...any) { fmt.Fprintln(k.Stdout) } } - k.Fatalf("%s", msg) + k.Errorf("%s", msg) + k.Exit(exitCodeFromError(err)) } // LoadConfig from path using the loader configured via Configuration(loader).