From 6c35da3ab6a76db8af2d430d3cda29d678dc8dd0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 7 Nov 2026 03:57:10 +0000 Subject: [PATCH] Move configuration reports to shared rendering --- docs/cli.md | 55 +++--- docs/consumers/guide.md | 4 +- docs/policy/testing.md | 4 +- docs/roadmap/implementation.md | 8 +- .../ckconfig/comparison_input_test.go | 23 +-- .../ckconfig/comparison_preparation_test.go | 6 +- integrations/ckconfig/completion_test.go | 2 +- integrations/ckconfig/diff.go | 2 +- integrations/ckconfig/diff_execution_test.go | 2 +- integrations/ckconfig/diff_report.go | 174 +++++------------- .../ckconfig/diff_report_render_test.go | 28 ++- integrations/ckconfig/diff_report_test.go | 123 ++----------- integrations/ckconfig/explain.go | 74 ++++---- integrations/ckconfig/family.go | 70 ++----- integrations/ckconfig/family_reports_test.go | 17 +- integrations/ckconfig/input.go | 23 --- integrations/ckconfig/operations.go | 10 +- .../ckconfig/report_cancellation_test.go | 8 +- integrations/ckconfig/shared_report_test.go | 152 +++++++++++++++ integrations/ckconfig/show.go | 147 +++------------ integrations/ckconfig/show_framing_test.go | 95 ---------- integrations/ckconfig/sources.go | 156 ++++++---------- integrations/ckconfig/validate.go | 99 ++++------ 23 files changed, 461 insertions(+), 821 deletions(-) create mode 100644 integrations/ckconfig/shared_report_test.go delete mode 100644 integrations/ckconfig/show_framing_test.go diff --git a/docs/cli.md b/docs/cli.md index 4d2cb58..eee881b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -316,8 +316,8 @@ PromptKit prompt or profile definition commands. `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. +Automatic output is concise text; structured formats emit a `scopes` sequence +with each reached scope and its outcome (`valid`, `failed`, or `canceled`). ```text example config validate @@ -326,19 +326,21 @@ 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 +at the first load failure or cancellation, then submits one report containing +the reached scopes. Output errors occur after acquisition has stopped. 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. ### Show effective configuration -`config show` writes the provider's native effective YAML for one scope. Its -default scope is `app`; `--format yaml` is accepted and YAML is the only initial -format. Single-scope output is passed through unchanged, without a CommandKit -header or document marker. +`config show` displays the provider's safe effective configuration. The default +scope is `app`. The native inspection YAML is converted to a logical report +value, preserving meaningful scalar types, empty collections, and redaction. +Automatic formatting uses text for a single scalar or nonempty string list and +YAML for structured values; `--format` selects text, YAML, or JSON. ```text example config show @@ -346,24 +348,17 @@ example config show --scope promptkit example config show --scope all ``` -`--scope all` acquires every selected scope and checks each effective writer -before writing output. It then emits one YAML document per scope in discovery -order, with a `# scope: NAME` comment at each document boundary. For ordinary -block documents the comment follows the marker. When a native opening marker -contains inline content, the comment precedes that marker so it does not -interrupt the native value. Existing native markers and leading comments are -retained; documents without a marker receive one. Scope labels containing line -control characters are quoted to keep them on one comment line. A provider -failure leaves the output stream empty. A rendering or output failure may leave -an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery -and the destination or rendering error. +`--scope all` acquires every selected scope before producing one mapping keyed +by scope name, including when only one scope is registered. A single selected +scope is unwrapped. A provider failure prevents report output. Conversion or +output failures are recorded in `Outcome.Presentation.Output`. ### List configuration sources `config sources` reports the source inventory captured during the selected scope's acquisition. It reads the source facet once and does not reload configuration. The default scope is `app`; `--scope all` is unsupported. Text is -the default format, and `--format yaml` returns a structured report: +the automatic format, and `--format yaml` or `--format json` returns a structured report: ```yaml scope: app @@ -415,8 +410,8 @@ for native evidence definitions. `config explain --path POINTER` explains the effective and historical value at a JSON Pointer path. `--path` is required; an explicitly empty value selects the -root. The default scope is `app`; `--format yaml` selects structured output, and -text is the default. `--scope all` is unsupported. +root. The default scope is `app`; `--format yaml` or `--format json` selects +structured output, and automatic output uses text. `--scope all` is unsupported. ```text example config explain --path /server/addr @@ -430,7 +425,7 @@ rejected before acquisition. A well-formed path that is unknown in the selected configuration is an operational error with status `1`. Text output comes from the scope's native explanation writer. -YAML output uses these root fields: +Structured output uses these root fields: ```yaml scope: app @@ -474,8 +469,8 @@ field is omitted, it inherits the corresponding left value and supplied state; an explicit empty string overrides it and remains supplied. Equal selections are allowed; their equality alone does not cause CommandKit to skip the provider. -YAML is the only format and the default. Explicit `--format yaml` is accepted; -`text` and other formats are usage errors. For example: +Automatic output is YAML. The shared `--format` also accepts YAML, JSON, and +text; text uses the same structured representation. For example: ```text example config diff --config before.yml --against-config after.yml @@ -487,9 +482,9 @@ example config diff --scope promptkit --config before.yml --against-config after For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values after defaults and resolution; source or provenance differences alone do not create a change. PromptKit delegates its native comparison to ConfigKit. The -report is one YAML document with `additions`, `changes`, and `deletions` in that -order. Additions and changes preserve semantic paths and contain right-side -values provided by the native comparison. Deletions contain canonical JSON +report contains `additions`, `changes`, and `deletions`. Additions and changes +preserve semantic paths and contain right-side values provided by the native +comparison. Deletions contain canonical JSON Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences and native structural replacement regions stay atomic. @@ -497,7 +492,7 @@ ConfigKit pairs sensitivity across both loads. A concealed change can therefore appear even when its two safe snapshots are equal. For those ConfigKit-backed comparisons, the report omits separate redaction and hidden-change metadata: a reported masked region changed, but that does not mean every member inside it -changed. Marker-looking public strings stay ordinary YAML values. An empty +changed. Marker-looking public strings stay ordinary strings. An empty comparison succeeds with this shape: ```yaml diff --git a/docs/consumers/guide.md b/docs/consumers/guide.md index b9d6d81..d5c73ee 100644 --- a/docs/consumers/guide.md +++ b/docs/consumers/guide.md @@ -114,7 +114,7 @@ static choices or is a filesystem path: ```go commandkit.FlagMeta{ - Name: "format", + Name: "view", Completion: commandkit.CompleteChoices( commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"}, @@ -246,7 +246,7 @@ timing, omitted-factory zero values (including nil pointers), and ownership. The application owns the state type and what it caches. `ckconfig.FromConfigKit` adapts a typed ConfigKit loader without calling it during construction. -The `config` family provides scope discovery, validation, native effective YAML, +The `config` family provides scope discovery, validation, safe effective configuration display, source inventory, path explanation, and ConfigKit-backed comparison for the capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes) owns operator syntax and report details. diff --git a/docs/policy/testing.md b/docs/policy/testing.md index 053971f..869c8ab 100644 --- a/docs/policy/testing.md +++ b/docs/policy/testing.md @@ -63,8 +63,8 @@ implemented: - Help and command discovery without provider loading or runtime construction. - Provider failures, cancellation, output errors, and preservation of application outcomes and public error identity. -- Selected inspection/report contracts, including native YAML and document order - for `config show --scope all`. +- Selected inspection/report contracts, including safe scalar preservation and + scope-keyed aggregation for `config show --scope all`. - Public type ownership and the internal Cobra/pflag import boundary selected by [ADR 0002](../adr/0002-public-facade-and-cli-backend.md). diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 4580cdd..a9d69e6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shared reporting and PromptKit v0.20.0 implementation plan -Status: implementation in progress; Stages 1–6 complete. +Status: implementation in progress; Stages 1–7 complete. ## Scope, authority, and execution @@ -250,6 +250,12 @@ ordinary `RepeatFlag` replacement semantics to simulate ordering. ## Stage 7: Migrate existing ckconfig reports to core +**Complete.** Existing ckconfig reports use shared rendering, including safe +effective values and reached-scope validation aggregation. Focused integration +tests and the root and ckconfig workspace suites passed with Go 1.27.0 using +`GOTOOLCHAIN=local go test ./... ./integrations/ckconfig/...`. The ckprompt +migration remains in the following stages. + Convert the existing `scopes`, `show`, `sources`, `explain`, `diff`, and concise `validate` report paths in [ckconfig](../../integrations/ckconfig) to the public handoff. Remove local format flags, validators, encoding switches, and encoders. diff --git a/integrations/ckconfig/comparison_input_test.go b/integrations/ckconfig/comparison_input_test.go index 7d12463..6d5aec9 100644 --- a/integrations/ckconfig/comparison_input_test.go +++ b/integrations/ckconfig/comparison_input_test.go @@ -83,20 +83,11 @@ func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) { if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") { t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp) } - if !strings.Contains(diffHelp, "default: yaml") { - t.Fatalf("diff help does not show the YAML default:\n%s", diffHelp) - } for _, operation := range []string{"validate", "show", "sources", "explain"} { operationHelp := help("config", operation, "--help") if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") { t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp) } - if operation == "show" && !strings.Contains(operationHelp, "default: yaml") { - t.Errorf("show format default changed:\n%s", operationHelp) - } - if operation != "show" && !strings.Contains(operationHelp, "default: text") { - t.Errorf("%s format default changed:\n%s", operation, operationHelp) - } if operation != "explain" && strings.Contains(operationHelp, "--path") { t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp) } @@ -107,7 +98,7 @@ func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) { } } -func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) { +func TestComparisonPreparation(t *testing.T) { inputCalls, stateCalls := 0, 0 prepared := captureSuite(Suite[struct{}, struct{}]{ NewInput: func() struct{} { inputCalls++; return struct{}{} }, @@ -123,12 +114,11 @@ func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) { input := &configCommandInput[struct{}, struct{}]{ selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}, against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}}, - format: commandkit.Supplied[string]{Value: "yaml", Set: true}, } if err := prepareConfigOperation(prepared, Diff, input); err != nil { t.Fatalf("explicit YAML format rejected: %v", err) } - if input.plan == nil || input.plan.format != "yaml" { + if input.plan == nil { t.Fatalf("comparison plan = %#v", input.plan) } if input.plan.scopeName != "app" { @@ -141,12 +131,6 @@ func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) { t.Fatalf("resolved right selection = %#v, want %#v", input.against, want) } - for _, format := range []string{"text", "json"} { - input := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: format}} - if err := prepareConfigOperation(prepared, Diff, input); err == nil { - t.Errorf("comparison format %q was accepted", format) - } - } if inputCalls != 2 || stateCalls != 0 { t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls) } @@ -170,8 +154,7 @@ func TestComparisonAvailabilityPreflightPrecedesFactories(t *testing.T) { }) for _, scope := range []string{"missing", "all", "plain"} { input := &configCommandInput[struct{}, struct{}]{ - scope: commandkit.Supplied[string]{Value: scope, Set: true}, - format: commandkit.Supplied[string]{Value: "yaml"}, + scope: commandkit.Supplied[string]{Value: scope, Set: true}, } if err := prepareConfigOperation(prepared, Diff, input); err == nil { t.Errorf("comparison scope %q was accepted", scope) diff --git a/integrations/ckconfig/comparison_preparation_test.go b/integrations/ckconfig/comparison_preparation_test.go index f841110..d5a22ef 100644 --- a/integrations/ckconfig/comparison_preparation_test.go +++ b/integrations/ckconfig/comparison_preparation_test.go @@ -191,7 +191,7 @@ func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) { }, }}, }) - commandInput := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} + commandInput := &configCommandInput[struct{}, struct{}]{} if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) { t.Fatalf("right-side input failure = %v", err) } @@ -217,7 +217,7 @@ func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) { }, }}, }) - commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} + commandInput := &configCommandInput[*int, struct{}]{} if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil { t.Fatal(err) } @@ -246,7 +246,7 @@ func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) { }, }}, }) - commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}} + commandInput := &configCommandInput[*int, struct{}]{} if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil { t.Fatal(err) } diff --git a/integrations/ckconfig/completion_test.go b/integrations/ckconfig/completion_test.go index bf12296..56b04c7 100644 --- a/integrations/ckconfig/completion_test.go +++ b/integrations/ckconfig/completion_test.go @@ -37,7 +37,7 @@ func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T }{ {[]string{"__complete", "config", "diff", "--scope="}, "app\n", "other\n"}, {[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\n"}, - {[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "text\n"}, + {[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "invalid\n"}, {[]string{"__complete", "config", "validate", "--format="}, "text\n", "invalid\n"}, {[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\n"}, } { diff --git a/integrations/ckconfig/diff.go b/integrations/ckconfig/diff.go index 02c3fb3..60603fe 100644 --- a/integrations/ckconfig/diff.go +++ b/integrations/ckconfig/diff.go @@ -30,7 +30,7 @@ func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOpera if err := invocation.Context.Err(); err != nil { return configFailure("Configuration comparison was canceled.", acquired.Explanation, err) } - return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) { + return renderPreparedConfigReport(invocation, acquired.Explanation, func() (any, error) { return renderDiffReport(acquired.Value) }) } diff --git a/integrations/ckconfig/diff_execution_test.go b/integrations/ckconfig/diff_execution_test.go index 8791045..e138d6e 100644 --- a/integrations/ckconfig/diff_execution_test.go +++ b/integrations/ckconfig/diff_execution_test.go @@ -60,7 +60,7 @@ func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *t loadsBeforeText := len(selections) output.Reset() outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard}) - if outcome.Kind != commandkit.OutcomeUsage || len(selections) != loadsBeforeText || output.Len() != 0 { + if outcome.Kind != commandkit.OutcomeSuccess || len(selections) != loadsBeforeText+2 || !strings.Contains(output.String(), "deletions:") { t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String()) } } diff --git a/integrations/ckconfig/diff_report.go b/integrations/ckconfig/diff_report.go index 4fe87b0..1fb01dc 100644 --- a/integrations/ckconfig/diff_report.go +++ b/integrations/ckconfig/diff_report.go @@ -1,26 +1,13 @@ package ckconfig import ( - "bytes" "fmt" "strings" + commandkit "go.maximumdirect.net/commandkit" configkit "go.maximumdirect.net/configkit" - "go.yaml.in/yaml/v4" ) -func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) { - var document yaml.Node - decoder := yaml.NewDecoder(strings.NewReader(snapshot)) - if err := decoder.Decode(&document); err != nil { - return nil, fmt.Errorf("decode safe YAML snapshot: %w", err) - } - if document.Kind != yaml.DocumentNode || len(document.Content) == 0 { - return nil, fmt.Errorf("decode safe YAML snapshot: document has no value") - } - return document.Content[0], nil -} - func decodeJSONPointer(pointer string) ([]string, error) { if pointer == "" { return nil, nil @@ -56,139 +43,68 @@ func decodeJSONPointer(pointer string) ([]string, error) { return parts, nil } -// yamlValuePlacer indexes each mapping once while assembling one report. -// Content remains the ordered representation used by the YAML encoder. -type yamlValuePlacer struct { - keys map[*yaml.Node]map[string]int -} - -func (placer *yamlValuePlacer) placeValue(category *yaml.Node, path []string, value *yaml.Node) (*yaml.Node, error) { - if value == nil { - return nil, fmt.Errorf("place YAML value: value node is nil") - } +func placeReportValue(root any, path []string, value any) (any, error) { if len(path) == 0 { return value, nil } - if category == nil || category.Kind != yaml.MappingNode { - return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1])) + current, ok := root.(map[string]any) + if !ok { + return nil, fmt.Errorf("report path ancestor is not a mapping") } - - current := category for index, token := range path { - keys := placer.mappingKeys(current) - keyIndex, exists := keys[token] if index == len(path)-1 { - if exists { - current.Content[keyIndex+1] = value - } else { - keys[token] = len(current.Content) - current.Content = append(current.Content, stringKey(token), value) - } - return category, nil + current[token] = value + return root, nil } - + child, exists := current[token] if !exists { - child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - keys[token] = len(current.Content) - current.Content = append(current.Content, stringKey(token), child) - current = child + next := make(map[string]any) + current[token] = next + current = next continue } - - child := current.Content[keyIndex+1] - if child.Kind != yaml.MappingNode { - return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1])) + next, ok := child.(map[string]any) + if !ok { + return nil, fmt.Errorf("report path ancestor %q is not a mapping", token) } - current = child + current = next } - return category, nil + return root, nil } -func (placer *yamlValuePlacer) mappingKeys(mapping *yaml.Node) map[string]int { - if keys, exists := placer.keys[mapping]; exists { - return keys - } - if placer.keys == nil { - placer.keys = make(map[*yaml.Node]map[string]int) - } - keys := make(map[string]int, len(mapping.Content)/2) - for index := 0; index+1 < len(mapping.Content); index += 2 { - candidate := mapping.Content[index] - if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" { - if _, exists := keys[candidate.Value]; !exists { - keys[candidate.Value] = index - } - } - } - placer.keys[mapping] = keys - return keys +type diffReport struct { + Additions any `json:"additions"` + Changes any `json:"changes"` + Deletions []string `json:"deletions"` } -func stringKey(value string) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} -} - -func pointerFromTokens(tokens []string) string { - if len(tokens) == 0 { - return "" - } - var pointer strings.Builder - for _, token := range tokens { - pointer.WriteByte('/') - pointer.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1")) - } - return pointer.String() -} - -func renderDiffReport(differences []configkit.Difference) ([]byte, error) { - var placer yamlValuePlacer - additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} - +func renderDiffReport(differences []configkit.Difference) (any, error) { + report := diffReport{Additions: map[string]any{}, Changes: map[string]any{}, Deletions: []string{}} for _, difference := range differences { path := difference.Path.String() - switch { - case !difference.Left.Present && difference.Right.Present: - tokens, err := decodeJSONPointer(path) - if err != nil { - return nil, fmt.Errorf("render addition at %q: %w", path, err) - } - value, err := decodeSafeSnapshot(difference.Right.YAML) - if err != nil { - return nil, fmt.Errorf("render addition at %q: %w", path, err) - } - additions, err = placer.placeValue(additions, tokens, value) - if err != nil { - return nil, fmt.Errorf("render addition at %q: %w", path, err) - } - case difference.Left.Present && difference.Right.Present: - tokens, err := decodeJSONPointer(path) - if err != nil { - return nil, fmt.Errorf("render change at %q: %w", path, err) - } - value, err := decodeSafeSnapshot(difference.Right.YAML) - if err != nil { - return nil, fmt.Errorf("render change at %q: %w", path, err) - } - changes, err = placer.placeValue(changes, tokens, value) - if err != nil { - return nil, fmt.Errorf("render change at %q: %w", path, err) - } - case difference.Left.Present && !difference.Right.Present: - deletions.Content = append(deletions.Content, stringKey(path)) + if difference.Left.Present && !difference.Right.Present { + report.Deletions = append(report.Deletions, path) + continue + } + if !difference.Right.Present { + continue + } + tokens, err := decodeJSONPointer(path) + if err != nil { + return nil, fmt.Errorf("difference at %q: %w", path, err) + } + value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML)) + if err != nil { + return nil, fmt.Errorf("difference at %q: %w", path, err) + } + if difference.Left.Present { + report.Changes, err = placeReportValue(report.Changes, tokens, value) + } else { + report.Additions, err = placeReportValue(report.Additions, tokens, value) + } + if err != nil { + return nil, fmt.Errorf("difference at %q: %w", path, err) } } - - root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{ - stringKey("additions"), additions, - stringKey("changes"), changes, - stringKey("deletions"), deletions, - }} - document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}} - var output bytes.Buffer - if err := encodeYAML(&output, document); err != nil { - return nil, fmt.Errorf("encode configuration diff report: %w", err) - } - return output.Bytes(), nil + return report, nil } diff --git a/integrations/ckconfig/diff_report_render_test.go b/integrations/ckconfig/diff_report_render_test.go index 577afac..d67cc17 100644 --- a/integrations/ckconfig/diff_report_render_test.go +++ b/integrations/ckconfig/diff_report_render_test.go @@ -2,10 +2,10 @@ package ckconfig import ( "bytes" - "reflect" "strings" "testing" + commandkit "go.maximumdirect.net/commandkit" configkit "go.maximumdirect.net/configkit" "go.yaml.in/yaml/v4" ) @@ -16,11 +16,16 @@ func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.No if err != nil { t.Fatal(err) } + var rendered bytes.Buffer + if err := commandkit.RenderReport(&rendered, commandkit.Report{Value: report}, commandkit.FormatYAML); err != nil { + t.Fatal(err) + } + data := rendered.Bytes() var document yaml.Node - if err := yaml.Unmarshal(report, &document); err != nil { + if err := yaml.Unmarshal(data, &document); err != nil { t.Fatalf("decode rendered report %q: %v", report, err) } - return document.Content[0], report + return document.Content[0], data } func reportField(mapping *yaml.Node, key string) *yaml.Node { @@ -43,18 +48,12 @@ func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) { if root.Kind != yaml.MappingNode || len(root.Content) != 6 { t.Fatalf("report root = %#v", root) } - if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) { - t.Fatalf("report category order = %#v", got) - } additions := reportField(root, "additions") - if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" { - t.Fatalf("addition order/value = %#v", additions) + if value := reportField(additions, "z-added"); value == nil || value.Tag != "!!bool" || value.Value != "false" { + t.Fatalf("boolean addition = %#v", value) } - if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" { - t.Fatalf("boolean addition lost its type: %#v", additions.Content[1]) - } - if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" { - t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3]) + if value := reportField(additions, "a-added"); value == nil || value.Tag != "!!str" || value.Value != "123" { + t.Fatalf("string addition = %#v", value) } changes := reportField(root, "changes") if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" { @@ -68,9 +67,6 @@ func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) { func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) { root, _ := renderAndDecode(t, nil) - if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) { - t.Fatalf("empty report keys = %#v", got) - } if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 { t.Fatalf("empty report categories = %#v", root) } diff --git a/integrations/ckconfig/diff_report_test.go b/integrations/ckconfig/diff_report_test.go index 605c889..c6bf562 100644 --- a/integrations/ckconfig/diff_report_test.go +++ b/integrations/ckconfig/diff_report_test.go @@ -2,49 +2,9 @@ package ckconfig import ( "reflect" - "strings" "testing" - - "go.yaml.in/yaml/v4" ) -func TestDecodeSafeSnapshotPreservesYAMLTypes(t *testing.T) { - tests := []struct { - name string - text string - kind yaml.Kind - tag string - value string - }{ - {name: "null", text: "null\n", kind: yaml.ScalarNode, tag: "!!null", value: "null"}, - {name: "boolean", text: "true\n", kind: yaml.ScalarNode, tag: "!!bool", value: "true"}, - {name: "large integer", text: "9007199254740993\n", kind: yaml.ScalarNode, tag: "!!int", value: "9007199254740993"}, - {name: "numeric string", text: "'123'\n", kind: yaml.ScalarNode, tag: "!!str", value: "123"}, - {name: "boolean-like string", text: "'false'\n", kind: yaml.ScalarNode, tag: "!!str", value: "false"}, - {name: "empty mapping", text: "{}\n", kind: yaml.MappingNode, tag: "!!map"}, - {name: "empty sequence", text: "[]\n", kind: yaml.SequenceNode, tag: "!!seq"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - node, err := decodeSafeSnapshot(test.text) - if err != nil { - t.Fatal(err) - } - if node.Kind != test.kind || node.Tag != test.tag || node.Value != test.value { - t.Fatalf("node kind/tag/value = %v/%q/%q, want %v/%q/%q", node.Kind, node.Tag, node.Value, test.kind, test.tag, test.value) - } - }) - } - - nested, err := decodeSafeSnapshot("items:\n - name: example\n enabled: false\n") - if err != nil { - t.Fatal(err) - } - if nested.Kind != yaml.MappingNode || nested.Content[1].Kind != yaml.SequenceNode || nested.Content[1].Content[0].Content[3].Tag != "!!bool" { - t.Fatalf("nested collection/scalar tags were not preserved: %#v", nested) - } -} - func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) { tests := []struct { pointer string @@ -73,74 +33,25 @@ func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) { } } -func TestPlaceYAMLValueBuildsOrderedSemanticMappings(t *testing.T) { - var placer yamlValuePlacer - category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - paths := [][]string{ - {"parent", "0", "dot.name", "50%"}, - {"parent", "second"}, - {"a/b", "~1"}, - {""}, - } - for index, path := range paths { - value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: string(rune('a' + index))} - updated, err := placer.placeValue(category, path, value) - if err != nil { - t.Fatalf("place %q: %v", pointerFromTokens(path), err) - } - category = updated - } - - if len(category.Content) != 6 || category.Content[0].Value != "parent" || category.Content[2].Value != "a/b" || category.Content[4].Value != "" { - t.Fatalf("mapping insertion order or empty key = %#v", category.Content) - } - parent := category.Content[1] - if parent.Kind != yaml.MappingNode || len(parent.Content) != 4 || parent.Content[0].Value != "0" || parent.Content[2].Value != "second" { - t.Fatalf("nested shared ancestor/order = %#v", parent) - } - numeric := parent.Content[1] - dotted := numeric.Content[1] - if numeric.Kind != yaml.MappingNode || numeric.Content[0].Value != "dot.name" || dotted.Kind != yaml.MappingNode || dotted.Content[0].Value != "50%" { - t.Fatalf("semantic tokens were changed: numeric=%#v dotted=%#v", numeric, dotted) - } - if category.Content[1].Content[0].Tag != "!!str" || category.Content[2].Tag != "!!str" || category.Content[4].Tag != "!!str" { - t.Fatal("mapping keys are not explicitly tagged as YAML strings") - } -} - -func TestPlaceYAMLValueHandlesRootAndEmptyKey(t *testing.T) { - var placer yamlValuePlacer - category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - root := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"} - updated, err := placer.placeValue(category, nil, root) - if err != nil || updated != root { - t.Fatalf("root placement = %#v, %v", updated, err) - } - - category = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "empty-key"} - updated, err = placer.placeValue(category, []string{""}, value) +func TestPlaceReportValue(t *testing.T) { + var root any = map[string]any{} + var err error + root, err = placeReportValue(root, []string{"a/b", "~1"}, false) if err != nil { t.Fatal(err) } - if updated != category || len(category.Content) != 2 || category.Content[0].Value != "" || category.Content[0].Tag != "!!str" || category.Content[1] != value { - t.Fatalf("slash path placement = %#v", category) - } -} - -func TestPlaceYAMLValueRejectsIncompatibleAncestors(t *testing.T) { - var placer yamlValuePlacer - category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{ - stringKey("parent"), {Kind: yaml.ScalarNode, Tag: "!!str", Value: "leaf"}, - }} - _, err := placer.placeValue(category, []string{"parent", "child"}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}) - if err == nil || !strings.Contains(err.Error(), "/parent/child") || !strings.Contains(err.Error(), "/parent") { - t.Fatalf("incompatible ancestor error = %v", err) - } -} - -func TestDecodeSafeSnapshotRejectsMalformedYAML(t *testing.T) { - if _, err := decodeSafeSnapshot("value: [unterminated\n"); err == nil { - t.Fatal("malformed snapshot unexpectedly decoded") + root, err = placeReportValue(root, []string{""}, nil) + if err != nil { + t.Fatal(err) + } + want := map[string]any{"a/b": map[string]any{"~1": false}, "": nil} + if !reflect.DeepEqual(root, want) { + t.Fatalf("got %#v", root) + } + if _, err := placeReportValue(root, []string{"a/b", "~1", "child"}, 1); err == nil { + t.Fatal("scalar ancestor accepted") + } + if value, err := placeReportValue(root, nil, "replacement"); err != nil || value != "replacement" { + t.Fatalf("root: %#v, %v", value, err) } } diff --git a/integrations/ckconfig/explain.go b/integrations/ckconfig/explain.go index 8983234..14bcf1c 100644 --- a/integrations/ckconfig/explain.go +++ b/integrations/ckconfig/explain.go @@ -3,51 +3,51 @@ package ckconfig import ( "bytes" "fmt" - "io" + "text/template" commandkit "go.maximumdirect.net/commandkit" configkit "go.maximumdirect.net/configkit" ) type explanationReport struct { - Scope string `yaml:"scope"` - Path string `yaml:"path"` - Effective snapshotDTO `yaml:"effective"` - Origin explanationOriginDTO `yaml:"origin"` - History []explanationChangeDTO `yaml:"history"` - SecretReference *secretReferenceDTO `yaml:"secret_reference"` + Scope string `json:"scope" yaml:"scope"` + Path string `json:"path" yaml:"path"` + Effective snapshotDTO `json:"effective" yaml:"effective"` + Origin explanationOriginDTO `json:"origin" yaml:"origin"` + History []explanationChangeDTO `json:"history" yaml:"history"` + SecretReference *secretReferenceDTO `json:"secret_reference" yaml:"secret_reference"` } type snapshotDTO struct { - Present bool `yaml:"present"` - Redacted bool `yaml:"redacted"` - YAML string `yaml:"yaml"` + Present bool `json:"present" yaml:"present"` + Redacted bool `json:"redacted" yaml:"redacted"` + YAML string `json:"yaml" yaml:"yaml"` } type explanationOriginDTO struct { - Layer string `yaml:"layer"` - Locations []locationDTO `yaml:"locations"` - Operation string `yaml:"operation"` - Inheritance []inheritanceStepDTO `yaml:"inheritance"` + Layer string `json:"layer" yaml:"layer"` + Locations []locationDTO `json:"locations" yaml:"locations"` + Operation string `json:"operation" yaml:"operation"` + Inheritance []inheritanceStepDTO `json:"inheritance" yaml:"inheritance"` } type inheritanceStepDTO struct { - From string `yaml:"from"` - To string `yaml:"to"` - Reference string `yaml:"reference"` - Locations []locationDTO `yaml:"locations"` + From string `json:"from" yaml:"from"` + To string `json:"to" yaml:"to"` + Reference string `json:"reference" yaml:"reference"` + Locations []locationDTO `json:"locations" yaml:"locations"` } type explanationChangeDTO struct { - Before snapshotDTO `yaml:"before"` - After snapshotDTO `yaml:"after"` - Origin explanationOriginDTO `yaml:"origin"` - Reasons []string `yaml:"reasons"` - Dependencies []string `yaml:"dependencies"` + Before snapshotDTO `json:"before" yaml:"before"` + After snapshotDTO `json:"after" yaml:"after"` + Origin explanationOriginDTO `json:"origin" yaml:"origin"` + Reasons []string `json:"reasons" yaml:"reasons"` + Dependencies []string `json:"dependencies" yaml:"dependencies"` } type secretReferenceDTO struct { - Name string `yaml:"name"` + Name string `json:"name" yaml:"name"` } func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { @@ -85,22 +85,18 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf if err := invocation.Context.Err(); err != nil { return configFailure("Configuration explanation was canceled.", acquired.Explanation, err) } - if plan.format != "yaml" { - err := reporter.RenderOutput(func(writer io.Writer) error { - return acquired.Value.Explanation.WriteExplanation(writer, plan.path) - }) - if err != nil && invocation.Context.Err() != nil { - return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) - } - return commandkit.Success() + text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{ + "render": func(explanationReport) (string, error) { + var output bytes.Buffer + err := acquired.Value.Explanation.WriteExplanation(&output, plan.path) + return output.String(), err + }, + })) + renderErr := reporter.Report(commandkit.Report{Value: makeExplanationReport(name, explanation), TextTemplate: text}) + if renderErr != nil && invocation.Context.Err() != nil { + return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) } - return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) { - var report bytes.Buffer - if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil { - return nil, err - } - return report.Bytes(), nil - }) + return commandkit.Success() } func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport { diff --git a/integrations/ckconfig/family.go b/integrations/ckconfig/family.go index 9fcefd1..75ebdea 100644 --- a/integrations/ckconfig/family.go +++ b/integrations/ckconfig/family.go @@ -1,25 +1,24 @@ package ckconfig import ( - "bytes" "context" "errors" "fmt" - "io" "sort" "strings" + "text/template" commandkit "go.maximumdirect.net/commandkit" configkit "go.maximumdirect.net/configkit" ) type scopeMetadata struct { - Name string `yaml:"name"` - Operations []Operation `yaml:"operations"` + Name string `json:"name"` + Operations []Operation `json:"operations"` } type scopeInventory struct { - Scopes []scopeMetadata `yaml:"scopes"` + Scopes []scopeMetadata `json:"scopes"` } type preparedScope[I, S any] struct { @@ -254,62 +253,17 @@ func joinNames(names []string) string { return strings.Join(ordered, ", ") } +var scopeInventoryTemplate = template.Must(commandkit.ParseTextTemplate("scopes", `{{range .Scopes}}{{.Name}}: {{range $index, $operation := .Operations}}{{if $index}}, {{end}}{{$operation}}{{end}} +{{end}}`, nil)) + func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.Command { - formatDefault := "text" - spec := commandkit.CommandSpec[scopeInventoryInput]{ - Name: "scopes", - Summary: "List configuration scopes and supported operations.", - Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag( - commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml.", Completion: completionChoices("text", "yaml")}, &formatDefault, parseConfigString, - func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value }, - )}, - CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) }, - Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome { - _ = invocation.Reporter().RenderOutput(func(writer io.Writer) error { - var report bytes.Buffer - if input.Format == "yaml" { - if err := encodeYAML(&report, prepared.inventory); err != nil { - return err - } - } else { - _, _ = report.WriteString(formatScopeInventory(prepared.inventory)) - } - _, err := writer.Write(report.Bytes()) - return err - }) + return commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ + Name: "scopes", Summary: "List configuration scopes and supported operations.", + Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome { + _ = invocation.Reporter().Report(commandkit.Report{Value: prepared.inventory, TextTemplate: scopeInventoryTemplate}) return commandkit.Success() }, - } - return commandkit.NewCommand(spec) -} - -type scopeInventoryInput struct{ Format string } - -type scopeInventoryYAML struct { - Scopes []scopeMetadata `yaml:"scopes"` -} - -func formatScopeInventory(inventory scopeInventory) string { - var output strings.Builder - for _, scope := range inventory.Scopes { - output.WriteString(scope.Name) - output.WriteString(": ") - for index, operation := range scope.Operations { - if index > 0 { - output.WriteString(", ") - } - output.WriteString(string(operation)) - } - output.WriteByte('\n') - } - return output.String() -} - -func checkConfigFormat(format string) error { - if format != "text" && format != "yaml" { - return fmt.Errorf("unsupported configuration format %q", format) - } - return nil + }) } func parseConfigString(value string) (string, error) { return value, nil } diff --git a/integrations/ckconfig/family_reports_test.go b/integrations/ckconfig/family_reports_test.go index 0cba1cd..e6b4fb1 100644 --- a/integrations/ckconfig/family_reports_test.go +++ b/integrations/ckconfig/family_reports_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "reflect" "strings" "testing" @@ -270,7 +271,7 @@ func TestConfigAliasesForwardSelectedTenantAndGlobalHelp(t *testing.T) { } } -func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) { +func TestShowAcquiresAllViewsBeforeRenderingAggregate(t *testing.T) { var output bytes.Buffer var acquisitionOutputLengths []int makeScope := func(name, native string, acquireErr error) Scope[struct{}, *int] { @@ -306,9 +307,13 @@ func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) { if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete { 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) + decoded, err := commandkit.DecodeYAMLReport(&output) + if err != nil { + t.Fatal(err) + } + want := map[string]any{"app": map[string]any{"name": "app"}, "promptkit": map[string]any{"name": "promptkit"}} + if !reflect.DeepEqual(decoded, want) { + t.Fatalf("aggregate = %#v, want %#v", decoded, want) } } @@ -483,7 +488,7 @@ func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testi }}})) var output bytes.Buffer outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard}) - if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.String() != "partial native explanation\n" { + if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.Len() != 0 { t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String()) } } @@ -513,7 +518,7 @@ func TestReportWriteFailureIsRecordedByReporterAndPreservesCancellation(t *testi }})) 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.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\n" { + if !errors.Is(outcome.Cause, context.Canceled) || outcome.Presentation.Output.State != commandkit.DeliveryComplete || calls != 2 || output.String() != "app: valid\npromptkit: canceled\n" { t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String()) } } diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index d7d22e0..8f2b858 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -12,7 +12,6 @@ type configCommandInput[I, S any] struct { selection Selection against Selection scope commandkit.Supplied[string] - format commandkit.Supplied[string] path commandkit.Supplied[string] values commandkit.FlagValues[I] plan *configOperationPlan[I, S] @@ -33,7 +32,6 @@ type configOperationPlan[I, S any] struct { operation Operation selection Selection scopeName string - format string path configkit.Path scopes []selectedScopeInput[I, S] comparison *preparedComparison[I, S] @@ -41,14 +39,6 @@ type configOperationPlan[I, S any] struct { func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command { defaultScope := "app" - defaultFormat := "text" - if operation == Show || operation == Diff { - defaultFormat = "yaml" - } - formats := completionChoices("text", "yaml") - if operation == Show || operation == Diff { - formats = completionChoices("yaml") - } var scopeChoices []commandkit.CompletionChoice allSupported := len(prepared.scopes) > 0 for _, scope := range prepared.scopes { @@ -64,8 +54,6 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio flags := []commandkit.Flag[*configCommandInput[I, S]]{ commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported.", Completion: commandkit.CompleteChoices(scopeChoices...)}, &defaultScope, parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }), - commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format.", Completion: formats}, &defaultFormat, parseConfigString, - func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }), commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).selection.Config = value @@ -133,16 +121,6 @@ func operationDeclaration(operation Operation) (string, string) { } func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error { - format := input.format.Value - if err := checkConfigFormat(format); err != nil { - return err - } - if operation == Show && format != "yaml" { - return fmt.Errorf("unsupported configuration show format %q", format) - } - if operation == Diff && format != "yaml" { - return fmt.Errorf("unsupported configuration diff format %q", format) - } scopeName := "app" if input.scope.Set { @@ -187,7 +165,6 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O operation: operation, selection: input.selection, scopeName: scopeName, - format: format, path: path, scopes: make([]selectedScopeInput[I, S], 0, len(selected)), } diff --git a/integrations/ckconfig/operations.go b/integrations/ckconfig/operations.go index a8d4a9b..8d20017 100644 --- a/integrations/ckconfig/operations.go +++ b/integrations/ckconfig/operations.go @@ -77,8 +77,9 @@ func configAcquisitionFailure( } // Preserve the preparation/delivery cancellation checkpoint for buffered reports. -func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() ([]byte, error)) commandkit.Outcome { +func renderPreparedConfigReport(invocation commandkit.Invocation, explanation commandkit.Explanation, prepare func() (any, error)) commandkit.Outcome { var canceled error + var value any renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error { report, err := prepare() if err != nil { @@ -87,8 +88,8 @@ func renderPreparedConfigReport(invocation commandkit.Invocation, explanation co if canceled = invocation.Context.Err(); canceled != nil { return nil } - _, err = writer.Write(report) - return err + value = report + return nil }) if canceled == nil && renderErr != nil { canceled = invocation.Context.Err() @@ -96,5 +97,8 @@ func renderPreparedConfigReport(invocation commandkit.Invocation, explanation co if canceled != nil { return configFailure("Configuration report preparation was canceled.", explanation, canceled) } + if renderErr == nil { + _ = invocation.Reporter().Report(commandkit.Report{Value: value}) + } return commandkit.Success() } diff --git a/integrations/ckconfig/report_cancellation_test.go b/integrations/ckconfig/report_cancellation_test.go index 486d108..fb9aa7d 100644 --- a/integrations/ckconfig/report_cancellation_test.go +++ b/integrations/ckconfig/report_cancellation_test.go @@ -18,10 +18,10 @@ func TestBufferedReportCancellationBeforeDelivery(t *testing.T) { app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ Name: "report", Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome { - return renderPreparedConfigReport(invocation, commandkit.Explanation{Message: "selected context"}, func() ([]byte, error) { + return renderPreparedConfigReport(invocation, commandkit.Explanation{Message: "selected context"}, func() (any, error) { prepared = true cancel() - return []byte("prepared report"), nil + return "prepared report", nil }) }, })) @@ -54,8 +54,8 @@ func TestBufferedReportDoesNotRejectCancellationAfterDeliveryStarts(t *testing.T app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ Name: "report", Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome { - return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() ([]byte, error) { - return []byte("report"), nil + return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() (any, error) { + return "report", nil }) }, })) diff --git a/integrations/ckconfig/shared_report_test.go b/integrations/ckconfig/shared_report_test.go new file mode 100644 index 0000000..0b09834 --- /dev/null +++ b/integrations/ckconfig/shared_report_test.go @@ -0,0 +1,152 @@ +package ckconfig + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "reflect" + "testing" + + commandkit "go.maximumdirect.net/commandkit" + configkit "go.maximumdirect.net/configkit" +) + +func TestShowSharedFormatsAndScopeShape(t *testing.T) { + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{ + Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("'false'\n")}}, nil + }, + }}})) + for _, tc := range []struct { + name string + args []string + want string + }{ + {"automatic", []string{"config", "show"}, "false\n"}, + {"text", []string{"config", "show", "--format=text"}, "false\n"}, + {"yaml", []string{"config", "show", "--format=yaml"}, "\"false\"\n"}, + {"json", []string{"config", "show", "--format=json"}, "\"false\"\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + result := app.Execute(t.Context(), tc.args, commandkit.Streams{Out: &out, Err: io.Discard}) + if tc.name == "yaml" { + value, err := commandkit.DecodeYAMLReport(&out) + if err != nil || value != "false" || result.Status != 0 { + t.Fatalf("%#v value=%#v err=%v", result, value, err) + } + return + } + if result.Status != 0 || out.String() != tc.want { + t.Fatalf("%#v output=%q", result, out.String()) + } + }) + } + var out bytes.Buffer + result := app.Execute(t.Context(), []string{"config", "show", "--scope=all", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard}) + var decoded map[string]any + if err := json.Unmarshal(out.Bytes(), &decoded); err != nil || result.Status != 0 || !reflect.DeepEqual(decoded, map[string]any{"app": "false"}) { + t.Fatalf("%#v output=%q decoded=%#v err=%v", result, out.String(), decoded, err) + } +} + +func TestShowConversionFailureIsPresentationEvidence(t *testing.T) { + for _, quiet := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary", true: "quiet"}[quiet], func(t *testing.T) { + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("x: [invalid\n")}}, nil + }}}})) + args := []string{"config", "show"} + if quiet { + args = append(args, "--quiet") + } + var out bytes.Buffer + result := app.Execute(t.Context(), args, commandkit.Streams{Out: &out, Err: io.Discard}) + if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.RenderError == nil || out.Len() != 0 { + t.Fatalf("%#v output=%q", result, out.String()) + } + }) + } +} + +func TestValidationSubmitsOneReportAfterAcquisitionStops(t *testing.T) { + failure := errors.New("invalid configuration") + 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: "middle", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, failure + }}, + {Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, nil + }}, + }})) + var out bytes.Buffer + result := app.Execute(t.Context(), []string{"config", "validate", "--scope=all", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard}) + var report struct { + Scopes []struct{ Scope, Outcome string } + } + dec := json.NewDecoder(&out) + if err := dec.Decode(&report); err != nil { + t.Fatal(err) + } + if err := dec.Decode(new(any)); err != io.EOF { + t.Fatalf("additional JSON: %v", err) + } + if !errors.Is(result.Cause, failure) || calls != 2 || len(report.Scopes) != 2 || report.Scopes[0].Outcome != "valid" || report.Scopes[1].Outcome != "failed" { + t.Fatalf("result=%#v calls=%d report=%#v", result, calls, report) + } +} + +func TestValidationDeliveryFailureDoesNotStopAcquisition(t *testing.T) { + writeErr := errors.New("write failed") + calls := 0 + acquire := func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, nil + } + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Validate}, Acquire: acquire}, {Name: "second", Operations: []Operation{Validate}, Acquire: acquire}}})) + result := app.Execute(t.Context(), []string{"config", "validate", "--scope=all"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard}) + if calls != 2 || !errors.Is(result.Presentation.Output.WriteError, writeErr) || result.Status == 0 { + t.Fatalf("result=%#v calls=%d", result, calls) + } +} + +func TestConfigurationReportsSupportSharedFormats(t *testing.T) { + type settings struct { + Greeting string `yaml:"greeting"` + } + app := newConfigTestApp(t, AppCommand(func(context.Context, Selection) (*configkit.Result[settings], error) { + return configkit.Load(configkit.Mapping("fixture", map[string]any{"greeting": "hello"}), configkit.Options[settings]{}) + })) + for _, args := range [][]string{{"scopes"}, {"show"}, {"sources"}, {"explain", "--path=/greeting"}, {"diff"}, {"validate"}} { + for _, format := range []string{"text", "yaml", "json"} { + t.Run(args[0]+"/"+format, func(t *testing.T) { + argv := append([]string{"config"}, args...) + argv = append(argv, "--format="+format) + var out bytes.Buffer + result := app.Execute(t.Context(), argv, commandkit.Streams{Out: &out, Err: io.Discard}) + if result.Status != 0 { + t.Fatalf("%#v output=%q", result, out.String()) + } + if format == "json" { + dec := json.NewDecoder(&out) + var value any + if err := dec.Decode(&value); err != nil { + t.Fatal(err) + } + if err := dec.Decode(&value); err != io.EOF { + t.Fatalf("extra JSON: %v", err) + } + } + }) + } + } +} diff --git a/integrations/ckconfig/show.go b/integrations/ckconfig/show.go index 2da838a..1a83c60 100644 --- a/integrations/ckconfig/show.go +++ b/integrations/ckconfig/show.go @@ -4,8 +4,6 @@ import ( "bytes" "fmt" "io" - "strconv" - "unicode" commandkit "go.maximumdirect.net/commandkit" ) @@ -23,13 +21,9 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) } if selected.scope.scope.Acquire == nil { - message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) - return commandkit.Failure(message) + return commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name)) } - acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ - Operation: Show, - Selection: plan.selection, - }, selected.input, state) + acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Show, Selection: plan.selection}, selected.input, state) if acquireErr != nil { return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr) } @@ -38,26 +32,17 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera return configFailure("Configuration show was canceled.", acquired.Explanation, err) } if acquired.Value.Effective == nil { - message := fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name) - return commandkit.Failure(message) + return commandkit.Failure(fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name)) } documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value}) } if err := invocation.Context.Err(); err != nil { return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) } - if len(documents) == 1 && plan.scopeName != "all" { - err := reporter.RenderOutput(func(writer io.Writer) error { - return documents[0].view.Effective.WriteEffective(writer) - }) - if err != nil && invocation.Context.Err() != nil { - return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err()) - } - return commandkit.Success() - } - - err := reporter.RenderOutput(func(writer io.Writer) error { - for index, document := range documents { + var value any + err := reporter.RenderOutput(func(io.Writer) error { + values := make(map[string]any, len(documents)) + for _, document := range documents { if err := invocation.Context.Err(); err != nil { return err } @@ -65,112 +50,28 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera if err := document.view.Effective.WriteEffective(&native); err != nil { return err } - if err := invocation.Context.Err(); err != nil { - return err - } - framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes()) - if index > 0 && hasDirective { - if _, err := io.WriteString(writer, "...\n"); err != nil { - return err - } - } - if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' { - framed = append(framed, '\n') - } - if _, err := writer.Write(framed); err != nil { + decoded, err := commandkit.DecodeYAMLReport(&native) + if err != nil { return err } + values[document.name] = decoded + } + if plan.scopeName == "all" { + value = values + } else { + value = values[documents[0].name] } return nil }) - if err != nil && invocation.Context.Err() != nil { - return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err()) + if err != nil { + if canceled := invocation.Context.Err(); canceled != nil { + return configFailure("Configuration show was canceled.", commandkit.Explanation{}, canceled) + } + return commandkit.Success() } + if err := invocation.Context.Err(); err != nil { + return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) + } + _ = reporter.Report(commandkit.Report{Value: value}) return commandkit.Success() } - -type yamlPreamble struct { - markerStart int - markerEnd int - markerFound bool - inlineMarker bool - preambleEnd int - hasDirective bool -} - -func analyzeYAMLPreamble(native []byte) yamlPreamble { - var analysis yamlPreamble - for start := 0; start < len(native); { - end := len(native) - if newline := bytes.IndexByte(native[start:], '\n'); newline >= 0 { - end = start + newline + 1 - } - lineEnd := end - if lineEnd > start && native[lineEnd-1] == '\n' { - lineEnd-- - } - trimmed := bytes.TrimSpace(native[start:lineEnd]) - if isYAMLDocumentStart(trimmed) { - content := bytes.TrimSpace(trimmed[3:]) - analysis.markerStart = start - analysis.markerEnd = end - analysis.markerFound = true - analysis.inlineMarker = len(content) > 0 && content[0] != '#' - return analysis - } - if bytes.HasPrefix(trimmed, []byte("%YAML ")) || bytes.HasPrefix(trimmed, []byte("%TAG ")) { - analysis.hasDirective = true - } - if len(trimmed) == 0 || trimmed[0] == '#' || bytes.HasPrefix(trimmed, []byte("%YAML ")) || bytes.HasPrefix(trimmed, []byte("%TAG ")) { - analysis.preambleEnd = end - } else { - return analysis - } - start = end - } - return analysis -} - -func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) { - analysis := analyzeYAMLPreamble(native) - label := scope - for _, character := range scope { - if unicode.IsControl(character) { - label = strconv.Quote(scope) - break - } - } - comment := []byte("# scope: " + label + "\n") - if analysis.markerFound { - out := make([]byte, 0, len(native)+len(comment)) - if analysis.inlineMarker { - out = append(out, native[:analysis.markerStart]...) - out = append(out, comment...) - out = append(out, native[analysis.markerStart:]...) - return out, analysis.hasDirective - } - out = append(out, native[:analysis.markerEnd]...) - if analysis.markerEnd > analysis.markerStart && native[analysis.markerEnd-1] != '\n' { - out = append(out, '\n') - } - out = append(out, comment...) - out = append(out, native[analysis.markerEnd:]...) - return out, analysis.hasDirective - } - result := make([]byte, 0, len(native)+len(comment)+5) - if analysis.hasDirective { - result = append(result, native[:analysis.preambleEnd]...) - } - result = append(result, []byte("---\n")...) - result = append(result, comment...) - if analysis.hasDirective { - result = append(result, native[analysis.preambleEnd:]...) - } else { - result = append(result, native...) - } - return result, analysis.hasDirective -} - -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 deleted file mode 100644 index 33d2eaf..0000000 --- a/integrations/ckconfig/show_framing_test.go +++ /dev/null @@ -1,95 +0,0 @@ -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 df2c228..c748da3 100644 --- a/integrations/ckconfig/sources.go +++ b/integrations/ckconfig/sources.go @@ -1,59 +1,56 @@ package ckconfig import ( - "bytes" "fmt" - "io" - "strconv" + "text/template" commandkit "go.maximumdirect.net/commandkit" configkit "go.maximumdirect.net/configkit" - "go.yaml.in/yaml/v4" ) type sourceReport struct { - Scope string `yaml:"scope"` - Sources []sourceDTO `yaml:"sources"` + Scope string `json:"scope" yaml:"scope"` + Sources []sourceDTO `json:"sources" yaml:"sources"` } type sourceDTO struct { - ID string `yaml:"id"` - Label string `yaml:"label"` - Path string `yaml:"path"` - Destination string `yaml:"destination"` - Placement string `yaml:"placement"` - DestinationIsFallback bool `yaml:"destination_is_fallback"` - Layer string `yaml:"layer"` - Kind string `yaml:"kind"` - UnderlayIndex int `yaml:"underlay_index"` - ImportedFrom *locationDTO `yaml:"imported_from"` - VersionEvidence *sourceVersionEvidenceDTO `yaml:"version_evidence"` - EmptyImportSelections []emptyImportSelectionDTO `yaml:"empty_import_selections"` + ID string `json:"id" yaml:"id"` + Label string `json:"label" yaml:"label"` + Path string `json:"path" yaml:"path"` + Destination string `json:"destination" yaml:"destination"` + Placement string `json:"placement" yaml:"placement"` + DestinationIsFallback bool `json:"destination_is_fallback" yaml:"destination_is_fallback"` + Layer string `json:"layer" yaml:"layer"` + Kind string `json:"kind" yaml:"kind"` + UnderlayIndex int `json:"underlay_index" yaml:"underlay_index"` + ImportedFrom *locationDTO `json:"imported_from" yaml:"imported_from"` + VersionEvidence *sourceVersionEvidenceDTO `json:"version_evidence" yaml:"version_evidence"` + EmptyImportSelections []emptyImportSelectionDTO `json:"empty_import_selections" yaml:"empty_import_selections"` } type emptyImportSelectionDTO struct { - Path string `yaml:"path"` - Location locationDTO `yaml:"location"` - Selector string `yaml:"selector"` + Path string `json:"path" yaml:"path"` + Location locationDTO `json:"location" yaml:"location"` + Selector string `json:"selector" yaml:"selector"` } type locationDTO struct { - SourceID string `yaml:"source_id"` - Line int `yaml:"line"` - Column int `yaml:"column"` + SourceID string `json:"source_id" yaml:"source_id"` + Line int `json:"line" yaml:"line"` + Column int `json:"column" yaml:"column"` } type sourceVersionEvidenceDTO struct { - Path string `yaml:"path"` - Version versionDTO `yaml:"version"` - Location *locationDTO `yaml:"location"` - Redacted bool `yaml:"redacted"` + Path string `json:"path" yaml:"path"` + Version versionDTO `json:"version" yaml:"version"` + Location *locationDTO `json:"location" yaml:"location"` + Redacted bool `json:"redacted" yaml:"redacted"` } type versionDTO struct { - Present bool `yaml:"present"` - Null bool `yaml:"null"` - Text string `yaml:"text"` + Present bool `json:"present" yaml:"present"` + Null bool `json:"null" yaml:"null"` + Text string `json:"text" yaml:"text"` } func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { @@ -128,73 +125,40 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp if err := invocation.Context.Err(); err != nil { return configFailure("Configuration source listing was canceled.", acquired.Explanation, err) } - return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) { - var report bytes.Buffer - if plan.format == "yaml" { - if err := encodeYAML(&report, document); err != nil { - return nil, err - } - } else { - writeSourceReportText(&report, document) - } - return report.Bytes(), nil - }) + _ = reporter.Report(commandkit.Report{Value: document, TextTemplate: sourceTextTemplate}) + return commandkit.Success() } -func writeSourceReportText(writer *bytes.Buffer, document sourceReport) { - _, _ = fmt.Fprintf(writer, "Scope: %s\n", strconv.Quote(document.Scope)) - if len(document.Sources) == 0 { - _, _ = writer.WriteString("Sources: []\n") - return - } - _, _ = writer.WriteString("Sources:\n") - for _, source := range document.Sources { - _, _ = fmt.Fprintf(writer, " - id: %s\n", strconv.Quote(source.ID)) - _, _ = fmt.Fprintf(writer, " label: %s\n", strconv.Quote(source.Label)) - _, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path)) - _, _ = fmt.Fprintf(writer, " destination: %s\n", strconv.Quote(source.Destination)) - _, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement)) - _, _ = fmt.Fprintf(writer, " destination_is_fallback: %t\n", source.DestinationIsFallback) - _, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer)) - _, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind)) - _, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex) - if source.ImportedFrom == nil { - _, _ = writer.WriteString(" imported_from: null\n") - } else { - _, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n", - strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column) - } - if source.VersionEvidence == nil { - _, _ = writer.WriteString(" version_evidence: null\n") - } else { - check := source.VersionEvidence - _, _ = fmt.Fprintf(writer, " version_evidence:\n path: %s\n", strconv.Quote(check.Path)) - _, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n", - check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text)) - if check.Location == nil { - _, _ = writer.WriteString(" location: null\n") - } else { - _, _ = fmt.Fprintf(writer, " location: {source_id: %s, line: %d, column: %d}\n", - strconv.Quote(check.Location.SourceID), check.Location.Line, check.Location.Column) - } - _, _ = fmt.Fprintf(writer, " redacted: %t\n", check.Redacted) - } - if len(source.EmptyImportSelections) == 0 { - _, _ = writer.WriteString(" empty_import_selections: []\n") - } else { - _, _ = writer.WriteString(" empty_import_selections:\n") - for _, selection := range source.EmptyImportSelections { - _, _ = fmt.Fprintf(writer, " - path: %s\n location: {source_id: %s, line: %d, column: %d}\n selector: %s\n", strconv.Quote(selection.Path), strconv.Quote(selection.Location.SourceID), selection.Location.Line, selection.Location.Column, strconv.Quote(selection.Selector)) - } - } - } -} - -func encodeYAML(writer io.Writer, document any) error { - encoder := yaml.NewEncoder(writer) - encoder.SetIndent(2) - return encoder.Encode(document) -} +var sourceTextTemplate = template.Must(commandkit.ParseTextTemplate("sources", `Scope: {{printf "%q" .Scope}} +{{if .Sources}}Sources: +{{range .Sources}} - id: {{printf "%q" .ID}} + label: {{printf "%q" .Label}} + path: {{printf "%q" .Path}} + destination: {{printf "%q" .Destination}} + placement: {{printf "%q" .Placement}} + destination_is_fallback: {{.DestinationIsFallback}} + layer: {{printf "%q" .Layer}} + kind: {{printf "%q" .Kind}} + underlay_index: {{.UnderlayIndex}} +{{if .ImportedFrom}} imported_from: {source_id: {{printf "%q" .ImportedFrom.SourceID}}, line: {{.ImportedFrom.Line}}, column: {{.ImportedFrom.Column}}} +{{else}} imported_from: null +{{end}}{{if .VersionEvidence}} version_evidence: + path: {{printf "%q" .VersionEvidence.Path}} + version: + present: {{.VersionEvidence.Version.Present}} + null: {{.VersionEvidence.Version.Null}} + text: {{printf "%q" .VersionEvidence.Version.Text}} +{{if .VersionEvidence.Location}} location: {source_id: {{printf "%q" .VersionEvidence.Location.SourceID}}, line: {{.VersionEvidence.Location.Line}}, column: {{.VersionEvidence.Location.Column}}} +{{else}} location: null +{{end}} redacted: {{.VersionEvidence.Redacted}} +{{else}} version_evidence: null +{{end}}{{if .EmptyImportSelections}} empty_import_selections: +{{range .EmptyImportSelections}} - path: {{printf "%q" .Path}} + location: {source_id: {{printf "%q" .Location.SourceID}}, line: {{.Location.Line}}, column: {{.Location.Column}}} + selector: {{printf "%q" .Selector}} +{{end}}{{else}} empty_import_selections: [] +{{end}}{{end}}{{else}}Sources: [] +{{end}}`, nil)) func locationReports(locations []configkit.Location) []locationDTO { report := make([]locationDTO, 0, len(locations)) diff --git a/integrations/ckconfig/validate.go b/integrations/ckconfig/validate.go index 0b6ad69..d510b7c 100644 --- a/integrations/ckconfig/validate.go +++ b/integrations/ckconfig/validate.go @@ -1,83 +1,58 @@ package ckconfig import ( - "bytes" "fmt" - "io" - "strings" + "text/template" commandkit "go.maximumdirect.net/commandkit" - "go.yaml.in/yaml/v4" ) +type validationReport struct { + Scopes []validationScope `json:"scopes"` +} + +type validationScope struct { + Scope string `json:"scope"` + Outcome string `json:"outcome"` +} + +var validationTextTemplate = template.Must(commandkit.ParseTextTemplate("validation", "{{range .Scopes}}{{.Scope}}: {{.Outcome}}\n{{end}}", nil)) + func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { reporter := invocation.Reporter() - writtenRecord := false + report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))} + outcome := commandkit.Success() for _, selected := range plan.scopes { if err := invocation.Context.Err(); err != nil { - return configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err) + outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err) + break } + name := selected.scope.scope.Name if selected.scope.scope.Acquire == nil { - message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) - return commandkit.Failure(message) + report.Scopes = append(report.Scopes, validationScope{Scope: name, Outcome: "failed"}) + outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name)) + break } - acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ - Operation: Validate, - Selection: plan.selection, - }, selected.input, state) - if acquireErr != nil { - return configAcquisitionFailure(reporter, "validate", selected.scope.scope.Name, acquired, acquireErr) + acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: plan.selection}, selected.input, state) + if err != nil { + status := "failed" + if invocation.Context.Err() != nil { + status = "canceled" + } + report.Scopes = append(report.Scopes, validationScope{Scope: name, Outcome: status}) + outcome = configAcquisitionFailure(reporter, "validate", name, acquired, err) + break } queueConfigDiagnostics(reporter, acquired.Diagnostics) if err := invocation.Context.Err(); err != nil { - return configFailure("Configuration validation was canceled.", acquired.Explanation, err) + report.Scopes = append(report.Scopes, validationScope{Scope: name, Outcome: "canceled"}) + outcome = configFailure("Configuration validation was canceled.", acquired.Explanation, err) + break } - - err := reporter.RenderOutput(func(writer io.Writer) error { - record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format) - if err != nil { - return err - } - if plan.format == "yaml" && !writtenRecord { - if _, err := io.WriteString(writer, "scopes:\n"); err != nil { - return err - } - } - _, err = writer.Write(record) - return err - }) - if err != nil { - if ctxErr := invocation.Context.Err(); ctxErr != nil { - return configFailure("Configuration validation was canceled.", acquired.Explanation, ctxErr) - } - return commandkit.Success() - } - writtenRecord = true + report.Scopes = append(report.Scopes, validationScope{Scope: name, Outcome: "valid"}) } - return commandkit.Success() -} - -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 + if len(report.Scopes) > 0 { + _ = reporter.Report(commandkit.Report{Value: report, TextTemplate: validationTextTemplate}) + } + return outcome }