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

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