From f34ab12ca5bb39ae38b3bac348948137eac7893c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 19 Oct 2026 19:48:57 +0000 Subject: [PATCH] Verify public alias registration flows --- declaration_external_test.go | 210 +++++++++++++++++++++++++++++++++ docs/roadmap/implementation.md | 10 +- 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/declaration_external_test.go b/declaration_external_test.go index 266a589..f8c4c30 100644 --- a/declaration_external_test.go +++ b/declaration_external_test.go @@ -1,6 +1,13 @@ package commandkit_test import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "reflect" + "strings" "testing" commandkit "gitea.maximumdirect.net/eric/CommandKit" @@ -29,3 +36,206 @@ func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) { t.Fatal("New() returned a nil app") } } + +func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) { + for _, test := range []struct { + name string + rulesFirst bool + }{ + {name: "commands first"}, + {name: "routing rules first", rulesFirst: true}, + } { + t.Run(test.name, func(t *testing.T) { + type runInput struct{ words commandkit.Supplied[[]string] } + type inspectInput struct{ id commandkit.Supplied[string] } + type showInput struct { + profile commandkit.Supplied[string] + format commandkit.Supplied[string] + } + var runParses, inspectParses, optionalParses int + var runValues [][]string + var inspectValues []string + var showValues []string + run := commandkit.NewCommand(commandkit.CommandSpec[runInput]{ + Name: "run", + Arguments: []commandkit.Argument[runInput]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "words"}, 0, -1, + func(value string) (string, error) { runParses++; return value, nil }, + func(target *runInput, value commandkit.Supplied[[]string]) { target.words = value }, + )}, + Run: func(_ commandkit.Invocation, value runInput) commandkit.Outcome { + runValues = append(runValues, append([]string(nil), value.words.Value...)) + return commandkit.Success() + }, + }) + inspect := commandkit.NewCommand(commandkit.CommandSpec[inspectInput]{ + Name: "inspect", + Arguments: []commandkit.Argument[inspectInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true, + func(value string) (string, error) { inspectParses++; return value, nil }, + func(target *inspectInput, value commandkit.Supplied[string]) { target.id = value }, + )}, + Run: func(_ commandkit.Invocation, value inspectInput) commandkit.Outcome { + inspectValues = append(inspectValues, value.id.Value) + return commandkit.Success() + }, + }) + show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{ + Name: "show", + Arguments: []commandkit.Argument[showInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "profile"}, false, + func(value string) (string, error) { optionalParses++; return value, nil }, + func(target *showInput, value commandkit.Supplied[string]) { target.profile = value }, + )}, + Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), + func(value string) (string, error) { return value, nil }, + func(target *showInput, value commandkit.Supplied[string]) { target.format = value }, + )}, + Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome { + showValues = append(showValues, value.profile.Value+":"+value.format.Value) + return commandkit.Success() + }, + }) + commands := []commandkit.Declaration{run, inspect, show} + rules := []commandkit.Declaration{ + commandkit.Alias("r", "run"), + commandkit.Alias("r full", "inspect"), + commandkit.Alias("inspect-shortcut", "inspect"), + commandkit.Alias("i", "inspect-shortcut"), + commandkit.FlagAlias("--fmt", "--format"), + } + declarations := append([]commandkit.Declaration(nil), commands...) + if test.rulesFirst { + declarations = append(append([]commandkit.Declaration(nil), rules...), commands...) + } else { + declarations = append(declarations, rules...) + } + + app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...) + if err != nil { + t.Fatal(err) + } + if runParses != 0 || inspectParses != 0 || optionalParses != 0 { + t.Fatalf("installation invoked operand converters: %d/%d/%d", runParses, inspectParses, optionalParses) + } + for _, args := range [][]string{{"run", "full"}, {"r", "full", "item-1"}, {"i", "item-2"}, {"show", "profile", "--fmt=json"}} { + if outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("Execute(%q) = %#v, want success", args, outcome) + } + } + if !reflect.DeepEqual(runValues, [][]string{{"full"}}) { + t.Fatalf("canonical run operands = %#v, want [[full]]", runValues) + } + if !reflect.DeepEqual(inspectValues, []string{"item-1", "item-2"}) { + t.Fatalf("routed required operands = %#v", inspectValues) + } + if !reflect.DeepEqual(showValues, []string{"profile:json"}) || optionalParses != 1 { + t.Fatalf("optional operand/flag values = %#v, converter calls %d", showValues, optionalParses) + } + + parsesBeforeReuse := [3]int{runParses, inspectParses, optionalParses} + secondApp, err := commandkit.New(commandkit.Identity{Name: "Second"}, declarations...) + if err != nil { + t.Fatalf("reusing declarations: %v", err) + } + if got := [3]int{runParses, inspectParses, optionalParses}; got != parsesBeforeReuse { + t.Fatalf("reinstalling declarations invoked operand converters: %v -> %v", parsesBeforeReuse, got) + } + if outcome := secondApp.Execute(context.Background(), []string{"run", "again"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("reused declaration execution = %#v", outcome) + } + if !reflect.DeepEqual(runValues, [][]string{{"full"}, {"again"}}) { + t.Fatalf("reused command declaration values = %#v", runValues) + } + }) + } +} + +func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) { + type showInput struct{ format commandkit.Supplied[string] } + var formatParses, lateRuns int + var formats []string + show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{ + Name: "show", + Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "format"}, (*string)(nil), + func(value string) (string, error) { formatParses++; return value, nil }, + func(target *showInput, value commandkit.Supplied[string]) { target.format = value }, + )}, + Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome { + formats = append(formats, value.format.Value) + return commandkit.Success() + }, + }) + family := commandkit.RootGroup("config", "Configuration", show) + constructionFailure := errors.New("family construction failed") + if _, err := commandkit.New(commandkit.Identity{Name: "Broken"}, commandkit.WithError(family, fmt.Errorf("adapter: %w", constructionFailure))); !errors.Is(err, constructionFailure) { + t.Fatalf("New() error = %v, want retained family construction failure", err) + } + + app, err := commandkit.New(commandkit.Identity{Name: "Example"}) + if err != nil { + t.Fatal(err) + } + var before bytes.Buffer + app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &before, Err: io.Discard}) + if strings.Contains(before.String(), "config") { + t.Fatalf("root help showed an unregistered family: %q", before.String()) + } + if err := app.Register(family, + commandkit.Alias("cfg-show", "config show"), + commandkit.Alias("cs", "cfg-show"), + commandkit.FlagAlias("--fmt", "--format"), + ); err != nil { + t.Fatal(err) + } + if formatParses != 0 { + t.Fatalf("registration invoked flag converter %d times", formatParses) + } + var after bytes.Buffer + app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &after, Err: io.Discard}) + for _, fragment := range []string{"config", "Aliases:", "cfg-show", "cs"} { + if !strings.Contains(after.String(), fragment) { + t.Errorf("updated root help %q does not contain %q", after.String(), fragment) + } + } + if outcome := app.Execute(context.Background(), []string{"cs", "--fmt=yaml"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("registered alias execution = %#v", outcome) + } + if !reflect.DeepEqual(formats, []string{"yaml"}) || formatParses != 1 { + t.Fatalf("registered family values = %#v, converter calls %d", formats, formatParses) + } + if err := app.Register(commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "cfg-show", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})); err == nil { + t.Fatal("registration accepted a canonical command that conflicts with an existing alias") + } + var afterConflict bytes.Buffer + app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterConflict, Err: io.Discard}) + if !strings.Contains(afterConflict.String(), "cfg-show") { + t.Fatalf("failed relationship validation changed registered aliases: %q", afterConflict.String()) + } + + late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "late", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { + lateRuns++ + return commandkit.Success() + }}) + registrationFailure := errors.New("retained registration failure") + err = app.Register(commandkit.WithError(late, fmt.Errorf("family: %w", registrationFailure))) + if !errors.Is(err, registrationFailure) { + t.Fatalf("Register() error = %v, want retained failure", err) + } + if err := app.Register(late, commandkit.Alias("late-shortcut", "missing")); err == nil { + t.Fatal("registration accepted a batch whose alias target is absent") + } + var afterFailure bytes.Buffer + app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterFailure, Err: io.Discard}) + if strings.Contains(afterFailure.String(), "late-shortcut") || strings.Contains(afterFailure.String(), " late ") { + t.Fatalf("failed batch changed root help: %q", afterFailure.String()) + } + if err := app.Register(late, commandkit.Alias("late-shortcut", "late")); err != nil { + t.Fatalf("registration after failed batch: %v", err) + } + var recovered bytes.Buffer + app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &recovered, Err: io.Discard}) + if !strings.Contains(recovered.String(), "late-shortcut") { + t.Fatalf("recovered root help omitted alias: %q", recovered.String()) + } + if outcome := app.Execute(context.Background(), []string{"late-shortcut"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess || lateRuns != 1 { + t.Fatalf("recovered alias execution = %#v, runs %d", outcome, lateRuns) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ed385ea..4d1cbc2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Application-owned aliases implementation plan -Status: in progress; Stages 1–11 completed. +Status: in progress; Stages 1–12 completed. ## Authority, baseline, and scope @@ -591,6 +591,14 @@ coverage. Fill missing cross-boundary cases rather than duplicating the private algorithm test inventory. No concurrent-dispatch contract is introduced. +**Completed:** External-package tests now exercise command-only and root-group +consumers using only the core import. They cover declaration ordering, alias +chains and global flags in one installation batch, canonical operand retention, +optional/required/repeated operands without installation-time conversion, +declaration reuse, retained construction errors, later registration, failed +relationship validation, failed-batch recovery, and refreshed help. The root +suite passes with `go test ./... -count=1`. + ## Stage 13: Verify typed bindings and integration composition **Work**