Move configuration reports to shared rendering

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

View File

@@ -316,8 +316,8 @@ PromptKit prompt or profile definition commands.
`app`; `--scope all` validates every registered scope and is available when each `app`; `--scope all` validates every registered scope and is available when each
scope declares validation. The command checks all selected capabilities before scope declares validation. The command checks all selected capabilities before
calling scope providers. calling scope providers.
Its default format is text; `--format yaml` emits a `scopes` sequence with Automatic output is concise text; structured formats emit a `scopes` sequence
`name` and `valid: true` for each scope that completed successfully. with each reached scope and its outcome (`valid`, `failed`, or `canceled`).
```text ```text
example config validate example config validate
@@ -326,19 +326,21 @@ example config validate --scope all
``` ```
Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops
at the first load or output failure; records from earlier successful scopes may at the first load failure or cancellation, then submits one report containing
already have been written. Provider warnings go to the diagnostic stream and do the reached scopes. Output errors occur after acquisition has stopped. Provider
not alone make validation fail. `--config` and `--overlay` preserve whether an warnings go to the diagnostic stream and do not alone make validation fail.
`--config` and `--overlay` preserve whether an
empty value was explicitly supplied, so a scope callback can map those inputs to empty value was explicitly supplied, so a scope callback can map those inputs to
its own ConfigKit load policy. Help and invalid syntax do not call scope its own ConfigKit load policy. Help and invalid syntax do not call scope
providers. providers.
### Show effective configuration ### Show effective configuration
`config show` writes the provider's native effective YAML for one scope. Its `config show` displays the provider's safe effective configuration. The default
default scope is `app`; `--format yaml` is accepted and YAML is the only initial scope is `app`. The native inspection YAML is converted to a logical report
format. Single-scope output is passed through unchanged, without a CommandKit value, preserving meaningful scalar types, empty collections, and redaction.
header or document marker. Automatic formatting uses text for a single scalar or nonempty string list and
YAML for structured values; `--format` selects text, YAML, or JSON.
```text ```text
example config show example config show
@@ -346,24 +348,17 @@ example config show --scope promptkit
example config show --scope all example config show --scope all
``` ```
`--scope all` acquires every selected scope and checks each effective writer `--scope all` acquires every selected scope before producing one mapping keyed
before writing output. It then emits one YAML document per scope in discovery by scope name, including when only one scope is registered. A single selected
order, with a `# scope: NAME` comment at each document boundary. For ordinary scope is unwrapped. A provider failure prevents report output. Conversion or
block documents the comment follows the marker. When a native opening marker output failures are recorded in `Outcome.Presentation.Output`.
contains inline content, the comment precedes that marker so it does not
interrupt the native value. Existing native markers and leading comments are
retained; documents without a marker receive one. Scope labels containing line
control characters are quoted to keep them on one comment line. A provider
failure leaves the output stream empty. A rendering or output failure may leave
an aggregate prefix; `Outcome.Presentation.Output` records incomplete delivery
and the destination or rendering error.
### List configuration sources ### List configuration sources
`config sources` reports the source inventory captured during the selected `config sources` reports the source inventory captured during the selected
scope's acquisition. It reads the source facet once and does not reload scope's acquisition. It reads the source facet once and does not reload
configuration. The default scope is `app`; `--scope all` is unsupported. Text is configuration. The default scope is `app`; `--scope all` is unsupported. Text is
the default format, and `--format yaml` returns a structured report: the automatic format, and `--format yaml` or `--format json` returns a structured report:
```yaml ```yaml
scope: app scope: app
@@ -415,8 +410,8 @@ for native evidence definitions.
`config explain --path POINTER` explains the effective and historical value at a `config explain --path POINTER` explains the effective and historical value at a
JSON Pointer path. `--path` is required; an explicitly empty value selects the JSON Pointer path. `--path` is required; an explicitly empty value selects the
root. The default scope is `app`; `--format yaml` selects structured output, and root. The default scope is `app`; `--format yaml` or `--format json` selects
text is the default. `--scope all` is unsupported. structured output, and automatic output uses text. `--scope all` is unsupported.
```text ```text
example config explain --path /server/addr example config explain --path /server/addr
@@ -430,7 +425,7 @@ rejected before acquisition. A well-formed path that is unknown in the selected
configuration is an operational error with status `1`. Text output comes from configuration is an operational error with status `1`. Text output comes from
the scope's native explanation writer. the scope's native explanation writer.
YAML output uses these root fields: Structured output uses these root fields:
```yaml ```yaml
scope: app scope: app
@@ -474,8 +469,8 @@ field is omitted, it inherits the corresponding left value and supplied state;
an explicit empty string overrides it and remains supplied. Equal selections are an explicit empty string overrides it and remains supplied. Equal selections are
allowed; their equality alone does not cause CommandKit to skip the provider. allowed; their equality alone does not cause CommandKit to skip the provider.
YAML is the only format and the default. Explicit `--format yaml` is accepted; Automatic output is YAML. The shared `--format` also accepts YAML, JSON, and
`text` and other formats are usage errors. For example: text; text uses the same structured representation. For example:
```text ```text
example config diff --config before.yml --against-config after.yml example config diff --config before.yml --against-config after.yml
@@ -487,9 +482,9 @@ example config diff --scope promptkit --config before.yml --against-config after
For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values
after defaults and resolution; source or provenance differences alone do not after defaults and resolution; source or provenance differences alone do not
create a change. PromptKit delegates its native comparison to ConfigKit. The create a change. PromptKit delegates its native comparison to ConfigKit. The
report is one YAML document with `additions`, `changes`, and `deletions` in that report contains `additions`, `changes`, and `deletions`. Additions and changes
order. Additions and changes preserve semantic paths and contain right-side preserve semantic paths and contain right-side values provided by the native
values provided by the native comparison. Deletions contain canonical JSON comparison. Deletions contain canonical JSON
Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences
and native structural replacement regions stay atomic. and native structural replacement regions stay atomic.
@@ -497,7 +492,7 @@ ConfigKit pairs sensitivity across both loads. A concealed change can therefore
appear even when its two safe snapshots are equal. For those ConfigKit-backed appear even when its two safe snapshots are equal. For those ConfigKit-backed
comparisons, the report omits separate redaction and hidden-change metadata: a comparisons, the report omits separate redaction and hidden-change metadata: a
reported masked region changed, but that does not mean every member inside it reported masked region changed, but that does not mean every member inside it
changed. Marker-looking public strings stay ordinary YAML values. An empty changed. Marker-looking public strings stay ordinary strings. An empty
comparison succeeds with this shape: comparison succeeds with this shape:
```yaml ```yaml

