From c0f3a21fc725d7dc409832770778283688355016 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 11 Oct 2026 01:39:23 +0000 Subject: [PATCH] Port native configuration reports --- binding.go | 2 +- command.go | 17 +- docs/roadmap/implementation.md | 16 +- flag_values.go | 11 +- integrations/ckconfig/explain.go | 120 ++----- integrations/ckconfig/family_reports_test.go | 353 +++++++++++++++++++ integrations/ckconfig/input.go | 1 + integrations/ckconfig/operations.go | 47 +++ integrations/ckconfig/show.go | 121 +++---- integrations/ckconfig/show_framing_test.go | 95 +++++ integrations/ckconfig/sources.go | 90 ++--- integrations/ckconfig/validate.go | 86 ++--- 12 files changed, 671 insertions(+), 288 deletions(-) create mode 100644 integrations/ckconfig/family_reports_test.go create mode 100644 integrations/ckconfig/operations.go create mode 100644 integrations/ckconfig/show_framing_test.go diff --git a/binding.go b/binding.go index 6992094..6182e50 100644 --- a/binding.go +++ b/binding.go @@ -182,7 +182,7 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands [] return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err) } preparedFlags[i] = value - if enforceRequired && binding.meta.Required && !value.supplied && missingRequired == "" { + if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" { missingRequired = binding.meta.Name } } diff --git a/command.go b/command.go index f6bf09d..b87fd07 100644 --- a/command.go +++ b/command.go @@ -67,14 +67,15 @@ type preparedValue[I any] struct { } type flagBinding[I any] struct { - identity *bindingIdentity - meta FlagMeta - defaultText string - repeated bool - boolean bool - prepare func([]string) (preparedValue[I], error) - clear func(*I) - valid bool + identity *bindingIdentity + meta FlagMeta + deferredRequired bool + defaultText string + repeated bool + boolean bool + prepare func([]string) (preparedValue[I], error) + clear func(*I) + valid bool } // Argument represents a typed binding for a positional command argument. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ac88033..5cc7c04 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 9 are complete; subsequent work follows the ordered roadmap. +Status: implementation in progress. Stages 1 through 10 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. @@ -499,6 +499,20 @@ failure tests, including aggregate preacquisition, marker edge cases, warning-on validation, source facet calls, pointer parsing, historical redaction, and cancelled acquisition. Run the complete ckconfig suite when coherent. +**Completion evidence — complete:** validate/show/sources/explain now execute from +the prepared scope records and one shared state. Validation emits ordered +successes and warnings do not prevent success; show preacquires aggregate views, +keeps single-scope native output intact, and its YAML framing preserves native +content, markers, directive preambles, comments, CRLF and document boundaries. +Sources reads the native inventory once and retains empty and zero-valued fields. +Explain parses pointers before acquisition, supports the root pointer, and keeps +safe history, provenance, secret references and empty lists. Provider and +cancellation causes are retained; report delivery failures are marked separately +from diagnostics. Captured required flags now retain help metadata while deferring +enforcement to the selected original binding. The focused ckconfig adapter and +report tests pass from an explicit file list; root `go test .` passes. The full +ckconfig package suite remains deferred until Stage 13 replaces prototype tests. + ## Stage 11: Implement PromptKit engine and scope adapters **Outcome:** ckprompt composes engine configuration through ckconfig and named diff --git a/flag_values.go b/flag_values.go index bd075b2..fb9d5a5 100644 --- a/flag_values.go +++ b/flag_values.go @@ -24,11 +24,12 @@ func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues 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, + identity: identity, + meta: inner.binding.meta, + deferredRequired: inner.binding.meta.Required, + 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 diff --git a/integrations/ckconfig/explain.go b/integrations/ckconfig/explain.go index 378d06b..b1c4e9d 100644 --- a/integrations/ckconfig/explain.go +++ b/integrations/ckconfig/explain.go @@ -6,41 +6,10 @@ import ( "fmt" "io" + commandkit "gitea.maximumdirect.net/eric/CommandKit" configkit "gitea.maximumdirect.net/eric/ConfigKit" ) -func newConfigExplainCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) { - flags := configInputFlags(prepared.suite, "text") - flags = append(flags, ValueFlag[configInput[C]](FlagMeta{ - Name: "path", - Usage: "Configuration path as a JSON Pointer; an empty value selects the root.", - Required: true, - }, nil, parseString, func(input *configInput[C], value Supplied[string]) { - input.Path = value - })) - spec := CommandSpec[configInput[C]]{ - Name: string(ConfigExplain), - Summary: "Explain the effective and historical value at a configuration path.", - Flags: flags, - CheckInput: func(input configInput[C]) error { - if _, err := preflightConfig(prepared, input, ConfigExplain); err != nil { - return err - } - if !input.Path.Set { - return fmt.Errorf("--path is required") - } - if _, err := configkit.ParsePath(input.Path.Value); err != nil { - return fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err) - } - return nil - }, - Run: func(invocation Invocation, input configInput[C]) Outcome { - return runConfigExplanation(invocation, prepared, input) - }, - } - return NewCommand(spec) -} - type explanationReport struct { Scope string `yaml:"scope"` Path string `yaml:"path"` @@ -82,70 +51,56 @@ type secretReferenceDTO struct { Name string `yaml:"name"` } -func runConfigExplanation[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome { +func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { + selected := plan.scopes[0] + name := selected.scope.scope.Name if err := invocation.Context.Err(); err != nil { - return Failure(err) + return configOperationFailure(err, nil, false) } - scopeNames, err := selectConfigScopes(prepared, input, ConfigExplain) - if err != nil { - return Failure(err) + if selected.scope.scope.Acquire == nil { + return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false) } - if !input.Path.Set { - return Failure(fmt.Errorf("--path is required")) - } - path, err := configkit.ParsePath(input.Path.Value) - if err != nil { - return Usage(fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err)) - } - name := scopeNames[0] - contexts := contextsForScopes(prepared, input, scopeNames) - scope, ok := findConfigScope(prepared, name) - if !ok || scope.Acquire == nil { - return Failure(fmt.Errorf("configuration scope %q has no acquisition callback", name)) - } - if err := invocation.Context.Err(); err != nil { - return explanationFailure(err, nil, false) - } - acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigExplain, Selection: input.Selection}, contexts[name]) - diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...) + acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ + Operation: Explain, + Selection: plan.selection, + }, selected.input, state) + diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...) if acquireErr != nil { - return explanationFailure(acquireErr, diagnostics, false) + return configOperationFailure(acquireErr, diagnostics, false) } if err := invocation.Context.Err(); err != nil { - return explanationFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) + } + if acquired.Value.Explanation == nil { + return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false) } - if acquired.Value.Explanation == nil { - return explanationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false) - } - explanation, found := acquired.Value.Explanation.Explain(path) + explanation, found := acquired.Value.Explanation.Explain(plan.path) if !found { - return explanationFailure(fmt.Errorf("configuration path %q is not known in scope %q", path.String(), name), diagnostics, false) + return configOperationFailure(fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name), diagnostics, false) } if err := invocation.Context.Err(); err != nil { - return explanationFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } - if input.Format != "yaml" { - return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, path, diagnostics) + if plan.format != "yaml" { + return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, plan.path, diagnostics) } var report bytes.Buffer if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil { - return explanationFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } if err := invocation.Context.Err(); err != nil { - return explanationFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } - if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil { - outcome := explanationFailure(err, diagnostics, true) - outcome.DeliveryError = err - return outcome + if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil { + return configDeliveryFailure(err, diagnostics, true) } - outcome := Success() + outcome := commandkit.Success() outcome.Diagnostics = diagnostics return outcome } -func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []Diagnostic) Outcome { +func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []commandkit.Diagnostic) commandkit.Outcome { checked := &checkedWriter{writer: writer} renderErr := reader.WriteExplanation(checked, path) if checked.failure != nil { @@ -156,11 +111,11 @@ func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path con } } if renderErr != nil { - outcome := explanationFailure(renderErr, diagnostics, true) + outcome := configOperationFailure(renderErr, diagnostics, true) outcome.DeliveryError = checked.failure return outcome } - outcome := Success() + outcome := commandkit.Success() outcome.Diagnostics = diagnostics return outcome } @@ -213,18 +168,3 @@ func originReport(origin configkit.Origin) explanationOriginDTO { } return report } - -func locationReports(locations []configkit.Location) []locationDTO { - report := make([]locationDTO, 0, len(locations)) - for _, location := range locations { - report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column}) - } - return report -} - -func explanationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome { - outcome := Failure(cause) - outcome.Diagnostics = diagnostics - outcome.ReportIncomplete = reportStarted - return outcome -} diff --git a/integrations/ckconfig/family_reports_test.go b/integrations/ckconfig/family_reports_test.go new file mode 100644 index 0000000..a0a6daf --- /dev/null +++ b/integrations/ckconfig/family_reports_test.go @@ -0,0 +1,353 @@ +package ckconfig + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) { + inputCalls, stateCalls, acquireCalls := 0, 0, 0 + suite := Suite[struct{}, struct{}]{ + NewInput: func() struct{} { inputCalls++; return struct{}{} }, + NewState: func() struct{} { stateCalls++; return struct{}{} }, + Scopes: []Scope[struct{}, struct{}]{ + {Name: "zeta"}, + {Name: "promptkit", Operations: []Operation{}}, + {Name: "app", Summary: "Application settings."}, + {Name: "alpha"}, + }, + } + for index := range suite.Scopes { + suite.Scopes[index].Acquire = func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + acquireCalls++ + return commandkit.Acquired[View]{}, nil + } + } + // An unused malformed binding has no generated command to validate. + suite.Selectors = []Selector[struct{}]{{Binding: commandkit.Flag[struct{}]{}, Scopes: []string{"app"}}} + app := newConfigTestApp(t, Command(suite)) + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: &diagnostic}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("discovery outcome = %#v; diagnostics %q", outcome, diagnostic.String()) + } + if got, want := output.String(), "app: \npromptkit: \nalpha: \nzeta: \n"; got != want { + t.Fatalf("discovery output = %q, want %q", got, want) + } + if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 { + t.Fatalf("discovery invoked suite work: input=%d state=%d acquire=%d", inputCalls, stateCalls, acquireCalls) + } +} + +func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T) { + type inspectInput struct { + Campaign commandkit.Supplied[string] + Retained string + } + type runState struct{ ID int } + var inputCalls, stateCalls, checkCalls int + var checked []*inspectInput + var acquired []*inspectInput + var states []*runState + var checkErr error + commandkitFlag := commandkit.ValueFlag[*inspectInput, string]( + commandkit.FlagMeta{Name: "campaign", Usage: "Campaign selector.", Required: true}, + (*string)(nil), func(value string) (string, error) { return value, nil }, + func(input **inspectInput, value commandkit.Supplied[string]) { (*input).Campaign = value }, + ) + makeScope := func(name string) Scope[*inspectInput, *runState] { + return Scope[*inspectInput, *runState]{Name: name, Operations: []Operation{Validate}, Acquire: func(_ context.Context, request Request, input *inspectInput, state *runState) (commandkit.Acquired[View], error) { + if request.Operation != Validate || !request.Selection.Config.Set || request.Selection.Config.Value != "chosen.yaml" { + t.Errorf("request = %#v", request) + } + acquired = append(acquired, input) + states = append(states, state) + return commandkit.Acquired[View]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, name+" warning")}}, nil + }} + } + suite := Suite[*inspectInput, *runState]{ + NewInput: func() *inspectInput { + inputCalls++ + return &inspectInput{Retained: fmt.Sprintf("initial-%d", inputCalls)} + }, + NewState: func() *runState { + stateCalls++ + if checkCalls != 2 { + t.Errorf("shared state created after %d input checks", checkCalls) + } + return &runState{ID: stateCalls} + }, + Selectors: []Selector[*inspectInput]{{Binding: commandkitFlag, Scopes: []string{"promptkit"}}}, + CheckInput: func(name string, selection Selection, input *inspectInput) error { + checkCalls++ + checked = append(checked, input) + if !selection.Config.Set || input.Retained == "" { + return fmt.Errorf("missing initialized input for %s", name) + } + if name == "app" && input.Campaign.Set { + return fmt.Errorf("inapplicable selector was applied to app") + } + if name == "promptkit" && (!input.Campaign.Set || input.Campaign.Value != "spring") { + return fmt.Errorf("promptkit campaign = %#v", input.Campaign) + } + if checkErr != nil { + return checkErr + } + return nil + }, + Scopes: []Scope[*inspectInput, *runState]{makeScope("promptkit"), makeScope("app")}, + } + app := newConfigTestApp(t, Command(suite)) + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: &diagnostic}) + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(diagnostic.String(), "app warning") || !strings.Contains(diagnostic.String(), "promptkit warning") { + t.Fatalf("validation outcome=%#v diagnostics=%q", outcome, diagnostic.String()) + } + if got, want := output.String(), "app: valid\npromptkit: valid\n"; got != want { + t.Fatalf("validation output = %q, want %q", got, want) + } + if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || len(acquired) != 2 { + t.Fatalf("factory/callback counts input=%d check=%d state=%d acquire=%d", inputCalls, checkCalls, stateCalls, len(acquired)) + } + if checked[0] != acquired[0] || checked[1] != acquired[1] || checked[0] == checked[1] { + t.Fatalf("scope inputs were not retained independently: checked=%p,%p acquired=%p,%p", checked[0], checked[1], acquired[0], acquired[1]) + } + if states[0] == nil || states[0] != states[1] || states[0].ID != 1 { + t.Fatalf("shared state values = %#v", states) + } +} + +func TestValidationChecksAggregateCoverageBeforeFactories(t *testing.T) { + newInputCalls, newStateCalls, acquireCalls := 0, 0, 0 + makeScope := func(name string, operations []Operation) Scope[struct{}, struct{}] { + return Scope[struct{}, struct{}]{Name: name, Operations: operations, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + acquireCalls++ + return commandkit.Acquired[View]{}, nil + }} + } + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{ + NewInput: func() struct{} { newInputCalls++; return struct{}{} }, + NewState: func() struct{} { newStateCalls++; return struct{}{} }, + Scopes: []Scope[struct{}, struct{}]{makeScope("app", []Operation{Validate}), makeScope("promptkit", []Operation{Show})}, + })) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || newInputCalls != 0 || newStateCalls != 0 || acquireCalls != 0 || output.Len() != 0 { + t.Fatalf("unsupported aggregate outcome=%#v counts=%d/%d/%d output=%q", outcome, newInputCalls, newStateCalls, acquireCalls, output.String()) + } +} + +func TestRequiredSelectorAppliesOnlyToItsSelectedScope(t *testing.T) { + type input struct{ Value commandkit.Supplied[string] } + var calls []string + flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.Value = value }) + makeScope := func(name string) Scope[input, struct{}] { + return Scope[input, struct{}]{Name: name, Operations: []Operation{Validate}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) { + calls = append(calls, name) + if name == "app" && value.Value.Set { + t.Errorf("tenant selector applied to app: %#v", value) + } + return commandkit.Acquired[View]{}, nil + }} + } + app := newConfigTestApp(t, Command(Suite[input, struct{}]{Selectors: []Selector[input]{{Binding: flag, Scopes: []string{"promptkit"}}}, Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")}})) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || len(calls) != 1 || calls[0] != "app" { + t.Fatalf("unselected required selector outcome=%#v calls=%v", outcome, calls) + } + calls = nil + outcome = app.Execute(context.Background(), []string{"config", "validate", "--scope", "promptkit"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || len(calls) != 0 || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) { + t.Fatalf("selected required selector outcome=%#v calls=%v", outcome, calls) + } +} + +func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) { + var output bytes.Buffer + var acquisitionOutputLengths []int + makeScope := func(name, native string, acquireErr error) Scope[struct{}, *int] { + return Scope[struct{}, *int]{Name: name, Operations: []Operation{Show}, Acquire: func(_ context.Context, _ Request, _ struct{}, state *int) (commandkit.Acquired[View], error) { + *state++ + acquisitionOutputLengths = append(acquisitionOutputLengths, output.Len()) + if acquireErr != nil { + return commandkit.Acquired[View]{}, acquireErr + } + return commandkit.Acquired[View]{Value: View{Effective: effectiveValue(native)}}, nil + }} + } + providerErr := errors.New("second provider failed") + app := newConfigTestApp(t, Command(Suite[struct{}, *int]{NewState: func() *int { return new(int) }, Scopes: []Scope[struct{}, *int]{ + makeScope("app", "---\nname: app\n", nil), + makeScope("promptkit", "---\nname: promptkit\n", providerErr), + }})) + outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 { + t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String()) + } + if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 { + t.Fatalf("show rendered before acquisition completed: %v", acquisitionOutputLengths) + } + + output.Reset() + acquisitionOutputLengths = nil + app = newConfigTestApp(t, Command(Suite[struct{}, *int]{NewState: func() *int { return new(int) }, Scopes: []Scope[struct{}, *int]{ + makeScope("app", "---\nname: app\n", nil), + makeScope("promptkit", "name: promptkit\n", nil), + }})) + outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("aggregate show outcome = %#v", outcome) + } + want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n" + if output.String() != want { + t.Fatalf("aggregate show output = %q, want %q", output.String(), want) + } +} + +func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) { + reader := &sourceInventory{values: []configkit.SourceInfo{{ + ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0, + ImportedFrom: &configkit.Location{SourceID: "", Line: 0, Column: 0}, + }}} + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Sources: reader}}, nil + }}}})) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 1 { + t.Fatalf("sources outcome=%#v source calls=%d", outcome, reader.calls) + } + for _, fragment := range []string{"sources:", "id: synthetic", "label: \"\"", "path: \"\"", "underlay_index: 0", "imported_from:", "line: 0", "column: 0"} { + if !strings.Contains(output.String(), fragment) { + t.Errorf("source report missing %q: %s", fragment, output.String()) + } + } +} + +func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.T) { + reader := &nativeExplanation{ + path: configkit.Path{}, + value: configkit.Explanation{ + Path: configkit.Path{}, + Effective: configkit.ValueSnapshot{Present: true, Redacted: true}, + History: []configkit.ValueChange{{ + Before: configkit.ValueSnapshot{Present: true, Redacted: true}, + After: configkit.ValueSnapshot{Present: true, Redacted: true}, + Reasons: []string{"override"}, + Dependencies: []configkit.Path{}, + }}, + SecretReference: &configkit.SecretReferenceInfo{Name: "APP_TOKEN"}, + }, found: true, + } + acquireCalls := 0 + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + acquireCalls++ + return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil + }}}})) + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "explain", "--path=", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || acquireCalls != 1 || reader.path.String() != "" { + t.Fatalf("root explanation outcome=%#v calls=%d path=%q", outcome, acquireCalls, reader.path.String()) + } + if strings.Contains(output.String(), "APP_TOKEN_VALUE") || !strings.Contains(output.String(), "redacted: true") || !strings.Contains(output.String(), "history:") || !strings.Contains(output.String(), "dependencies: []") || !strings.Contains(output.String(), "name: APP_TOKEN") { + t.Fatalf("safe explanation report = %s", output.String()) + } + + output.Reset() + acquireCalls = 0 + outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/bad~2"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || acquireCalls != 0 || output.Len() != 0 { + t.Fatalf("malformed pointer was not rejected before acquisition: outcome=%#v calls=%d", outcome, acquireCalls) + } +} + +func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T) { + writeErr := errors.New("output failed") + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{values: []configkit.SourceInfo{}}}}, nil + }}}})) + outcome := app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard}) + if !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete { + t.Fatalf("source delivery failure outcome = %#v", outcome) + } + + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{ + {Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, nil + }}, + {Name: "promptkit", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + cancel() + return commandkit.Acquired[View]{}, nil + }}, + }})) + var output bytes.Buffer + outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if !errors.Is(outcome.Cause, context.Canceled) || !outcome.ReportIncomplete || calls != 2 || output.String() != "app: valid\n" { + t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String()) + } +} + +type effectiveValue string + +func (value effectiveValue) WriteEffective(writer io.Writer) error { + _, err := io.WriteString(writer, string(value)) + return err +} + +type sourceInventory struct { + values []configkit.SourceInfo + calls int +} + +func (reader *sourceInventory) Sources() []configkit.SourceInfo { + reader.calls++ + return reader.values +} + +type nativeExplanation struct { + path configkit.Path + value configkit.Explanation + found bool +} + +func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) { + reader.path = path + return reader.value, reader.found +} + +func (reader *nativeExplanation) WriteExplanation(writer io.Writer, path configkit.Path) error { + reader.path = path + _, err := fmt.Fprintf(writer, "Explanation for %q\n", path.String()) + return err +} + +type failingOutput struct{ err error } + +func (writer failingOutput) Write(value []byte) (int, error) { + if len(value) == 0 { + return 0, writer.err + } + return 1, writer.err +} + +func newConfigTestApp(t *testing.T, command commandkit.Command) *commandkit.App { + t.Helper() + app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) + if err != nil { + t.Fatal(err) + } + return app +} diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index 2900b69..c540a9e 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -2,6 +2,7 @@ package ckconfig import ( "fmt" + "strings" commandkit "gitea.maximumdirect.net/eric/CommandKit" configkit "gitea.maximumdirect.net/eric/ConfigKit" diff --git a/integrations/ckconfig/operations.go b/integrations/ckconfig/operations.go new file mode 100644 index 0000000..cb5ca98 --- /dev/null +++ b/integrations/ckconfig/operations.go @@ -0,0 +1,47 @@ +package ckconfig + +import ( + "errors" + "fmt" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" +) + +func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome { + if input == nil || input.plan == nil { + return commandkit.Failure(errors.New("configuration operation was not prepared")) + } + if err := invocation.Context.Err(); err != nil { + return commandkit.Failure(err) + } + plan := input.plan + var state S + if plan.prepared.newState != nil { + state = plan.prepared.newState() + } + switch operation { + case Validate: + return runConfigValidation(invocation, plan, state) + case Show: + return runConfigShow(invocation, plan, state) + case Sources: + return runConfigSources(invocation, plan, state) + case Explain: + return runConfigExplanation(invocation, plan, state) + default: + return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation)) + } +} + +func configOperationFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome { + outcome := commandkit.Failure(cause) + outcome.Diagnostics = diagnostics + outcome.ReportIncomplete = reportStarted + return outcome +} + +func configDeliveryFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome { + outcome := configOperationFailure(cause, diagnostics, reportStarted) + outcome.DeliveryError = cause + return outcome +} diff --git a/integrations/ckconfig/show.go b/integrations/ckconfig/show.go index b4d47fe..5cc02f7 100644 --- a/integrations/ckconfig/show.go +++ b/integrations/ckconfig/show.go @@ -4,69 +4,48 @@ import ( "bytes" "errors" "fmt" + "io" "strconv" "unicode" -) -func newConfigShowCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) { - spec := CommandSpec[configInput[C]]{ - Name: string(ConfigShow), - Summary: "Show effective configuration as YAML.", - Flags: configInputFlags(prepared.suite, "yaml"), - CheckInput: func(input configInput[C]) error { - _, err := preflightConfig(prepared, input, ConfigShow) - return err - }, - Run: func(invocation Invocation, input configInput[C]) Outcome { - return runConfigShow(invocation, prepared, input) - }, - } - return NewCommand(spec) -} + commandkit "gitea.maximumdirect.net/eric/CommandKit" +) type effectiveDocument struct { name string - view ConfigView + view View } -func runConfigShow[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome { - if err := invocation.Context.Err(); err != nil { - return Failure(err) - } - scopeNames, err := selectConfigScopes(prepared, input, ConfigShow) - if err != nil { - return Failure(err) - } - contexts := contextsForScopes(prepared, input, scopeNames) - documents := make([]effectiveDocument, 0, len(scopeNames)) - diagnostics := make([]Diagnostic, 0) - for _, name := range scopeNames { - scope, ok := findConfigScope(prepared, name) - if !ok || scope.Acquire == nil { - return showFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, false) - } +func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { + documents := make([]effectiveDocument, 0, len(plan.scopes)) + diagnostics := make([]commandkit.Diagnostic, 0) + for _, selected := range plan.scopes { if err := invocation.Context.Err(); err != nil { - return showFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } - acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigShow, Selection: input.Selection}, contexts[name]) + if selected.scope.scope.Acquire == nil { + return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, false) + } + acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ + Operation: Show, + Selection: plan.selection, + }, selected.input, state) diagnostics = append(diagnostics, acquired.Diagnostics...) if acquireErr != nil { - return showFailure(acquireErr, diagnostics, false) + return configOperationFailure(acquireErr, diagnostics, false) } if err := invocation.Context.Err(); err != nil { - return showFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } - if acquired.Value.Effective == nil { - return showFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", name), diagnostics, false) + return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", selected.scope.scope.Name), diagnostics, false) } - documents = append(documents, effectiveDocument{name: name, view: acquired.Value}) + documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value}) } - if err := invocation.Context.Err(); err != nil { - return showFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } - if len(documents) == 1 && input.Scope != "all" { + if len(documents) == 1 && plan.scopeName != "all" { checked := &checkedWriter{writer: invocation.Streams.Out} renderErr := documents[0].view.Effective.WriteEffective(checked) if checked.failure != nil { @@ -77,11 +56,11 @@ func runConfigShow[C any](invocation Invocation, prepared *preparedConfigSuite[C } } if renderErr != nil { - outcome := showFailure(renderErr, diagnostics, true) + outcome := configOperationFailure(renderErr, diagnostics, true) outcome.DeliveryError = checked.failure return outcome } - outcome := Success() + outcome := commandkit.Success() outcome.Diagnostics = diagnostics return outcome } @@ -89,38 +68,53 @@ func runConfigShow[C any](invocation Invocation, prepared *preparedConfigSuite[C written := false for index, document := range documents { if err := invocation.Context.Err(); err != nil { - return showFailure(err, diagnostics, written) + return configOperationFailure(err, diagnostics, written) } var native bytes.Buffer if err := document.view.Effective.WriteEffective(&native); err != nil { - return showFailure(err, diagnostics, written) + return configOperationFailure(err, diagnostics, written) } if err := invocation.Context.Err(); err != nil { - return showFailure(err, diagnostics, written) + return configOperationFailure(err, diagnostics, written) } framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes()) if index > 0 && hasDirective { - if err := writeReport(invocation.Streams.Out, []byte("...\n")); err != nil { - outcome := showFailure(err, diagnostics, true) - outcome.DeliveryError = err - return outcome + if err := writeConfigReport(invocation.Streams.Out, []byte("...\n")); err != nil { + return configDeliveryFailure(err, diagnostics, true) } } if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' { framed = append(framed, '\n') } - if err := writeReport(invocation.Streams.Out, framed); err != nil { - outcome := showFailure(err, diagnostics, true) - outcome.DeliveryError = err - return outcome + if err := writeConfigReport(invocation.Streams.Out, framed); err != nil { + return configDeliveryFailure(err, diagnostics, true) } written = true } - outcome := Success() + outcome := commandkit.Success() outcome.Diagnostics = diagnostics return outcome } +type checkedWriter struct { + writer io.Writer + failure error +} + +func (writer *checkedWriter) Write(content []byte) (int, error) { + if writer.failure != nil { + return 0, writer.failure + } + written, err := writer.writer.Write(content) + if err == nil && written != len(content) { + err = io.ErrShortWrite + } + if err != nil { + writer.failure = err + } + return written, err +} + type yamlPreamble struct { markerStart int markerEnd int @@ -166,8 +160,8 @@ func analyzeYAMLPreamble(native []byte) yamlPreamble { func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) { analysis := analyzeYAMLPreamble(native) label := scope - for _, r := range scope { - if unicode.IsControl(r) { + for _, character := range scope { + if unicode.IsControl(character) { label = strconv.Quote(scope) break } @@ -175,8 +169,6 @@ func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) { comment := []byte("# scope: " + label + "\n") if analysis.markerFound { out := make([]byte, 0, len(native)+len(comment)) - // Inline content may be a block scalar; place the label before - // its marker rather than interrupting the native value. if analysis.inlineMarker { out = append(out, native[:analysis.markerStart]...) out = append(out, comment...) @@ -205,13 +197,6 @@ func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) { return result, analysis.hasDirective } -func showFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome { - outcome := Failure(cause) - outcome.Diagnostics = diagnostics - outcome.ReportIncomplete = reportStarted - return outcome -} - func isYAMLDocumentStart(line []byte) bool { return bytes.HasPrefix(line, []byte("---")) && (len(line) == 3 || line[3] == ' ' || line[3] == '\t') } diff --git a/integrations/ckconfig/show_framing_test.go b/integrations/ckconfig/show_framing_test.go new file mode 100644 index 0000000..33d2eaf --- /dev/null +++ b/integrations/ckconfig/show_framing_test.go @@ -0,0 +1,95 @@ +package ckconfig + +import ( + "bytes" + "io" + "reflect" + "strings" + "testing" + + "go.yaml.in/yaml/v4" +) + +func TestEffectiveYAMLFramingPreservesOneNativeDocument(t *testing.T) { + for _, native := range []string{ + "--- {setting: value}\n", "---\t{setting: value}\n", "--- value\n", + "--- |\n first\n second\n", "--- # authored\nsetting: value\n", + "setting: value\n", "%YAML 1.1\n--- {setting: value}\n", + } { + t.Run(native, func(t *testing.T) { + var want any + if err := yaml.Unmarshal([]byte(native), &want); err != nil { + t.Fatal(err) + } + framed, _ := frameEffectiveYAML("app", []byte(native)) + withoutLabel := bytes.Replace(framed, []byte("# scope: app\n"), nil, 1) + if !strings.HasPrefix(native, "---") && !strings.HasPrefix(native, "%") { + withoutLabel = bytes.TrimPrefix(withoutLabel, []byte("---\n")) + } + if !bytes.Equal(withoutLabel, []byte(native)) { + t.Fatalf("native content changed: %q", framed) + } + if !bytes.Contains(framed, []byte("# scope: app\n")) { + t.Fatalf("missing label: %q", framed) + } + decoder := yaml.NewDecoder(bytes.NewReader(framed)) + var got any + if err := decoder.Decode(&got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got=%#v want=%#v", got, want) + } + if err := decoder.Decode(new(any)); err != io.EOF { + t.Fatalf("extra document: %v", err) + } + }) + } +} + +func TestEffectiveYAMLFramingPreservesBoundariesAndDirectives(t *testing.T) { + for _, test := range []struct { + name string + scope string + native string + directive bool + label string + }{ + {name: "no trailing newline", scope: "app", native: "value: plain", label: "# scope: app\n"}, + {name: "CRLF", scope: "app", native: "# authored\r\n---\r\nvalue: yes\r\n", label: "# scope: app\n"}, + {name: "directive preamble", scope: "app", native: "%YAML 1.1\n# authored\n---\nvalue: yes", directive: true, label: "# scope: app\n"}, + {name: "control character label", scope: "line\nbreak", native: "value: yes", label: "# scope: \"line\\nbreak\"\n"}, + } { + t.Run(test.name, func(t *testing.T) { + var want any + if err := yaml.Unmarshal([]byte(test.native), &want); err != nil { + t.Fatal(err) + } + framed, hasDirective := frameEffectiveYAML(test.scope, []byte(test.native)) + if hasDirective != test.directive { + t.Fatalf("hasDirective = %t, want %t", hasDirective, test.directive) + } + withoutLabel := bytes.Replace(framed, []byte(test.label), nil, 1) + if !strings.HasPrefix(test.native, "---") && !strings.HasPrefix(test.native, "%") { + withoutLabel = bytes.TrimPrefix(withoutLabel, []byte("---\n")) + } + if !bytes.Equal(withoutLabel, []byte(test.native)) { + t.Fatalf("native content changed: got %q want %q", withoutLabel, test.native) + } + if !bytes.Contains(framed, []byte(test.label)) { + t.Fatalf("missing scope label %q in %q", test.label, framed) + } + decoder := yaml.NewDecoder(bytes.NewReader(framed)) + var first any + if err := decoder.Decode(&first); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(first, want) { + t.Fatalf("decoded value = %#v, want %#v", first, want) + } + if err := decoder.Decode(new(any)); err != io.EOF { + t.Fatalf("unexpected extra YAML document: %v", err) + } + }) + } +} diff --git a/integrations/ckconfig/sources.go b/integrations/ckconfig/sources.go index 59ba4cf..710c3fe 100644 --- a/integrations/ckconfig/sources.go +++ b/integrations/ckconfig/sources.go @@ -3,24 +3,13 @@ package ckconfig import ( "bytes" "fmt" + "io" "strconv" -) -func newConfigSourcesCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) { - spec := CommandSpec[configInput[C]]{ - Name: string(ConfigSources), - Summary: "List configuration sources used by a scope.", - Flags: configInputFlags(prepared.suite, "text"), - CheckInput: func(input configInput[C]) error { - _, err := preflightConfig(prepared, input, ConfigSources) - return err - }, - Run: func(invocation Invocation, input configInput[C]) Outcome { - return runConfigSources(invocation, prepared, input) - }, - } - return NewCommand(spec) -} + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "go.yaml.in/yaml/v4" +) type sourceReport struct { Scope string `yaml:"scope"` @@ -43,35 +32,30 @@ type locationDTO struct { Column int `yaml:"column"` } -func runConfigSources[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome { +func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { + selected := plan.scopes[0] + name := selected.scope.scope.Name if err := invocation.Context.Err(); err != nil { - return Failure(err) + return configOperationFailure(err, nil, false) } - scopeNames, err := selectConfigScopes(prepared, input, ConfigSources) - if err != nil { - return Failure(err) + if selected.scope.scope.Acquire == nil { + return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false) } - contexts := contextsForScopes(prepared, input, scopeNames) - name := scopeNames[0] - scope, ok := findConfigScope(prepared, name) - if !ok || scope.Acquire == nil { - return sourceReportFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false) - } - if err := invocation.Context.Err(); err != nil { - return sourceReportFailure(err, nil, false) - } - acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigSources, Selection: input.Selection}, contexts[name]) - diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...) + acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ + Operation: Sources, + Selection: plan.selection, + }, selected.input, state) + diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...) if acquireErr != nil { - return sourceReportFailure(acquireErr, diagnostics, false) + return configOperationFailure(acquireErr, diagnostics, false) } if err := invocation.Context.Err(); err != nil { - return sourceReportFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) + } + if acquired.Value.Sources == nil { + return configOperationFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false) } - if acquired.Value.Sources == nil { - return sourceReportFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false) - } sources := acquired.Value.Sources.Sources() document := sourceReport{Scope: name, Sources: make([]sourceDTO, 0, len(sources))} for _, source := range sources { @@ -88,24 +72,21 @@ func runConfigSources[C any](invocation Invocation, prepared *preparedConfigSuit } document.Sources = append(document.Sources, item) } - var report bytes.Buffer - if input.Format == "yaml" { + if plan.format == "yaml" { if err := encodeYAML(&report, document); err != nil { - return sourceReportFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } } else { writeSourceReportText(&report, document) } if err := invocation.Context.Err(); err != nil { - return sourceReportFailure(err, diagnostics, false) + return configOperationFailure(err, diagnostics, false) } - if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil { - outcome := sourceReportFailure(err, diagnostics, true) - outcome.DeliveryError = err - return outcome + if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil { + return configDeliveryFailure(err, diagnostics, true) } - outcome := Success() + outcome := commandkit.Success() outcome.Diagnostics = diagnostics return outcome } @@ -133,9 +114,16 @@ func writeSourceReportText(writer *bytes.Buffer, document sourceReport) { } } -func sourceReportFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome { - outcome := Failure(cause) - outcome.Diagnostics = diagnostics - outcome.ReportIncomplete = reportStarted - return outcome +func encodeYAML(writer io.Writer, document any) error { + encoder := yaml.NewEncoder(writer) + encoder.SetIndent(2) + return encoder.Encode(document) +} + +func locationReports(locations []configkit.Location) []locationDTO { + report := make([]locationDTO, 0, len(locations)) + for _, location := range locations { + report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column}) + } + return report } diff --git a/integrations/ckconfig/validate.go b/integrations/ckconfig/validate.go index e1a9138..bfdb69b 100644 --- a/integrations/ckconfig/validate.go +++ b/integrations/ckconfig/validate.go @@ -5,93 +5,51 @@ import ( "fmt" "strings" + commandkit "gitea.maximumdirect.net/eric/CommandKit" "go.yaml.in/yaml/v4" ) -func configOperationDeclared[C any](scopes []ConfigScope[C], operation ConfigOperation) bool { - for _, scope := range scopes { - if containsOperation(scope.Operations, operation) { - return true - } - } - return false -} - -func newConfigValidateCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) { - spec := CommandSpec[configInput[C]]{ - Name: string(ConfigValidate), - Summary: "Validate selected configuration scopes.", - Flags: configInputFlags(prepared.suite, "text"), - CheckInput: func(input configInput[C]) error { - _, err := preflightConfig(prepared, input, ConfigValidate) - return err - }, - Run: func(invocation Invocation, input configInput[C]) Outcome { - return runConfigValidation(invocation, prepared, input) - }, - } - return NewCommand(spec) -} - -func runConfigValidation[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome { - if err := invocation.Context.Err(); err != nil { - return Failure(err) - } - scopeNames, err := selectConfigScopes(prepared, input, ConfigValidate) - if err != nil { - return Failure(err) - } - contexts := contextsForScopes(prepared, input, scopeNames) - diagnostics := make([]Diagnostic, 0) +func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { + diagnostics := make([]commandkit.Diagnostic, 0) writtenRecord := false - for _, name := range scopeNames { - scope, ok := findConfigScope(prepared, name) - if !ok || scope.Acquire == nil { - return validationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, writtenRecord) - } + for _, selected := range plan.scopes { if err := invocation.Context.Err(); err != nil { - return validationFailure(err, diagnostics, writtenRecord) + return configOperationFailure(err, diagnostics, writtenRecord) } - acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigValidate, Selection: input.Selection}, contexts[name]) + if selected.scope.scope.Acquire == nil { + return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, writtenRecord) + } + acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ + Operation: Validate, + Selection: plan.selection, + }, selected.input, state) diagnostics = append(diagnostics, acquired.Diagnostics...) if acquireErr != nil { - return validationFailure(acquireErr, diagnostics, writtenRecord) + return configOperationFailure(acquireErr, diagnostics, writtenRecord) } - if err := invocation.Context.Err(); err != nil { - return validationFailure(err, diagnostics, writtenRecord) + return configOperationFailure(err, diagnostics, writtenRecord) } - record, err := validationSuccessRecord(name, input.Format) + record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format) if err != nil { - return validationFailure(err, diagnostics, writtenRecord) + return configOperationFailure(err, diagnostics, writtenRecord) } - if input.Format == "yaml" && !writtenRecord { - if err := writeReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil { - outcome := validationFailure(err, diagnostics, true) - outcome.DeliveryError = err - return outcome + if plan.format == "yaml" && !writtenRecord { + if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil { + return configDeliveryFailure(err, diagnostics, true) } } - if err := writeReport(invocation.Streams.Out, record); err != nil { - outcome := validationFailure(err, diagnostics, true) - outcome.DeliveryError = err - return outcome + if err := writeConfigReport(invocation.Streams.Out, record); err != nil { + return configDeliveryFailure(err, diagnostics, true) } writtenRecord = true } - outcome := Success() + outcome := commandkit.Success() outcome.Diagnostics = diagnostics return outcome } -func validationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome { - outcome := Failure(cause) - outcome.Diagnostics = diagnostics - outcome.ReportIncomplete = reportStarted - return outcome -} - func validationSuccessRecord(scope, format string) ([]byte, error) { if format == "text" { return []byte(scope + ": valid\n"), nil