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

@@ -421,21 +421,32 @@ for native evidence definitions.
### Explain a configuration path ### Explain a configuration path
`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. An explicitly empty path selects the root. `config explain
root. The default scope is `app`; `--format yaml` or `--format json` selects --list` discovers paths when the selected scope supports path discovery. An
invocation with neither mode displays help; the two modes cannot be combined.
The default scope is `app`; `--format yaml` or `--format json` selects
structured output, and automatic output uses text. `--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
example config explain --path /profiles/team~1summary --format yaml example config explain --path /profiles/team~1summary --format yaml
example config explain --path= --format yaml example config explain --path= --format yaml
example config explain --list --under /profiles --historical
``` ```
Listing normally includes current paths. `--historical` adds paths that only
occurred in history; `--under POINTER` selects a subtree, and `--explicit=true`
or `--explicit=false` filters current paths by native explicitness. Historical-only
paths do not match an explicitness filter. These options require `--list`.
The report contains the scope and each selected path's current presence and,
when present, its explicitness. Path discovery does not include values or history.
Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0` Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0`
represents `~`. A malformed pointer is a usage error with status `2` and is represents `~`. A malformed pointer is a usage error with status `2` and is
rejected before acquisition. A well-formed path that is unknown in the selected 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 includes
the scope's native explanation writer. the scope's native explanation and available field metadata. Structured output
includes metadata when supplied by the scope.
Structured output uses these root fields: Structured output uses these root fields:

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–16 complete. Status: implementation in progress; Stages 1–17 complete.
## Scope, authority, and execution ## Scope, authority, and execution
@@ -610,6 +610,12 @@ algorithms remain upstream tests.
## Stage 17: Explanation discovery and metadata ## Stage 17: Explanation discovery and metadata
**Complete.** Explain supports discovery filters and field metadata, with
mode and capability checks before acquisition. `Invocation.Help` renders the
selected command's existing help when no mode is selected, avoiding application
factories. Root, ckconfig, and ckprompt workspace suites passed with Go 1.27.0,
including historical paths, escaped subtrees, and help delivery.
Extend `config explain` under [OD-4](promptkit-v0.20.0.md#od-4-configuration-path-and-metadata-presentation). Extend `config explain` under [OD-4](promptkit-v0.20.0.md#od-4-configuration-path-and-metadata-presentation).
Track supplied `--path` separately from its value; root `--path ''` is an Track supplied `--path` separately from its value; root `--path ''` is an
operation. Bare invocation shows successful command help before input/state operation. Bare invocation shows successful command help before input/state

View File

@@ -107,6 +107,7 @@ func (current *execution) invocation() Invocation {
reporter: current.reporter, reporter: current.reporter,
enteredPath: current.route.enteredPath.String(), enteredPath: current.route.enteredPath.String(),
canonicalPath: current.route.canonicalPath.String(), canonicalPath: current.route.canonicalPath.String(),
renderHelp: func() Outcome { return current.renderHelp(nil) },
} }
} }

View File