View File

@@ -114,7 +114,7 @@ static choices or is a filesystem path:
```go ```go
commandkit.FlagMeta{ commandkit.FlagMeta{
Name: "format", Name: "view",
Completion: commandkit.CompleteChoices( Completion: commandkit.CompleteChoices(
commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "text", Description: "Readable text"},
commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"},
@@ -246,7 +246,7 @@ timing, omitted-factory zero values (including nil pointers), and ownership.
The application owns the state type and what it caches. `ckconfig.FromConfigKit` The application owns the state type and what it caches. `ckconfig.FromConfigKit`
adapts a typed ConfigKit loader without calling it during construction. adapts a typed ConfigKit loader without calling it during construction.
The `config` family provides scope discovery, validation, native effective YAML, The `config` family provides scope discovery, validation, safe effective configuration display,
source inventory, path explanation, and ConfigKit-backed comparison for the source inventory, path explanation, and ConfigKit-backed comparison for the
capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes) capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes)
owns operator syntax and report details. owns operator syntax and report details.

View File

@@ -63,8 +63,8 @@ implemented:
- Help and command discovery without provider loading or runtime construction. - Help and command discovery without provider loading or runtime construction.
- Provider failures, cancellation, output errors, and preservation of application - Provider failures, cancellation, output errors, and preservation of application
outcomes and public error identity. outcomes and public error identity.
- Selected inspection/report contracts, including native YAML and document order - Selected inspection/report contracts, including safe scalar preservation and
for `config show --scope all`. scope-keyed aggregation for `config show --scope all`.
- Public type ownership and the internal Cobra/pflag import boundary selected by - Public type ownership and the internal Cobra/pflag import boundary selected by
[ADR 0002](../adr/0002-public-facade-and-cli-backend.md). [ADR 0002](../adr/0002-public-facade-and-cli-backend.md).

View File

@@ -1,6 +1,6 @@
# Shared reporting and PromptKit v0.20.0 implementation plan # Shared reporting and PromptKit v0.20.0 implementation plan
Status: implementation in progress; Stages 1–6 complete. Status: implementation in progress; Stages 1–7 complete.
## Scope, authority, and execution ## Scope, authority, and execution
@@ -250,6 +250,12 @@ ordinary `RepeatFlag` replacement semantics to simulate ordering.
## Stage 7: Migrate existing ckconfig reports to core ## Stage 7: Migrate existing ckconfig reports to core
**Complete.** Existing ckconfig reports use shared rendering, including safe
effective values and reached-scope validation aggregation. Focused integration
tests and the root and ckconfig workspace suites passed with Go 1.27.0 using
`GOTOOLCHAIN=local go test ./... ./integrations/ckconfig/...`. The ckprompt
migration remains in the following stages.
Convert the existing `scopes`, `show`, `sources`, `explain`, `diff`, and concise Convert the existing `scopes`, `show`, `sources`, `explain`, `diff`, and concise
`validate` report paths in [ckconfig](../../integrations/ckconfig) to the public `validate` report paths in [ckconfig](../../integrations/ckconfig) to the public
handoff. Remove local format flags, validators, encoding switches, and encoders. handoff. Remove local format flags, validators, encoding switches, and encoders.

View File

@@ -83,20 +83,11 @@ func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") { if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") {
t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp) 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"} { for _, operation := range []string{"validate", "show", "sources", "explain"} {
operationHelp := help("config", operation, "--help") operationHelp := help("config", operation, "--help")
if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") { if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") {
t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp) 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") { if operation != "explain" && strings.Contains(operationHelp, "--path") {
t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp) 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 inputCalls, stateCalls := 0, 0
prepared := captureSuite(Suite[struct{}, struct{}]{ prepared := captureSuite(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} }, NewInput: func() struct{} { inputCalls++; return struct{}{} },
@@ -123,12 +114,11 @@ func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) {
input := &configCommandInput[struct{}, struct{}]{ input := &configCommandInput[struct{}, struct{}]{
selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}, selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}},
against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", 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 { if err := prepareConfigOperation(prepared, Diff, input); err != nil {
t.Fatalf("explicit YAML format rejected: %v", err) 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) t.Fatalf("comparison plan = %#v", input.plan)
} }
if input.plan.scopeName != "app" { 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) 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 { if inputCalls != 2 || stateCalls != 0 {
t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls) 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"} { for _, scope := range []string{"missing", "all", "plain"} {
input := &configCommandInput[struct{}, struct{}]{ input := &configCommandInput[struct{}, struct{}]{
scope: commandkit.Supplied[string]{Value: scope, Set: true}, scope: commandkit.Supplied[string]{Value: scope, Set: true},
format: commandkit.Supplied[string]{Value: "yaml"},
} }
if err := prepareConfigOperation(prepared, Diff, input); err == nil { if err := prepareConfigOperation(prepared, Diff, input); err == nil {
t.Errorf("comparison scope %q was accepted", scope) 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) { if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
t.Fatalf("right-side input failure = %v", err) 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 { if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err) 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 { if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err) 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", "diff", "--scope="}, "app\n", "other\n"},
{[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\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", "validate", "--format="}, "text\n", "invalid\n"},
{[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\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 { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err) 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) return renderDiffReport(acquired.Value)
}) })
} }

