Add configuration path discovery

This commit is contained in:
2026-11-07 04:44:01 +00:00
parent aeb70e5873
commit b38130548e
8 changed files with 307 additions and 20 deletions

View File

@@ -0,0 +1,116 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestExplainDiscoveryModesAndMetadata(t *testing.T) {
loads, inputs, states := 0, 0, 0
scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) {
loads++
return configkit.Load(configkit.Mapping("fixture", map[string]any{"a/b": map[string]any{"x": 1}, "ab": 2, "abacus": 3, "gone": map[string]any{"x": 1}}), configkit.Options[map[string]any]{Overrides: selection.Overrides})
})
scope.Features.Overrides = true
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
run := func(args ...string) (commandkit.Outcome, string) {
var out bytes.Buffer
result := app.Execute(context.Background(), append([]string{"config", "explain"}, args...), commandkit.Streams{Out: &out, Err: io.Discard})
return result, out.String()
}
result, out := run()
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, "--list") || !strings.Contains(out, "--path") || loads != 0 || inputs != 0 || states != 0 {
t.Fatalf("bare help: %#v %q, %d/%d/%d", result, out, loads, inputs, states)
}
for _, args := range [][]string{{"--list", "--path=/ab"}, {"--historical"}, {"--under=/ab"}, {"--explicit=false"}, {"--path=/ab", "--under=/ab"}, {"--list", "--under=bad"}} {
result, _ := run(args...)
if result.Kind != commandkit.OutcomeUsage || loads != 0 || inputs != 0 || states != 0 {
t.Fatalf("invalid %v: %#v, %d/%d/%d", args, result, loads, inputs, states)
}
}
result, out = run("--list", "--under=/a~1b", "--format=json")
var report pathDiscoveryReport
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 2 {
t.Fatalf("escaped subtree: %#v %q", result, out)
}
seen := map[string]bool{}
for _, entry := range report.Paths {
seen[entry.Path] = true
}
if !seen["/a~1b"] || !seen["/a~1b/x"] {
t.Fatalf("escaped paths: %#v", report.Paths)
}
result, out = run("--list", "--under=/ab", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 1 || report.Paths[0].Path != "/ab" {
t.Fatalf("token boundary: %#v %q", result, out)
}
result, out = run("--list", "--historical", "--set=/gone=null", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
t.Fatalf("historical: %#v %q", result, out)
}
removed := false
for _, entry := range report.Paths {
if entry.Path == "/gone/x" && !entry.Present && entry.Metadata == nil {
removed = true
}
}
if !removed {
t.Fatalf("missing historical entry: %q", out)
}
result, out = run("--list", "--historical", "--explicit=false", "--set=/gone=null", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil {
t.Fatalf("explicit filter: %#v %q", result, out)
}
for _, entry := range report.Paths {
if !entry.Present || entry.Metadata == nil || entry.Metadata.Explicit {
t.Fatalf("invalid filtered entry: %#v", entry)
}
}
result, out = run("--path=", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"path": ""`) || !strings.Contains(out, `"metadata"`) {
t.Fatalf("root explanation: %#v %q", result, out)
}
result, out = run("--path=/gone/x", "--set=/gone=null", "--format=json")
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"metadata"`) || !strings.Contains(out, `"history"`) {
t.Fatalf("historical explanation: %#v %q", result, out)
}
}
func TestExplainDiscoveryCapabilityBeforeAcquisition(t *testing.T) {
calls := 0
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, 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{}]{scope}}))
result := app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || calls != 0 {
t.Fatalf("unsupported discovery: %#v calls=%d", result, calls)
}
scope.Features.PathDiscovery = true
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result = app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational || calls != 1 {
t.Fatalf("missing metadata: %#v calls=%d", result, calls)
}
}
func TestExplainBareHelpRecordsDeliveryFailure(t *testing.T) {
inputs, states, calls := 0, 0, 0
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}}))
result := app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
if result.Presentation.Output.State != commandkit.DeliveryIncomplete || inputs != 0 || states != 0 || calls != 0 {
t.Fatalf("help delivery = %#v, input/state/acquire=%d/%d/%d", result, inputs, states, calls)
}
}