@@ -52,12 +52,22 @@ type Invocation struct {
reporter *Reporter reporter *Reporter
enteredPath string enteredPath string
canonicalPath string canonicalPath string
renderHelp func() Outcome
} }
// Reporter returns the reporting facility supplied for this invocation. A // Reporter returns the reporting facility supplied for this invocation. A
// manually constructed Invocation has no reporter. // manually constructed Invocation has no reporter.
func (inv Invocation) Reporter() *Reporter { return inv.reporter } func (inv Invocation) Reporter() *Reporter { return inv.reporter }
// Help renders the selected command's help through this invocation's output.
// It returns an operational failure when the invocation has no help renderer.
func (inv Invocation) Help() Outcome {
if inv.renderHelp == nil {
return Failure("Command help is unavailable.")
}
return inv.renderHelp()
}
// EnteredPath returns the command path used to invoke the selected command, // EnteredPath returns the command path used to invoke the selected command,
// excluding the executable name, flags, and operands. It is empty for an // excluding the executable name, flags, and operands. It is empty for an
// Invocation that was not created by command execution. // Invocation that was not created by command execution.

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 ( import (
"bytes" "bytes"
"fmt" "fmt"
"strings"
"text/template" "text/template"
commandkit "go.maximumdirect.net/commandkit" commandkit "go.maximumdirect.net/commandkit"
@@ -16,8 +17,37 @@ type explanationReport struct {
Origin explanationOriginDTO `json:"origin" yaml:"origin"` Origin explanationOriginDTO `json:"origin" yaml:"origin"`
History []explanationChangeDTO `json:"history" yaml:"history"` History []explanationChangeDTO `json:"history" yaml:"history"`
SecretReference *secretReferenceDTO `json:"secret_reference" yaml:"secret_reference"` 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 { type snapshotDTO struct {
Present bool `json:"present" yaml:"present"` Present bool `json:"present" yaml:"present"`
Redacted bool `json:"redacted" yaml:"redacted"` 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 { 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.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 { if acquired.Value.Explanation == nil {
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name) 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) 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 { 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)
} }
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{ text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{
"render": func(explanationReport) (string, error) { "render": func(report explanationReport) (string, error) {
var output bytes.Buffer var output bytes.Buffer
err := acquired.Value.Explanation.WriteExplanation(&output, plan.path) 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 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 { if renderErr != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
} }
return commandkit.Success() 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 { func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
report := explanationReport{ report := explanationReport{
Scope: scope, Scope: scope,

View File

@@ -13,6 +13,10 @@ type configCommandInput[I, S any] struct {
against Selection against Selection
scope commandkit.Supplied[string] scope commandkit.Supplied[string]
path 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] values commandkit.FlagValues[I]
againstValues commandkit.FlagValues[I] againstValues commandkit.FlagValues[I]
plan *configOperationPlan[I, S] plan *configOperationPlan[I, S]
@@ -34,6 +38,11 @@ type configOperationPlan[I, S any] struct {
operation Operation operation Operation
scopeName string scopeName string
path configkit.Path path configkit.Path
list bool
help bool
historical bool
under *configkit.Path
explicit commandkit.Supplied[bool]
scopes []selectedScopeInput[I, S] scopes []selectedScopeInput[I, S]
comparison *preparedComparison[I, S] comparison *preparedComparison[I, S]
} }
@@ -89,8 +98,13 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
} }
} }
if operation == Explain { if operation == Explain {
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, flags = append(flags,
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value })) 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] { flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
if input == nil || *input == nil { if input == nil || *input == nil {
@@ -110,8 +124,9 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio
name, summary := operationDeclaration(operation) name, summary := operationDeclaration(operation)
return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{ return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{
Name: name, Summary: summary, Name: name, Summary: summary,
NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} }, Description: operationDescription(operation),
Flags: flags, NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} },
Flags: flags,
CheckInput: func(input *configCommandInput[I, S]) error { CheckInput: func(input *configCommandInput[I, S]) error {
if input == nil { if input == nil {
return fmt.Errorf("configuration input must not be 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) { func operationDeclaration(operation Operation) (string, string) {
switch operation { switch operation {
case Validate: 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 { 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" scopeName := "app"
if input.scope.Set { 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 { 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) 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 var path configkit.Path
if operation == Explain { if operation == Explain && input.path.Set {
if !input.path.Set {
return fmt.Errorf("configuration explanation requires --path")
}
parsed, err := configkit.ParsePath(input.path.Value) parsed, err := configkit.ParsePath(input.path.Value)
if err != nil { if err != nil {
return fmt.Errorf("invalid configuration path: %w", err) return fmt.Errorf("invalid configuration path: %w", err)
} }
path = parsed 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]{ plan := &configOperationPlan[I, S]{
prepared: prepared, prepared: prepared,
operation: operation, operation: operation,
scopeName: scopeName, scopeName: scopeName,
path: path, path: path,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)), list: input.list.Value,
historical: input.historical.Value,
under: under,
explicit: input.explicit,
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
} }
if operation == Diff { if operation == Diff {
scope := selected[0] 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 { if input == nil || input.plan == nil {
return commandkit.Failure("Configuration operation was not prepared.") return commandkit.Failure("Configuration operation was not prepared.")
} }
if input.plan.help {
return invocation.Help()
}
if err := invocation.Context.Err(); err != nil { if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err) return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err)
} }