View File

@@ -60,7 +60,7 @@ func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *t
loadsBeforeText := len(selections) loadsBeforeText := len(selections)
output.Reset() output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard}) 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()) t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
} }
} }

View File

@@ -1,26 +1,13 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit" 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) { func decodeJSONPointer(pointer string) ([]string, error) {
if pointer == "" { if pointer == "" {
return nil, nil return nil, nil
@@ -56,139 +43,68 @@ func decodeJSONPointer(pointer string) ([]string, error) {
return parts, nil return parts, nil
} }
// yamlValuePlacer indexes each mapping once while assembling one report. func placeReportValue(root any, path []string, value any) (any, error) {
// 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")
}
if len(path) == 0 { if len(path) == 0 {
return value, nil return value, nil
} }
if category == nil || category.Kind != yaml.MappingNode { current, ok := root.(map[string]any)
return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1])) if !ok {
return nil, fmt.Errorf("report path ancestor is not a mapping")
} }
current := category
for index, token := range path { for index, token := range path {
keys := placer.mappingKeys(current)
keyIndex, exists := keys[token]
if index == len(path)-1 { if index == len(path)-1 {
if exists { current[token] = value
current.Content[keyIndex+1] = value return root, nil
} else {
keys[token] = len(current.Content)
current.Content = append(current.Content, stringKey(token), value)
}
return category, nil
} }
child, exists := current[token]
if !exists { if !exists {
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} next := make(map[string]any)
keys[token] = len(current.Content) current[token] = next
current.Content = append(current.Content, stringKey(token), child) current = next
current = child
continue continue
} }
next, ok := child.(map[string]any)
child := current.Content[keyIndex+1] if !ok {
if child.Kind != yaml.MappingNode { return nil, fmt.Errorf("report path ancestor %q is not a mapping", token)
return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1]))
} }
current = child current = next
} }
return category, nil return root, nil
} }
func (placer *yamlValuePlacer) mappingKeys(mapping *yaml.Node) map[string]int { type diffReport struct {
if keys, exists := placer.keys[mapping]; exists { Additions any `json:"additions"`
return keys Changes any `json:"changes"`
} Deletions []string `json:"deletions"`
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
} }
func stringKey(value string) *yaml.Node { func renderDiffReport(differences []configkit.Difference) (any, error) {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} report := diffReport{Additions: map[string]any{}, Changes: map[string]any{}, Deletions: []string{}}
}
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"}
for _, difference := range differences { for _, difference := range differences {
path := difference.Path.String() path := difference.Path.String()
switch { if difference.Left.Present && !difference.Right.Present {
case !difference.Left.Present && difference.Right.Present: report.Deletions = append(report.Deletions, path)
tokens, err := decodeJSONPointer(path) continue
if err != nil { }
return nil, fmt.Errorf("render addition at %q: %w", path, err) if !difference.Right.Present {
} continue
value, err := decodeSafeSnapshot(difference.Right.YAML) }
if err != nil { tokens, err := decodeJSONPointer(path)
return nil, fmt.Errorf("render addition at %q: %w", path, err) if err != nil {
} return nil, fmt.Errorf("difference at %q: %w", path, err)
additions, err = placer.placeValue(additions, tokens, value) }
if err != nil { value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
return nil, fmt.Errorf("render addition at %q: %w", path, err) if err != nil {
} return nil, fmt.Errorf("difference at %q: %w", path, err)
case difference.Left.Present && difference.Right.Present: }
tokens, err := decodeJSONPointer(path) if difference.Left.Present {
if err != nil { report.Changes, err = placeReportValue(report.Changes, tokens, value)
return nil, fmt.Errorf("render change at %q: %w", path, err) } else {
} report.Additions, err = placeReportValue(report.Additions, tokens, value)
value, err := decodeSafeSnapshot(difference.Right.YAML) }
if err != nil { if err != nil {
return nil, fmt.Errorf("render change at %q: %w", path, err) return nil, fmt.Errorf("difference 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))
} }
} }
return report, nil
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
} }

