Move configuration reports to shared rendering

This commit is contained in:
2026-11-07 03:57:10 +00:00
parent 7159f52f4d
commit 6c35da3ab6
23 changed files with 461 additions and 821 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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"},
} {

View File

@@ -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)
})
}

View File

@@ -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())
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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())
}
}

View File

@@ -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)),
}

View File

@@ -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()
}

View File

@@ -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
})
},
}))

View File

@@ -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)
}
}
})
}
}
}

View File

@@ -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')
}

View File

@@ -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)
}
})
}
}

View File

@@ -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))

View File

@@ -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
}