View File

@@ -3,6 +3,7 @@ package ckconfig
import (
"bytes"
"fmt"
"strings"
"text/template"
commandkit "go.maximumdirect.net/commandkit"
@@ -16,8 +17,37 @@ type explanationReport struct {
Origin explanationOriginDTO `json:"origin" yaml:"origin"`
History []explanationChangeDTO `json:"history" yaml:"history"`
SecretReference *secretReferenceDTO `json:"secret_reference" yaml:"secret_reference"`
Metadata *explanationMetadata `json:"metadata,omitempty"`
}
type explanationMetadata struct {
Explicit *bool `json:"explicit,omitempty"`
Null *bool `json:"null,omitempty"`
History []metadataHistoryRecord `json:"history,omitempty"`
}
type metadataHistoryRecord struct {
Path string `json:"path"`
Previous []explanationOriginDTO `json:"previous,omitempty"`
}
type pathDiscoveryReport struct {
Scope string `json:"scope"`
Paths []pathDiscoveryEntry `json:"paths"`
}
type pathDiscoveryEntry struct {
Path string `json:"path"`
Present bool `json:"present"`
Metadata *pathMetadata `json:"metadata,omitempty"`
}
type pathMetadata struct {
Explicit bool `json:"explicit"`
}
var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil))
type snapshotDTO struct {
Present bool `json:"present" yaml:"present"`
Redacted bool `json:"redacted" yaml:"redacted"`
@@ -72,6 +102,31 @@ 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.list {
if acquired.Value.Metadata == nil {
return configFailure("Configuration scope did not provide path metadata.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path discovery without metadata", name))
}
report := pathDiscoveryReport{Scope: name, Paths: []pathDiscoveryEntry{}}
for _, path := range acquired.Value.Metadata.HistoricalPaths() {
if plan.under != nil && !pathWithin(path, *plan.under) {
continue
}
info, present := acquired.Value.Metadata.Lookup(path)
if !present && (!plan.historical || plan.explicit.Set) {
continue
}
if present && plan.explicit.Set && info.Explicit != plan.explicit.Value {
continue
}
entry := pathDiscoveryEntry{Path: path.String(), Present: present}
if present {
entry.Metadata = &pathMetadata{Explicit: info.Explicit}
}
report.Paths = append(report.Paths, entry)
}
_ = reporter.Report(commandkit.Report{Value: report, TextTemplate: pathDiscoveryTemplate})
return commandkit.Success()
}
if acquired.Value.Explanation == nil {
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
@@ -85,20 +140,59 @@ 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)
}
report := makeExplanationReport(name, explanation)
if acquired.Value.Metadata != nil {
metadata := &explanationMetadata{}
if info, ok := acquired.Value.Metadata.Lookup(plan.path); ok {
metadata.Explicit, metadata.Null = &info.Explicit, &info.Null
}
for _, change := range acquired.Value.Metadata.History(plan.path) {
entry := metadataHistoryRecord{Path: change.Path.String()}
for _, previous := range change.Previous {
entry.Previous = append(entry.Previous, originReport(previous))
}
metadata.History = append(metadata.History, entry)
}
if metadata.Explicit != nil || len(metadata.History) > 0 {
report.Metadata = metadata
}
}
text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{
"render": func(explanationReport) (string, error) {
"render": func(report explanationReport) (string, error) {
var output bytes.Buffer
err := acquired.Value.Explanation.WriteExplanation(&output, plan.path)
if err == nil && report.Metadata != nil && report.Metadata.Explicit != nil {
_, err = fmt.Fprintf(&output, "Explicit: %t\nNull: %t\n", *report.Metadata.Explicit, *report.Metadata.Null)
}
return output.String(), err
},
}))
renderErr := reporter.Report(commandkit.Report{Value: makeExplanationReport(name, explanation), TextTemplate: text})
renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
if renderErr != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
}
return commandkit.Success()
}
func pathWithin(path, parent configkit.Path) bool {
tokens := func(value string) []string {
if value == "" {
return nil
}
return strings.Split(strings.TrimPrefix(value, "/"), "/")
}
child, prefix := tokens(path.String()), tokens(parent.String())
if len(prefix) > len(child) {
return false
}
for index := range prefix {
if prefix[index] != child[index] {
return false
}
}
return true
}
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
report := explanationReport{
Scope: scope,

View File

@@ -13,6 +13,10 @@ type configCommandInput[I, S any] struct {
against Selection
scope commandkit.Supplied[string]
path commandkit.Supplied[string]
list commandkit.Supplied[bool]
historical commandkit.Supplied[bool]
under commandkit.Supplied[string]
explicit commandkit.Supplied[bool]
values commandkit.FlagValues[I]
againstValues commandkit.FlagValues[I]
plan *configOperationPlan[I, S]
@@ -34,6 +38,11 @@ type configOperationPlan[I, S any] struct {
operation Operation
scopeName string
path configkit.Path
list bool
help bool
historical bool
under *configkit.Path
explicit commandkit.Supplied[bool]
scopes []selectedScopeInput[I, S]
comparison *preparedComparison[I, S]
}
@@ -89,8 +98,13 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
}
}
if operation == Explain {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
flags = append(flags,
commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "list", Usage: "List configuration paths."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).list = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "historical", Usage: "Include historical-only paths when listing."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).historical = value }),
commandkit.ValueFlag(commandkit.FlagMeta{Name: "under", Usage: "List paths under a JSON Pointer."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).under = value }),
commandkit.BoolFlag(commandkit.FlagMeta{Name: "explicit", Usage: "Filter listed current paths by explicitness."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).explicit = value }),
)
}
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
if input == nil || *input == nil {
@@ -110,8 +124,9 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
name, summary := operationDeclaration(operation)
return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{
Name: name, Summary: summary,
NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} },
Flags: flags,
Description: operationDescription(operation),
NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} },
Flags: flags,
CheckInput: func(input *configCommandInput[I, S]) error {
if input == nil {
return fmt.Errorf("configuration input must not be nil")
@@ -124,6 +139,13 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
})
}
func operationDescription(operation Operation) string {
if operation == Explain {
return "Use --list to discover paths or --path POINTER to explain one path."
}
return ""
}
func operationDeclaration(operation Operation) (string, string) {
switch operation {
case Validate:
@@ -142,6 +164,18 @@ func operationDeclaration(operation Operation) (string, string) {
}
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
if operation == Explain {
if input.list.Value && input.path.Set {
return fmt.Errorf("--list and --path cannot be combined")
}
if !input.list.Value && (input.historical.Set || input.under.Set || input.explicit.Set) {
return fmt.Errorf("--historical, --under, and --explicit require --list")
}
if !input.list.Value && !input.path.Set {
input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true}
return nil
}
}
scopeName := "app"
if input.scope.Set {
@@ -170,26 +204,38 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O
if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides {
return fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name)
}
if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery {
return fmt.Errorf("configuration scope %q does not support path discovery", scope.scope.Name)
}
}
var path configkit.Path
if operation == Explain {
if !input.path.Set {
return fmt.Errorf("configuration explanation requires --path")
}
if operation == Explain && input.path.Set {
parsed, err := configkit.ParsePath(input.path.Value)
if err != nil {
return fmt.Errorf("invalid configuration path: %w", err)
}
path = parsed
}
var under *configkit.Path
if input.under.Set {
parsed, err := configkit.ParsePath(input.under.Value)
if err != nil {
return fmt.Errorf("invalid subtree path: %w", err)
}
under = &parsed
}
plan := &configOperationPlan[I, S]{
prepared: prepared,
operation: operation,
scopeName: scopeName,
path: path,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
prepared: prepared,
operation: operation,
scopeName: scopeName,
path: path,
list: input.list.Value,
historical: input.historical.Value,
under: under,
explicit: input.explicit,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
}
if operation == Diff {
scope := selected[0]

View File

@@ -11,6 +11,9 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op
if input == nil || input.plan == nil {
return commandkit.Failure("Configuration operation was not prepared.")
}
if input.plan.help {
return invocation.Help()
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
}