View File

@@ -2,10 +2,10 @@ package ckconfig
import ( import (
"bytes" "bytes"
"reflect"
"strings" "strings"
"testing" "testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit" configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -16,11 +16,16 @@ func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.No
if err != nil { if err != nil {
t.Fatal(err) 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 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) 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 { 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 { if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
t.Fatalf("report root = %#v", root) 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") additions := reportField(root, "additions")
if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" { if value := reportField(additions, "z-added"); value == nil || value.Tag != "!!bool" || value.Value != "false" {
t.Fatalf("addition order/value = %#v", additions) t.Fatalf("boolean addition = %#v", value)
} }
if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" { if value := reportField(additions, "a-added"); value == nil || value.Tag != "!!str" || value.Value != "123" {
t.Fatalf("boolean addition lost its type: %#v", additions.Content[1]) t.Fatalf("string addition = %#v", value)
}
if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" {
t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3])
} }
changes := reportField(root, "changes") changes := reportField(root, "changes")
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" { 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) { func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
root, _ := renderAndDecode(t, nil) 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 { 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) t.Fatalf("empty report categories = %#v", root)
} }

View File

@@ -2,49 +2,9 @@ package ckconfig
import ( import (
"reflect" "reflect"
"strings"
"testing" "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) { func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
tests := []struct { tests := []struct {
pointer string pointer string
@@ -73,74 +33,25 @@ func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
} }
} }
func TestPlaceYAMLValueBuildsOrderedSemanticMappings(t *testing.T) { func TestPlaceReportValue(t *testing.T) {
var placer yamlValuePlacer var root any = map[string]any{}
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} var err error
paths := [][]string{ root, err = placeReportValue(root, []string{"a/b", "~1"}, false)
{"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)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if updated != category || len(category.Content) != 2 || category.Content[0].Value != "" || category.Content[0].Tag != "!!str" || category.Content[1] != value { root, err = placeReportValue(root, []string{""}, nil)
t.Fatalf("slash path placement = %#v", category) if err != nil {
} t.Fatal(err)
} }
want := map[string]any{"a/b": map[string]any{"~1": false}, "": nil}
func TestPlaceYAMLValueRejectsIncompatibleAncestors(t *testing.T) { if !reflect.DeepEqual(root, want) {
var placer yamlValuePlacer t.Fatalf("got %#v", root)
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{ }
stringKey("parent"), {Kind: yaml.ScalarNode, Tag: "!!str", Value: "leaf"}, if _, err := placeReportValue(root, []string{"a/b", "~1", "child"}, 1); err == nil {
}} t.Fatal("scalar ancestor accepted")
_, 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") { if value, err := placeReportValue(root, nil, "replacement"); err != nil || value != "replacement" {
t.Fatalf("incompatible ancestor error = %v", err) t.Fatalf("root: %#v, %v", value, err)
}
}
func TestDecodeSafeSnapshotRejectsMalformedYAML(t *testing.T) {
if _, err := decodeSafeSnapshot("value: [unterminated\n"); err == nil {
t.Fatal("malformed snapshot unexpectedly decoded")
} }
} }

View File

