Preserve alias context in usage errors

This commit is contained in:
2026-10-19 19:46:22 +00:00
parent a504efbad7
commit 1fa026e5bd
5 changed files with 239 additions and 13 deletions

View File

@@ -3,6 +3,8 @@ package commandkit
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"reflect"
"strings"
@@ -316,6 +318,163 @@ func TestExecuteAppliesCanonicalCommandFlagAliasesAfterRouting(t *testing.T) {
}
}
func TestAliasConversionErrorsRetainOccurrenceAndEnteredUsage(t *testing.T) {
colorFailure := errors.New("invalid color")
tagFailure := errors.New("invalid tag")
type input struct {
color Supplied[string]
tags Supplied[[]string]
}
command := NewCommand(CommandSpec[input]{
Name: "inspect",
Flags: []Flag[input]{
ValueFlag(FlagMeta{Name: "color"}, (*string)(nil), func(value string) (string, error) {
if value == "bad" {
return "", colorFailure
}
return value, nil
}, func(target *input, value Supplied[string]) { target.color = value }),
RepeatFlag(FlagMeta{Name: "tag"}, nil, func(value string) (string, error) {
if value == "bad" {
return "", tagFailure
}
return value, nil
}, func(target *input, value Supplied[[]string]) { target.tags = value }),
},
Run: func(Invocation, input) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command,
Alias("shortcut", "inspect"),
FlagAliasFor("inspect", "--colour", "--color"),
FlagAliasFor("inspect", "--labels", "--tag"),
)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
args []string
cause error
binding string
spelling string
occurrence int
}{
{
name: "invalid scalar before valid overwrite",
args: []string{"shortcut", "--colour=bad", "--color=good"},
cause: colorFailure,
binding: "color",
spelling: "--colour",
occurrence: 1,
},
{
name: "invalid repeat occurrence after mixed spelling",
args: []string{"shortcut", "--labels=first", "--tag=bad"},
cause: tagFailure,
binding: "tag",
spelling: "--tag",
occurrence: 2,
},
} {
t.Run(test.name, func(t *testing.T) {
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), test.args, Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != OutcomeUsage {
t.Fatalf("outcome kind = %v, want usage", outcome.Kind)
}
if !errors.Is(outcome.Cause, test.cause) {
t.Fatalf("outcome cause = %v, want wrapped %v", outcome.Cause, test.cause)
}
var conversionErr *flagConversionError
if !errors.As(outcome.Cause, &conversionErr) {
t.Fatalf("outcome cause %T does not expose flag conversion context", outcome.Cause)
}
if conversionErr.binding != test.binding || conversionErr.spelling != test.spelling || conversionErr.occurrence != test.occurrence {
t.Fatalf("conversion error = %#v, want binding/spelling/occurrence for %s %d", conversionErr, test.spelling, test.occurrence)
}
for _, fragment := range []string{fmt.Sprintf("flag %s occurrence %d: %s", test.spelling, test.occurrence, test.cause), "Usage: Example shortcut", "Alias for: Example inspect"} {
if !strings.Contains(diagnostics.String(), fragment) {
t.Errorf("diagnostics %q do not contain %q", diagnostics.String(), fragment)
}
}
})
}
}
func TestAliasUsageSurvivesRegistrationDuringExecution(t *testing.T) {
for _, test := range []struct {
name string
register func(*App) error
wantErr bool
}{
{
name: "successful registration",
register: func(app *App) error {
return app.Register(NewCommand(CommandSpec[struct{}]{Name: "late", Run: func(Invocation, struct{}) Outcome { return Success() }}))
},
},
{
name: "failed registration",
register: func(app *App) error { return app.Register(Alias("shortcut", "other")) },
wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
var app *App
var registrationErr error
command := NewCommand(CommandSpec[struct{}]{
Name: "inspect",
Run: func(Invocation, struct{}) Outcome {
registrationErr = test.register(app)
return Usage(errors.New("operation rejected"))
},
})
var err error
app, err = New(Identity{Name: "Example"}, command, Alias("shortcut", "inspect"))
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"shortcut"}, Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != OutcomeUsage {
t.Fatalf("outcome kind = %v, want usage", outcome.Kind)
}
if (registrationErr != nil) != test.wantErr {
t.Fatalf("registration error = %v, want error %t", registrationErr, test.wantErr)
}
for _, fragment := range []string{"operation rejected", "Usage: Example shortcut", "Alias for: Example inspect"} {
if !strings.Contains(diagnostics.String(), fragment) {
t.Errorf("diagnostics %q do not contain %q", diagnostics.String(), fragment)
}
}
})
}
}
func TestUnresolvedRouteUsageHasNoCanonicalAliasAnnotation(t *testing.T) {
app := routeTestApp(t)
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"missing", "route"}, Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != OutcomeUsage {
t.Fatalf("outcome kind = %v, want usage", outcome.Kind)
}
if strings.Contains(diagnostics.String(), "Alias for:") {
t.Fatalf("unresolved route diagnostics invented a canonical target: %q", diagnostics.String())
}
diagnostics.Reset()
outcome = app.Execute(context.Background(), []string{"r", "full", "--unknown"}, Streams{Out: io.Discard, Err: &diagnostics})
if outcome.Kind != OutcomeUsage {
t.Fatalf("unknown flag outcome kind = %v, want usage", outcome.Kind)
}
for _, fragment := range []string{"unknown flag", "Usage: Example r full", "Alias for: Example inspect"} {
if !strings.Contains(diagnostics.String(), fragment) {
t.Errorf("unknown flag diagnostics %q do not contain %q", diagnostics.String(), fragment)
}
}
}
func TestExecuteAppliesGlobalHelpAliasAtApplicationRoot(t *testing.T) {
app, err := New(Identity{Name: "Example"}, FlagAlias("--hlep", "--help"))
if err != nil {