Move configuration reports to shared rendering
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
} {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
152
integrations/ckconfig/shared_report_test.go
Normal file
152
integrations/ckconfig/shared_report_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user