diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 463fcae..b02a7ef 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement the optional integration modules and captured registration API -Status: implementation in progress. Stages 1 through 3 are complete; subsequent work follows the ordered roadmap. +Status: implementation in progress. Stages 1 through 4 are complete; subsequent work follows the ordered roadmap. Execute the stages in order. Each stage is sized as a separate Luna coding-agent assignment. This document authorizes no publication or changes to sibling repositories. @@ -254,10 +254,14 @@ checks. No new grammar restriction was added. materialize or apply the target's input. Scope inapplicability never skips supplied-value conversion. -**Completion evidence:** original/copied/lookalike identity, defaults and presence, -selected required flags, wrapped errors, nil destination, empty selection, -repeatable independence, and no second conversion. Check both ordinary and -captured bindings through the same preparation path. +**Completion evidence — complete:** `CaptureFlags` stores invocation-local +prepared values by original binding identity, and `ApplyTo` accepts copied handles +while rejecting lookalikes, uncaptured handles, and nil destinations with +`ErrFlagWiring`. Required checks apply only to selected bindings and match +`ErrFlagInput`; defaults and explicit presence are retained. Nil accessors and +invalid inner bindings are rejected at installation. Repeat applications copy +core-owned slices without reconversion. Wrapped wiring/input errors route to +operational/usage outcomes respectively. Root tests pass. ## Stage 5: Compile typed runners and real built-in declarations diff --git a/execute.go b/execute.go index 9cb38c2..a7fe518 100644 --- a/execute.go +++ b/execute.go @@ -39,7 +39,7 @@ func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outco } input := buildInput() if err := command.record.payload.checkInput(input); err != nil { - return current.present(Usage(err), parsed.Path) + return current.present(classifyInputError(err), parsed.Path) } if len(parsed.Path) == 1 && parsed.Path[0] == "help" { help, ok := input.(*helpInput) diff --git a/flag_values.go b/flag_values.go new file mode 100644 index 0000000..bd075b2 --- /dev/null +++ b/flag_values.go @@ -0,0 +1,89 @@ +package commandkit + +import ( + "errors" + "fmt" +) + +// ErrFlagInput identifies a missing required captured flag selected for an input. +var ErrFlagInput = errors.New("required flag input is missing") + +// ErrFlagWiring identifies an invalid captured binding or destination. +var ErrFlagWiring = errors.New("flag wiring is invalid") + +// FlagValues stores invocation-local values prepared for captured flags. +type FlagValues[I any] struct { + values map[*bindingIdentity]preparedValue[I] +} + +// CaptureFlags adapts flags declared for I into flags on Outer. The returned +// flags preserve the original identities and store prepared values in target. +func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] { + captured := make([]Flag[Outer], len(flags)) + for index, original := range flags { + inner := original + identity := newBindingIdentity() + binding := flagBinding[Outer]{ + identity: identity, + meta: inner.binding.meta, + defaultText: inner.binding.defaultText, + repeated: inner.binding.repeated, + boolean: inner.binding.boolean, + } + if target != nil && inner.binding.identity != nil && inner.binding.valid && inner.binding.prepare != nil { + binding.valid = true + binding.prepare = func(raw []string) (preparedValue[Outer], error) { + prepared, err := inner.binding.prepare(raw) + if err != nil { + return preparedValue[Outer]{}, err + } + return preparedValue[Outer]{ + identity: identity, + supplied: prepared.supplied, + apply: func(outer *Outer) { + store := target(outer) + if store == nil { + return + } + if store.values == nil { + store.values = make(map[*bindingIdentity]preparedValue[I]) + } + store.values[inner.binding.identity] = prepared + }, + }, nil + } + } + captured[index] = Flag[Outer]{binding: binding} + } + return captured +} + +// ApplyTo applies selected captured flags to an initialized destination. +// Repeated core-owned values are copied for each application. +func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error { + if target == nil { + return fmt.Errorf("%w: destination is nil", ErrFlagWiring) + } + for _, flag := range selected { + binding := flag.binding + if binding.identity == nil { + return fmt.Errorf("%w: selected flag has no identity", ErrFlagWiring) + } + prepared, exists := values.values[binding.identity] + if !exists { + return fmt.Errorf("%w: flag --%s was not captured", ErrFlagWiring, binding.meta.Name) + } + if binding.meta.Required && !prepared.supplied { + return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput) + } + prepared.apply(target) + } + return nil +} + +func classifyInputError(err error) Outcome { + if errors.Is(err, ErrFlagWiring) { + return Failure(err) + } + return Usage(err) +} diff --git a/flag_values_test.go b/flag_values_test.go new file mode 100644 index 0000000..7c66586 --- /dev/null +++ b/flag_values_test.go @@ -0,0 +1,144 @@ +package commandkit + +import ( + "errors" + "fmt" + "testing" +) + +type capturedInput struct { + name Supplied[string] + tags Supplied[[]string] +} + +type capturedOuter struct { + flags FlagValues[capturedInput] +} + +func TestCaptureFlagsAppliesOnlyOriginalBindingWithoutReconversion(t *testing.T) { + parseCalls := 0 + original := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) { + parseCalls++ + return value, nil + }, func(input *capturedInput, value Supplied[string]) { input.name = value }) + copied := original + captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags }) + prepared, err := captured[0].binding.prepare([]string{"chosen"}) + if err != nil { + t.Fatal(err) + } + outer := capturedOuter{} + prepared.apply(&outer) + var target capturedInput + if err := outer.flags.ApplyTo(&target, copied); err != nil { + t.Fatal(err) + } + if target.name.Value != "chosen" || !target.name.Set || parseCalls != 1 { + t.Fatalf("target=%#v parse calls=%d", target, parseCalls) + } + lookalike := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) { return value, nil }, func(*capturedInput, Supplied[string]) {}) + if err := outer.flags.ApplyTo(&target, lookalike); !errors.Is(err, ErrFlagWiring) { + t.Fatalf("lookalike binding error = %v", err) + } + if err := outer.flags.ApplyTo(nil, copied); !errors.Is(err, ErrFlagWiring) { + t.Fatalf("nil target error = %v", err) + } + if err := (FlagValues[capturedInput]{}).ApplyTo(&target); err != nil { + t.Fatalf("empty selection from zero store: %v", err) + } +} + +func TestCaptureFlagsDefersRequiredCheckToSelectedBindings(t *testing.T) { + required := ValueFlag(FlagMeta{Name: "name", Required: true}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value }) + captured := CaptureFlags([]Flag[capturedInput]{required}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags }) + prepared, err := captured[0].binding.prepare(nil) + if err != nil { + t.Fatal(err) + } + outer := capturedOuter{} + prepared.apply(&outer) + var target capturedInput + if err := outer.flags.ApplyTo(&target); err != nil { + t.Fatalf("unselected required flag failed: %v", err) + } + if err := outer.flags.ApplyTo(&target, required); !errors.Is(err, ErrFlagInput) { + t.Fatalf("selected missing required flag error = %v", err) + } + optional := ValueFlag(FlagMeta{Name: "name"}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value }) + optionalCapture := CaptureFlags([]Flag[capturedInput]{optional}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags }) + optionalPrepared, err := optionalCapture[0].binding.prepare(nil) + if err != nil { + t.Fatal(err) + } + outer = capturedOuter{} + optionalPrepared.apply(&outer) + target = capturedInput{} + if err := outer.flags.ApplyTo(&target, optional); err != nil { + t.Fatal(err) + } + if target.name.Value != "default" || target.name.Set { + t.Fatalf("captured default value = %#v", target.name) + } + + badCapture := CaptureFlags[capturedOuter]([]Flag[capturedInput]{required}, nil) + command := NewCommand(CommandSpec[capturedOuter]{Name: "broken", Flags: badCapture}) + if _, err := New(Identity{Name: "example"}, command); err == nil { + t.Fatal("installation accepted a nil captured-flag accessor") + } +} + +func TestCaptureFlagsCopiesRepeatedValuesOnEachApplication(t *testing.T) { + original := RepeatFlag(FlagMeta{Name: "tag"}, nil, func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[[]string]) { input.tags = value }) + captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags }) + prepared, err := captured[0].binding.prepare([]string{"a,b", "c"}) + if err != nil { + t.Fatal(err) + } + outer := capturedOuter{} + prepared.apply(&outer) + var first, second capturedInput + if err := outer.flags.ApplyTo(&first, original); err != nil { + t.Fatal(err) + } + if err := outer.flags.ApplyTo(&second, original); err != nil { + t.Fatal(err) + } + first.tags.Value[0] = "changed" + if second.tags.Value[0] != "a,b" || second.tags.Value[1] != "c" || !second.tags.Set { + t.Fatalf("second application shares repeat storage: %#v", second.tags) + } +} + +func TestCapturedFlagErrorClassificationPreservesWrappedCategories(t *testing.T) { + if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagWiring)); got.Kind != OutcomeOperational { + t.Fatalf("wiring outcome = %#v", got) + } + if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagInput)); got.Kind != OutcomeUsage { + t.Fatalf("input outcome = %#v", got) + } +} + +func TestCheckInputClassifiesWrappedFlagWiringAsOperational(t *testing.T) { + command := NewCommand(CommandSpec[struct{}]{ + Name: "run", + CheckInput: func(struct{}) error { return fmt.Errorf("scope selector: %w", ErrFlagWiring) }, + Run: func(Invocation, struct{}) Outcome { t.Fatal("run called after wiring error"); return Success() }, + }) + app, err := New(Identity{Name: "example"}, command) + if err != nil { + t.Fatal(err) + } + if outcome := app.Execute(t.Context(), []string{"run"}, Streams{}); outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, ErrFlagWiring) { + t.Fatalf("wrapped wiring outcome = %#v", outcome) + } + usage := NewCommand(CommandSpec[struct{}]{Name: "usage", CheckInput: func(struct{}) error { return fmt.Errorf("selected value: %w", ErrFlagInput) }, Run: func(Invocation, struct{}) Outcome { return Success() }}) + app, err = New(Identity{Name: "example"}, usage) + if err != nil { + t.Fatal(err) + } + if outcome := app.Execute(t.Context(), []string{"usage"}, Streams{}); outcome.Kind != OutcomeUsage || !errors.Is(outcome.Cause, ErrFlagInput) { + t.Fatalf("wrapped input outcome = %#v", outcome) + } +} + +func strptr(value string) *string { return &value }