@@ -3,51 +3,51 @@ package ckconfig
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io" "text/template"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit" configkit "go.maximumdirect.net/configkit"
) )
type explanationReport struct { type explanationReport struct {
Scope string `yaml:"scope"` Scope string `json:"scope" yaml:"scope"`
Path string `yaml:"path"` Path string `json:"path" yaml:"path"`
Effective snapshotDTO `yaml:"effective"` Effective snapshotDTO `json:"effective" yaml:"effective"`
Origin explanationOriginDTO `yaml:"origin"` Origin explanationOriginDTO `json:"origin" yaml:"origin"`
History []explanationChangeDTO `yaml:"history"` History []explanationChangeDTO `json:"history" yaml:"history"`
SecretReference *secretReferenceDTO `yaml:"secret_reference"` SecretReference *secretReferenceDTO `json:"secret_reference" yaml:"secret_reference"`
} }
type snapshotDTO struct { type snapshotDTO struct {
Present bool `yaml:"present"` Present bool `json:"present" yaml:"present"`
Redacted bool `yaml:"redacted"` Redacted bool `json:"redacted" yaml:"redacted"`
YAML string `yaml:"yaml"` YAML string `json:"yaml" yaml:"yaml"`
} }
type explanationOriginDTO struct { type explanationOriginDTO struct {
Layer string `yaml:"layer"` Layer string `json:"layer" yaml:"layer"`
Locations []locationDTO `yaml:"locations"` Locations []locationDTO `json:"locations" yaml:"locations"`
Operation string `yaml:"operation"` Operation string `json:"operation" yaml:"operation"`
Inheritance []inheritanceStepDTO `yaml:"inheritance"` Inheritance []inheritanceStepDTO `json:"inheritance" yaml:"inheritance"`
} }
type inheritanceStepDTO struct { type inheritanceStepDTO struct {
From string `yaml:"from"` From string `json:"from" yaml:"from"`
To string `yaml:"to"` To string `json:"to" yaml:"to"`
Reference string `yaml:"reference"` Reference string `json:"reference" yaml:"reference"`
Locations []locationDTO `yaml:"locations"` Locations []locationDTO `json:"locations" yaml:"locations"`
} }
type explanationChangeDTO struct { type explanationChangeDTO struct {
Before snapshotDTO `yaml:"before"` Before snapshotDTO `json:"before" yaml:"before"`
After snapshotDTO `yaml:"after"` After snapshotDTO `json:"after" yaml:"after"`
Origin explanationOriginDTO `yaml:"origin"` Origin explanationOriginDTO `json:"origin" yaml:"origin"`
Reasons []string `yaml:"reasons"` Reasons []string `json:"reasons" yaml:"reasons"`
Dependencies []string `yaml:"dependencies"` Dependencies []string `json:"dependencies" yaml:"dependencies"`
} }
type secretReferenceDTO struct { 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 { 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 { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err) return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
} }
if plan.format != "yaml" { text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{
err := reporter.RenderOutput(func(writer io.Writer) error { "render": func(explanationReport) (string, error) {
return acquired.Value.Explanation.WriteExplanation(writer, plan.path) var output bytes.Buffer
}) err := acquired.Value.Explanation.WriteExplanation(&output, plan.path)
if err != nil && invocation.Context.Err() != nil { return output.String(), err
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) },
} }))
return commandkit.Success() 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) { return commandkit.Success()
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return nil, err
}
return report.Bytes(), nil
})
} }
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport { func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {

View File

@@ -1,25 +1,24 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"sort" "sort"
"strings" "strings"
"text/template"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit" configkit "go.maximumdirect.net/configkit"
) )
type scopeMetadata struct { type scopeMetadata struct {
Name string `yaml:"name"` Name string `json:"name"`
Operations []Operation `yaml:"operations"` Operations []Operation `json:"operations"`
} }
type scopeInventory struct { type scopeInventory struct {
Scopes []scopeMetadata `yaml:"scopes"` Scopes []scopeMetadata `json:"scopes"`
} }
type preparedScope[I, S any] struct { type preparedScope[I, S any] struct {
@@ -254,62 +253,17 @@ func joinNames(names []string) string {
return strings.Join(ordered, ", ") 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 { func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.Command {
formatDefault := "text" return commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
spec := commandkit.CommandSpec[scopeInventoryInput]{ Name: "scopes", Summary: "List configuration scopes and supported operations.",
Name: "scopes", Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
Summary: "List configuration scopes and supported operations.", _ = invocation.Reporter().Report(commandkit.Report{Value: prepared.inventory, TextTemplate: scopeInventoryTemplate})
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.Success() 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 } func parseConfigString(value string) (string, error) { return value, nil }

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect"
"strings" "strings"
"testing" "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 output bytes.Buffer
var acquisitionOutputLengths []int var acquisitionOutputLengths []int
makeScope := func(name, native string, acquireErr error) Scope[struct{}, *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 { if outcome.Kind != commandkit.OutcomeSuccess || outcome.Presentation.Output.State != commandkit.DeliveryComplete {
t.Fatalf("aggregate show outcome = %#v", outcome) t.Fatalf("aggregate show outcome = %#v", outcome)
} }
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n" decoded, err := commandkit.DecodeYAMLReport(&output)
if output.String() != want { if err != nil {
t.Fatalf("aggregate show output = %q, want %q", output.String(), want) 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 var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard}) 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()) t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
} }
} }
@@ -513,7 +518,7 @@ func TestReportWriteFailureIsRecordedByReporterAndPreservesCancellation(t *testi
}})) }}))
var output bytes.Buffer var output bytes.Buffer
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) 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()) 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 selection Selection
against Selection against Selection
scope commandkit.Supplied[string] scope commandkit.Supplied[string]
format commandkit.Supplied[string]
path commandkit.Supplied[string] path commandkit.Supplied[string]
values commandkit.FlagValues[I] values commandkit.FlagValues[I]
plan *configOperationPlan[I, S] plan *configOperationPlan[I, S]
@@ -33,7 +32,6 @@ type configOperationPlan[I, S any] struct {
operation Operation operation Operation
selection Selection selection Selection
scopeName string scopeName string
format string
path configkit.Path path configkit.Path
scopes []selectedScopeInput[I, S] scopes []selectedScopeInput[I, S]
comparison *preparedComparison[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 { func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command {
defaultScope := "app" 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 var scopeChoices []commandkit.CompletionChoice
allSupported := len(prepared.scopes) > 0 allSupported := len(prepared.scopes) > 0
for _, scope := range prepared.scopes { 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]]{ 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, 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 }), 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, commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source.", Completion: commandkit.CompleteFiles()}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
(*input).selection.Config = value (*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 { 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" scopeName := "app"
if input.scope.Set { if input.scope.Set {
@@ -187,7 +165,6 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
operation: operation, operation: operation,
selection: input.selection, selection: input.selection,
scopeName: scopeName, scopeName: scopeName,
format: format,
path: path, path: path,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)), 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. // 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 canceled error
var value any
renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error { renderErr := invocation.Reporter().RenderOutput(func(writer io.Writer) error {
report, err := prepare() report, err := prepare()
if err != nil { if err != nil {
@@ -87,8 +88,8 @@ func renderPreparedConfigReport(invocation commandkit.Invocation, explanation co
if canceled = invocation.Context.Err(); canceled != nil { if canceled = invocation.Context.Err(); canceled != nil {
return nil return nil
} }
_, err = writer.Write(report) value = report
return err return nil
}) })
if canceled == nil && renderErr != nil { if canceled == nil && renderErr != nil {
canceled = invocation.Context.Err() canceled = invocation.Context.Err()
@@ -96,5 +97,8 @@ func renderPreparedConfigReport(invocation commandkit.Invocation, explanation co
if canceled != nil { if canceled != nil {
return configFailure("Configuration report preparation was canceled.", explanation, canceled) return configFailure("Configuration report preparation was canceled.", explanation, canceled)
} }
if renderErr == nil {
_ = invocation.Reporter().Report(commandkit.Report{Value: value})
}
return commandkit.Success() return commandkit.Success()
} }

View File

@@ -18,10 +18,10 @@ func TestBufferedReportCancellationBeforeDelivery(t *testing.T) {
app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report", Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome { 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 prepared = true
cancel() 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{}]{ app := newConfigTestApp(t, commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "report", Name: "report",
Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome { Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() ([]byte, error) { return renderPreparedConfigReport(invocation, commandkit.Explanation{}, func() (any, error) {
return []byte("report"), nil 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" "bytes"
"fmt" "fmt"
"io" "io"
"strconv"
"unicode"
commandkit "go.maximumdirect.net/commandkit" 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) return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
} }
if selected.scope.scope.Acquire == nil { if selected.scope.scope.Acquire == nil {
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) return commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name))
return commandkit.Failure(message)
} }
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Show, Selection: plan.selection}, selected.input, state)
Operation: Show,
Selection: plan.selection,
}, selected.input, state)
if acquireErr != nil { if acquireErr != nil {
return configAcquisitionFailure(reporter, "show", selected.scope.scope.Name, acquired, acquireErr) 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) return configFailure("Configuration show was canceled.", acquired.Explanation, err)
} }
if acquired.Value.Effective == nil { 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(fmt.Sprintf("Configuration scope %q did not provide an effective configuration writer.", selected.scope.scope.Name))
return commandkit.Failure(message)
} }
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value}) documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
} }
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err) return configFailure("Configuration show was canceled.", commandkit.Explanation{}, err)
} }
if len(documents) == 1 && plan.scopeName != "all" { var value any
err := reporter.RenderOutput(func(writer io.Writer) error { err := reporter.RenderOutput(func(io.Writer) error {
return documents[0].view.Effective.WriteEffective(writer) values := make(map[string]any, len(documents))
}) for _, document := range documents {
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 {
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return err 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 { if err := document.view.Effective.WriteEffective(&native); err != nil {
return err return err
} }
if err := invocation.Context.Err(); err != nil { decoded, err := commandkit.DecodeYAMLReport(&native)
return err if err != nil {
}
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 {
return err return err
} }
values[document.name] = decoded
}
if plan.scopeName == "all" {
value = values
} else {
value = values[documents[0].name]
} }
return nil return nil
}) })
if err != nil && invocation.Context.Err() != nil { if err != nil {
return configFailure("Configuration show was canceled.", commandkit.Explanation{}, invocation.Context.Err()) 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() 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 package ckconfig
import ( import (
"bytes"
"fmt" "fmt"
"io" "text/template"
"strconv"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit" configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4"
) )
type sourceReport struct { type sourceReport struct {
Scope string `yaml:"scope"` Scope string `json:"scope" yaml:"scope"`
Sources []sourceDTO `yaml:"sources"` Sources []sourceDTO `json:"sources" yaml:"sources"`
} }
type sourceDTO struct { type sourceDTO struct {
ID string `yaml:"id"` ID string `json:"id" yaml:"id"`
Label string `yaml:"label"` Label string `json:"label" yaml:"label"`
Path string `yaml:"path"` Path string `json:"path" yaml:"path"`
Destination string `yaml:"destination"` Destination string `json:"destination" yaml:"destination"`
Placement string `yaml:"placement"` Placement string `json:"placement" yaml:"placement"`
DestinationIsFallback bool `yaml:"destination_is_fallback"` DestinationIsFallback bool `json:"destination_is_fallback" yaml:"destination_is_fallback"`
Layer string `yaml:"layer"` Layer string `json:"layer" yaml:"layer"`
Kind string `yaml:"kind"` Kind string `json:"kind" yaml:"kind"`
UnderlayIndex int `yaml:"underlay_index"` UnderlayIndex int `json:"underlay_index" yaml:"underlay_index"`
ImportedFrom *locationDTO `yaml:"imported_from"` ImportedFrom *locationDTO `json:"imported_from" yaml:"imported_from"`
VersionEvidence *sourceVersionEvidenceDTO `yaml:"version_evidence"` VersionEvidence *sourceVersionEvidenceDTO `json:"version_evidence" yaml:"version_evidence"`
EmptyImportSelections []emptyImportSelectionDTO `yaml:"empty_import_selections"` EmptyImportSelections []emptyImportSelectionDTO `json:"empty_import_selections" yaml:"empty_import_selections"`
} }
type emptyImportSelectionDTO struct { type emptyImportSelectionDTO struct {
Path string `yaml:"path"` Path string `json:"path" yaml:"path"`
Location locationDTO `yaml:"location"` Location locationDTO `json:"location" yaml:"location"`
Selector string `yaml:"selector"` Selector string `json:"selector" yaml:"selector"`
} }
type locationDTO struct { type locationDTO struct {
SourceID string `yaml:"source_id"` SourceID string `json:"source_id" yaml:"source_id"`
Line int `yaml:"line"` Line int `json:"line" yaml:"line"`
Column int `yaml:"column"` Column int `json:"column" yaml:"column"`
} }
type sourceVersionEvidenceDTO struct { type sourceVersionEvidenceDTO struct {
Path string `yaml:"path"` Path string `json:"path" yaml:"path"`
Version versionDTO `yaml:"version"` Version versionDTO `json:"version" yaml:"version"`
Location *locationDTO `yaml:"location"` Location *locationDTO `json:"location" yaml:"location"`
Redacted bool `yaml:"redacted"` Redacted bool `json:"redacted" yaml:"redacted"`
} }
type versionDTO struct { type versionDTO struct {
Present bool `yaml:"present"` Present bool `json:"present" yaml:"present"`
Null bool `yaml:"null"` Null bool `json:"null" yaml:"null"`
Text string `yaml:"text"` Text string `json:"text" yaml:"text"`
} }
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { 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 { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err) return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
} }
return renderPreparedConfigReport(invocation, acquired.Explanation, func() ([]byte, error) { _ = reporter.Report(commandkit.Report{Value: document, TextTemplate: sourceTextTemplate})
var report bytes.Buffer return commandkit.Success()
if plan.format == "yaml" {
if err := encodeYAML(&report, document); err != nil {
return nil, err
}
} else {
writeSourceReportText(&report, document)
}
return report.Bytes(), nil
})
} }
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) { var sourceTextTemplate = template.Must(commandkit.ParseTextTemplate("sources", `Scope: {{printf "%q" .Scope}}
_, _ = fmt.Fprintf(writer, "Scope: %s\n", strconv.Quote(document.Scope)) {{if .Sources}}Sources:
if len(document.Sources) == 0 { {{range .Sources}} - id: {{printf "%q" .ID}}
_, _ = writer.WriteString("Sources: []\n") label: {{printf "%q" .Label}}
return path: {{printf "%q" .Path}}
} destination: {{printf "%q" .Destination}}
_, _ = writer.WriteString("Sources:\n") placement: {{printf "%q" .Placement}}
for _, source := range document.Sources { destination_is_fallback: {{.DestinationIsFallback}}
_, _ = fmt.Fprintf(writer, " - id: %s\n", strconv.Quote(source.ID)) layer: {{printf "%q" .Layer}}
_, _ = fmt.Fprintf(writer, " label: %s\n", strconv.Quote(source.Label)) kind: {{printf "%q" .Kind}}
_, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path)) underlay_index: {{.UnderlayIndex}}
_, _ = fmt.Fprintf(writer, " destination: %s\n", strconv.Quote(source.Destination)) {{if .ImportedFrom}} imported_from: {source_id: {{printf "%q" .ImportedFrom.SourceID}}, line: {{.ImportedFrom.Line}}, column: {{.ImportedFrom.Column}}}
_, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement)) {{else}} imported_from: null
_, _ = fmt.Fprintf(writer, " destination_is_fallback: %t\n", source.DestinationIsFallback) {{end}}{{if .VersionEvidence}} version_evidence:
_, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer)) path: {{printf "%q" .VersionEvidence.Path}}
_, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind)) version:
_, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex) present: {{.VersionEvidence.Version.Present}}
if source.ImportedFrom == nil { null: {{.VersionEvidence.Version.Null}}
_, _ = writer.WriteString(" imported_from: null\n") text: {{printf "%q" .VersionEvidence.Version.Text}}
} else { {{if .VersionEvidence.Location}} location: {source_id: {{printf "%q" .VersionEvidence.Location.SourceID}}, line: {{.VersionEvidence.Location.Line}}, column: {{.VersionEvidence.Location.Column}}}
_, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n", {{else}} location: null
strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column) {{end}} redacted: {{.VersionEvidence.Redacted}}
} {{else}} version_evidence: null
if source.VersionEvidence == nil { {{end}}{{if .EmptyImportSelections}} empty_import_selections:
_, _ = writer.WriteString(" version_evidence: null\n") {{range .EmptyImportSelections}} - path: {{printf "%q" .Path}}
} else { location: {source_id: {{printf "%q" .Location.SourceID}}, line: {{.Location.Line}}, column: {{.Location.Column}}}
check := source.VersionEvidence selector: {{printf "%q" .Selector}}
_, _ = fmt.Fprintf(writer, " version_evidence:\n path: %s\n", strconv.Quote(check.Path)) {{end}}{{else}} empty_import_selections: []
_, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n", {{end}}{{end}}{{else}}Sources: []
check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text)) {{end}}`, nil))
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)
}
func locationReports(locations []configkit.Location) []locationDTO { func locationReports(locations []configkit.Location) []locationDTO {
report := make([]locationDTO, 0, len(locations)) report := make([]locationDTO, 0, len(locations))

View File

@@ -1,83 +1,58 @@
package ckconfig package ckconfig
import ( import (
"bytes"
"fmt" "fmt"
"io" "text/template"
"strings"
commandkit "go.maximumdirect.net/commandkit" 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 { func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
reporter := invocation.Reporter() reporter := invocation.Reporter()
writtenRecord := false report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
outcome := commandkit.Success()
for _, selected := range plan.scopes { for _, selected := range plan.scopes {
if err := invocation.Context.Err(); err != nil { 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 { if selected.scope.scope.Acquire == nil {
message := fmt.Sprintf("Configuration scope %q has no acquisition callback.", selected.scope.scope.Name) report.Scopes = append(report.Scopes, validationScope{Scope: name, Outcome: "failed"})
return commandkit.Failure(message) outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
break
} }
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{ acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: plan.selection}, selected.input, state)
Operation: Validate, if err != nil {
Selection: plan.selection, status := "failed"
}, selected.input, state) if invocation.Context.Err() != nil {
if acquireErr != nil { status = "canceled"
return configAcquisitionFailure(reporter, "validate", selected.scope.scope.Name, acquired, acquireErr) }
report.Scopes = append(report.Scopes, validationScope{Scope: name, Outcome: status})
outcome = configAcquisitionFailure(reporter, "validate", name, acquired, err)
break
} }
queueConfigDiagnostics(reporter, acquired.Diagnostics) queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err := invocation.Context.Err(); err != nil { 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
} }
report.Scopes = append(report.Scopes, validationScope{Scope: name, Outcome: "valid"})
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
} }
return commandkit.Success() if len(report.Scopes) > 0 {
} _ = reporter.Report(commandkit.Report{Value: report, TextTemplate: validationTextTemplate})
}
func validationSuccessRecord(scope, format string) ([]byte, error) { return outcome
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
} }