diff --git a/README.md b/README.md index dbde344..bbf2474 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ and shared command infrastructure across related applications. The current Go library provides an instance-owned command registry, typed flag and operand bindings, Cobra-backed routing, invocation outcomes, generated help, -and application or CommandKit version reporting. Standard configuration and -PromptKit command families remain planned. +application or CommandKit version reporting, configuration scope discovery, and +ConfigKit-backed validation. Effective display, source and explanation reports, +and PromptKit commands remain planned. ## Documentation diff --git a/config.go b/config.go index 3ce7f91..2111e42 100644 --- a/config.go +++ b/config.go @@ -2,9 +2,11 @@ package commandkit import ( "context" + "errors" "fmt" "io" "sort" + "strconv" "strings" configkit "gitea.maximumdirect.net/eric/ConfigKit" @@ -129,11 +131,22 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error { return fmt.Errorf("command path %q is already registered", "config") } - command, err := newConfigScopesCommandWithScopes(normalized.Scopes, nil) + commands := make([]Command, 0, 2) + installed := make([]ConfigOperation, 0, 1) + if configOperationDeclared(normalized.Scopes, ConfigValidate) { + validate, err := newConfigValidateCommand(normalized) + if err != nil { + return err + } + commands = append(commands, validate) + installed = append(installed, ConfigValidate) + } + command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed) if err != nil { return err } - root, err := Group("config", "Inspect configuration.", command) + commands = append(commands, command) + root, err := Group("config", "Inspect configuration.", commands...) if err != nil { return err } @@ -144,6 +157,80 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error { return nil } +// NewConfigKitScope adapts a ConfigKit loader into a configuration scope. +func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) { + if load == nil { + return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil") + } + return ConfigScope[C]{ + Name: name, + Summary: summary, + Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain}, + Acquire: func(ctx context.Context, request ConfigRequest, contextValue C) (Acquired[ConfigView], error) { + result, err := load(ctx, request.Selection, contextValue) + if err != nil { + var loadError *configkit.LoadError + if errors.As(err, &loadError) { + return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err + } + return Acquired[ConfigView]{}, err + } + if result == nil { + return Acquired[ConfigView]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name) + } + return Acquired[ConfigView]{ + Value: ConfigView{Effective: result, Sources: result, Explanation: result}, + Diagnostics: ConfigKitDiagnostics(name, result.Sources(), result.Diagnostics()), + }, nil + }, + }, nil +} + +// ConfigKitDiagnostics groups ConfigKit records with their scope and source legend. +func ConfigKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []Diagnostic { + if len(records) == 0 { + return nil + } + severity := configkit.SeverityWarning + for _, record := range records { + if record.Severity == configkit.SeverityError { + severity = configkit.SeverityError + break + } + } + return []Diagnostic{{ + Severity: severity, + Write: func(writer io.Writer) error { + if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil { + return err + } + if len(sources) > 0 { + if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil { + return err + } + for _, source := range sources { + line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n" + if err := writeDiagnosticText(writer, line); err != nil { + return err + } + } + } + return configkit.WriteDiagnostics(writer, records) + }, + }} +} + +func writeDiagnosticText(writer io.Writer, value string) error { + written, err := io.WriteString(writer, value) + if err != nil { + return err + } + if written != len(value) { + return io.ErrShortWrite + } + return nil +} + func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) { normalized := suite normalized.Selectors = append([]Selector[C](nil), suite.Selectors...) diff --git a/config_test.go b/config_test.go index dfdb110..14dc877 100644 --- a/config_test.go +++ b/config_test.go @@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) { if outcome.Kind != OutcomeSuccess { t.Fatalf("text inventory outcome = %#v", outcome) } - wantText := "app:\npromptkit:\nmetadata:\nzeta:\n" + wantText := "app: validate\npromptkit:\nmetadata:\nzeta:\n" if textOutput.String() != wantText { t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText) } diff --git a/configkit_test.go b/configkit_test.go new file mode 100644 index 0000000..3eb9070 --- /dev/null +++ b/configkit_test.go @@ -0,0 +1,242 @@ +package commandkit_test + +import ( + "bytes" + "context" + "errors" + "io" + "reflect" + "strings" + "testing" + "testing/fstest" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestConfigKitScopeUsesDefaultsOverlayAndRecordedResolution(t *testing.T) { + type configuration struct { + Mode string `yaml:"mode"` + Defaulted string `yaml:"defaulted"` + } + filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{ + "config/main.yml": &fstest.MapFile{Data: []byte("mode: base\n")}, + "config/overlays/dev.yml": &fstest.MapFile{Data: []byte("mode: overlay\n")}, + }) + definition := configkit.Definition[configuration]{ + Defaults: func() configuration { return configuration{Defaulted: "from default"} }, + Resolve: func(input *configuration, resolution *configkit.Resolution) error { + resolution.Record(configkit.Keys("mode"), configkit.ChangeNormalization, "normalized for use") + input.Mode = strings.ToUpper(input.Mode) + return nil + }, + } + var received commandkit.Selection + scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "Application settings.", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) { + received = selection + return configkit.LoadLayered(configkit.Input{Source: filesystem.File(selection.Config.Value)}, configkit.LayeredOptions[configuration]{ + Definition: definition, + Overlay: selection.Overlay.Value, + OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem}, + }) + }) + if err != nil { + t.Fatal(err) + } + selection := commandkit.Selection{ + Config: commandkit.Supplied[string]{Value: "config/main.yml", Set: true}, + Overlay: commandkit.Supplied[string]{Value: "dev", Set: true}, + } + acquired, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{Operation: commandkit.ConfigValidate, Selection: selection}, struct{}{}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(received, selection) { + t.Fatalf("loader selection = %#v, want %#v", received, selection) + } + if acquired.Value.Effective == nil || acquired.Value.Sources == nil || acquired.Value.Explanation == nil { + t.Fatalf("ConfigKit result facets = %#v", acquired.Value) + } + if got := acquired.Value.Sources.Sources(); len(got) < 2 { + t.Fatalf("sources = %#v, want base and overlay", got) + } + var effective bytes.Buffer + if err := acquired.Value.Effective.WriteEffective(&effective); err != nil { + t.Fatal(err) + } + if !strings.Contains(effective.String(), "mode: OVERLAY") || !strings.Contains(effective.String(), "defaulted: from default") { + t.Fatalf("effective configuration = %q", effective.String()) + } + if explanation, ok := acquired.Value.Explanation.Explain(configkit.Keys("mode")); !ok || len(explanation.History) == 0 { + t.Fatalf("resolution explanation = %#v, present=%t", explanation, ok) + } +} + +func TestConfigKitScopePreservesAbsentAndEmptySelections(t *testing.T) { + type configuration struct { + Value string `yaml:"value"` + } + var seen []commandkit.Selection + scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) { + seen = append(seen, selection) + return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{Defaults: func() configuration { return configuration{Value: "default"} }}}) + }) + if err != nil { + t.Fatal(err) + } + _, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}) + if err != nil { + t.Fatal(err) + } + empty := commandkit.Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: commandkit.Supplied[string]{Set: true}} + _, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{Selection: empty}, struct{}{}) + if err != nil { + t.Fatal(err) + } + want := []commandkit.Selection{{}, empty} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("loader selections = %#v, want %#v", seen, want) + } +} + +func TestConfigKitScopeRequiresLoaderAndNonNilResult(t *testing.T) { + if _, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", nil); err == nil { + t.Fatal("accepted a nil ConfigKit loader") + } + scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) { + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if _, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}); err == nil || !strings.Contains(err.Error(), "nil result") { + t.Fatalf("nil result error = %v", err) + } +} + +func TestConfigKitLoadErrorRetainsIdentityAndStructuredDiagnostics(t *testing.T) { + type configuration struct { + Name string `yaml:"name"` + } + var loadError error + scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) { + _, loadError = configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{}}) + return nil, loadError + }) + if err != nil { + t.Fatal(err) + } + acquired, acquireError := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}) + var publicLoadError *configkit.LoadError + if acquireError != loadError || !errors.As(acquireError, &publicLoadError) { + t.Fatalf("load error identity = %v (original %v), public type=%T", acquireError, loadError, acquireError) + } + if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != configkit.SeverityError { + t.Fatalf("structured diagnostics = %#v", acquired.Diagnostics) + } + var rendered bytes.Buffer + if err := acquired.Diagnostics[0].Write(&rendered); err != nil { + t.Fatal(err) + } + if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) { + t.Fatalf("grouped diagnostics = %q", rendered.String()) + } + + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil { + t.Fatal(err) + } + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &output, Err: &diagnostic}) + if outcome.Cause != loadError || len(outcome.Diagnostics) != 1 || strings.Count(diagnostic.String(), `Configuration scope "app":`) != 1 || output.Len() != 0 { + t.Fatalf("presented load failure outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String()) + } +} + +func TestConfigValidationEmitsSuccessAsScopesCompleteAndStopsAtFailure(t *testing.T) { + app, err := commandkit.New(commandkit.Identity{Name: "example"}) + if err != nil { + t.Fatal(err) + } + var calls []string + newContextCalls := 0 + failure := errors.New("promptkit could not load") + suite := commandkit.ConfigSuite[string]{ + NewContext: func() string { newContextCalls++; return "runtime" }, + Scopes: []commandkit.ConfigScope[string]{ + {Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) { + calls = append(calls, "zeta") + return commandkit.Acquired[commandkit.ConfigView]{}, nil + }}, + {Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) { + calls = append(calls, "promptkit") + return commandkit.Acquired[commandkit.ConfigView]{}, failure + }}, + {Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) { + calls = append(calls, "app") + return commandkit.Acquired[commandkit.ConfigView]{}, nil + }}, + }, + } + if err := commandkit.RegisterConfig(app, suite); err != nil { + t.Fatal(err) + } + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: &diagnostic}) + if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, failure) || !outcome.ReportIncomplete { + t.Fatalf("validation outcome = %#v", outcome) + } + if !reflect.DeepEqual(calls, []string{"app", "promptkit"}) || newContextCalls != 1 { + t.Fatalf("acquisitions = %v, NewContext calls=%d", calls, newContextCalls) + } + if output.String() != "app: valid\n" || !strings.Contains(diagnostic.String(), failure.Error()) { + t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String()) + } +} + +func TestConfigValidationYAMLWarningsAndShortWrites(t *testing.T) { + type configuration struct { + Name string `yaml:"name"` + } + warningScope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) { + return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{ + Validate: func(_ *configuration, validation *configkit.Validation) { + validation.Warning(configkit.Keys("name"), "uses a demonstration name") + }, + }}) + }) + if err != nil { + t.Fatal(err) + } + app, _ := commandkit.New(commandkit.Identity{Name: "example"}) + if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{warningScope}}); err != nil { + t.Fatal(err) + } + var output, diagnostic bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "validate", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: &diagnostic}) + if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete { + t.Fatalf("warning validation outcome = %#v", outcome) + } + if output.String() != "scopes:\n - name: app\n valid: true\n" || !strings.Contains(diagnostic.String(), "uses a demonstration name") { + t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String()) + } + + outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: configKitPartialWriter{}, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) { + t.Fatalf("report short write outcome = %#v", outcome) + } + var delivered bytes.Buffer + outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &delivered, Err: configKitPartialWriter{}}) + if outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) { + t.Fatalf("diagnostic short write outcome = %#v", outcome) + } +} + +type configKitPartialWriter struct{} + +func (configKitPartialWriter) Write(data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } + return len(data) - 1, nil +} diff --git a/docs/cli.md b/docs/cli.md index 034cb5a..cd98d20 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,6 +102,29 @@ runtime context or acquire configuration. It reports the intersection between declared scope capabilities and installed handlers, so an operation is listed only when the corresponding command is available. +### Validate configuration + +`config validate` loads and validates the selected scope. The default scope is +`app`; `--scope all` validates every registered scope and is available when each +scope declares validation. The command checks all selected capabilities before +calling scope providers. +Its default format is text; `--format yaml` emits a `scopes` sequence with +`name` and `valid: true` for each scope that completed successfully. + +```text +example config validate +example config validate --scope promptkit --format yaml +example config validate --scope all +``` + +Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops +at the first load or output failure; records from earlier successful scopes may +already have been written. Provider warnings go to the diagnostic stream and do +not alone make validation fail. `--config` and `--overlay` preserve whether an +empty value was explicitly supplied, so a scope callback can map those inputs to +its own ConfigKit load policy. Help and invalid syntax do not call scope +providers. + ## Outcome status CommandKit usage failures use status `2`; operational failures use status `1`. diff --git a/docs/development.md b/docs/development.md index c7ff5b7..ec45bb2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -4,8 +4,9 @@ CommandKit currently contains adopted repository policies, accepted architectura decisions, implementation plans, identity and outcome primitives, typed command declarations, an instance-owned registry, a Cobra-backed routing and parsing adapter, public `App.Execute` coordination, and generated help/version commands. -Configuration suite registration and scope discovery are implemented; the -configuration operations remain pending. The +Configuration suite registration, scope discovery, ConfigKit integration, and +validation are implemented; other configuration and PromptKit operations remain +pending. The [README](../README.md) provides project orientation; the [documentation policy](policy/documentation.md#canonical-ownership) assigns canonical documentation owners. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6294f55..2ef6743 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Initial CommandKit implementation plan -Status: implementation underway; Stages 1-7 complete and Stages 8-14 pending. +Status: implementation underway; Stages 1-8 complete and Stages 9-14 pending. Date: 2026-10-07. @@ -551,7 +551,7 @@ sequenced in Stages 8-11. ## Stage 8: integrate ConfigKit and implement validation -**Status:** pending. **Prerequisite:** Stage 7. +**Status:** complete. **Prerequisite:** Stage 7. ```go func NewConfigKitScope[C, T any](name, summary string, @@ -594,6 +594,16 @@ empty overlay callback input; warnings permit validation success; structured loa errors retain public identity and render once; all validates in order and stops on failure. Report/diagnostic short writes retain their writer errors. +**Completion evidence:** added the ConfigKit scope adapter and grouped diagnostic +writer. The adapter exposes native effective/source/explanation facets, captures +success and `LoadError` diagnostics, and preserves loader errors. `config validate` +now installs for declared scopes, preflights all requested capabilities, acquires +in stable order, and emits success records incrementally in text or YAML. Checks +cover ConfigKit defaults, overlay, recorded resolution, absent/empty selections, +warning success, load-error identity and single presentation, aggregate stop +behavior, and report/diagnostic short writes. `go test ./...` passes; README, +development orientation, and CLI reference describe the implemented boundary. + ## Stage 9: implement native effective YAML display **Status:** pending. **Prerequisite:** Stage 8. diff --git a/selectors.go b/selectors.go index 906afcb..f88543f 100644 --- a/selectors.go +++ b/selectors.go @@ -64,6 +64,19 @@ func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] { func parseString(value string) (string, error) { return value, nil } func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) { + scopes, err := selectConfigScopes(suite, input, operation) + if err != nil { + return nil, err + } + if suite.CheckInput != nil { + if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil { + return nil, err + } + } + return scopes, nil +} + +func selectConfigScopes[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) { if err := checkConfigFormat(input.Format); err != nil { return nil, err } @@ -114,11 +127,6 @@ func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operatio return nil, fmt.Errorf("selector is not applicable to configuration scope %q", input.Scope) } } - if suite.CheckInput != nil { - if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil { - return nil, err - } - } return scopes, nil } diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..e46492f --- /dev/null +++ b/validate.go @@ -0,0 +1,104 @@ +package commandkit + +import ( + "bytes" + "fmt" + "strings" + + "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](suite ConfigSuite[C]) (Command, error) { + spec := CommandSpec[configInput[C]]{ + Name: string(ConfigValidate), + Summary: "Validate selected configuration scopes.", + Flags: configInputFlags(suite, "text"), + CheckInput: func(input configInput[C]) error { + _, err := preflightConfig(suite, input, ConfigValidate) + return err + }, + Run: func(invocation Invocation, input configInput[C]) Outcome { + return runConfigValidation(invocation, suite, input) + }, + } + return NewCommand(spec) +} + +func runConfigValidation[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome { + scopeNames, err := selectConfigScopes(suite, input, ConfigValidate) + if err != nil { + return Failure(err) + } + contexts := contextsForScopes(suite, input, scopeNames) + diagnostics := make([]Diagnostic, 0) + writtenRecord := false + for _, name := range scopeNames { + scope, ok := findConfigScope(suite.Scopes, name) + if !ok || scope.Acquire == nil { + return validationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, writtenRecord) + } + acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigValidate, Selection: input.Selection}, contexts[name]) + diagnostics = append(diagnostics, acquired.Diagnostics...) + if acquireErr != nil { + return validationFailure(acquireErr, diagnostics, writtenRecord) + } + + record, err := validationSuccessRecord(name, input.Format) + if err != nil { + return validationFailure(err, diagnostics, writtenRecord) + } + if input.Format == "yaml" && !writtenRecord { + if err := writeReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil { + return validationFailure(err, diagnostics, true) + } + } + if err := writeReport(invocation.Streams.Out, record); err != nil { + return validationFailure(err, diagnostics, true) + } + writtenRecord = true + } + outcome := 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 + } + encoded, err := yaml.Marshal(struct { + Name string `yaml:"name"` + Valid bool `yaml:"valid"` + }{Name: scope, Valid: true}) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSuffix(string(encoded), "\n"), "\n") + var record bytes.Buffer + for index, line := range lines { + if index == 0 { + record.WriteString(" - ") + } else { + record.WriteString(" ") + } + record.WriteString(line) + record.WriteByte('\n') + } + return record.Bytes(), nil +}