Move configuration reports to shared rendering
This commit is contained in:
55
docs/cli.md
55
docs/cli.md
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
152
integrations/ckconfig/shared_report_test.go
Normal file
152
integrations/ckconfig/shared_report_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package ckconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
commandkit "go.maximumdirect.net/commandkit"
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShowSharedFormatsAndScopeShape(t *testing.T) {
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||||
|
Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("'false'\n")}}, nil
|
||||||
|
},
|
||||||
|
}}}))
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"automatic", []string{"config", "show"}, "false\n"},
|
||||||
|
{"text", []string{"config", "show", "--format=text"}, "false\n"},
|
||||||
|
{"yaml", []string{"config", "show", "--format=yaml"}, "\"false\"\n"},
|
||||||
|
{"json", []string{"config", "show", "--format=json"}, "\"false\"\n"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(t.Context(), tc.args, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
if tc.name == "yaml" {
|
||||||
|
value, err := commandkit.DecodeYAMLReport(&out)
|
||||||
|
if err != nil || value != "false" || result.Status != 0 {
|
||||||
|
t.Fatalf("%#v value=%#v err=%v", result, value, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Status != 0 || out.String() != tc.want {
|
||||||
|
t.Fatalf("%#v output=%q", result, out.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(t.Context(), []string{"config", "show", "--scope=all", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &decoded); err != nil || result.Status != 0 || !reflect.DeepEqual(decoded, map[string]any{"app": "false"}) {
|
||||||
|
t.Fatalf("%#v output=%q decoded=%#v err=%v", result, out.String(), decoded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowConversionFailureIsPresentationEvidence(t *testing.T) {
|
||||||
|
for _, quiet := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "ordinary", true: "quiet"}[quiet], func(t *testing.T) {
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue("x: [invalid\n")}}, nil
|
||||||
|
}}}}))
|
||||||
|
args := []string{"config", "show"}
|
||||||
|
if quiet {
|
||||||
|
args = append(args, "--quiet")
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(t.Context(), args, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.RenderError == nil || out.Len() != 0 {
|
||||||
|
t.Fatalf("%#v output=%q", result, out.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationSubmitsOneReportAfterAcquisitionStops(t *testing.T) {
|
||||||
|
failure := errors.New("invalid configuration")
|
||||||
|
calls := 0
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
|
||||||
|
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}},
|
||||||
|
{Name: "middle", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{}, failure
|
||||||
|
}},
|
||||||
|
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}},
|
||||||
|
}}))
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(t.Context(), []string{"config", "validate", "--scope=all", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
var report struct {
|
||||||
|
Scopes []struct{ Scope, Outcome string }
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(&out)
|
||||||
|
if err := dec.Decode(&report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := dec.Decode(new(any)); err != io.EOF {
|
||||||
|
t.Fatalf("additional JSON: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(result.Cause, failure) || calls != 2 || len(report.Scopes) != 2 || report.Scopes[0].Outcome != "valid" || report.Scopes[1].Outcome != "failed" {
|
||||||
|
t.Fatalf("result=%#v calls=%d report=%#v", result, calls, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationDeliveryFailureDoesNotStopAcquisition(t *testing.T) {
|
||||||
|
writeErr := errors.New("write failed")
|
||||||
|
calls := 0
|
||||||
|
acquire := func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||||
|
calls++
|
||||||
|
return commandkit.Acquired[View]{}, nil
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Validate}, Acquire: acquire}, {Name: "second", Operations: []Operation{Validate}, Acquire: acquire}}}))
|
||||||
|
result := app.Execute(t.Context(), []string{"config", "validate", "--scope=all"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||||
|
if calls != 2 || !errors.Is(result.Presentation.Output.WriteError, writeErr) || result.Status == 0 {
|
||||||
|
t.Fatalf("result=%#v calls=%d", result, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigurationReportsSupportSharedFormats(t *testing.T) {
|
||||||
|
type settings struct {
|
||||||
|
Greeting string `yaml:"greeting"`
|
||||||
|
}
|
||||||
|
app := newConfigTestApp(t, AppCommand(func(context.Context, Selection) (*configkit.Result[settings], error) {
|
||||||
|
return configkit.Load(configkit.Mapping("fixture", map[string]any{"greeting": "hello"}), configkit.Options[settings]{})
|
||||||
|
}))
|
||||||
|
for _, args := range [][]string{{"scopes"}, {"show"}, {"sources"}, {"explain", "--path=/greeting"}, {"diff"}, {"validate"}} {
|
||||||
|
for _, format := range []string{"text", "yaml", "json"} {
|
||||||
|
t.Run(args[0]+"/"+format, func(t *testing.T) {
|
||||||
|
argv := append([]string{"config"}, args...)
|
||||||
|
argv = append(argv, "--format="+format)
|
||||||
|
var out bytes.Buffer
|
||||||
|
result := app.Execute(t.Context(), argv, commandkit.Streams{Out: &out, Err: io.Discard})
|
||||||
|
if result.Status != 0 {
|
||||||
|
t.Fatalf("%#v output=%q", result, out.String())
|
||||||
|
}
|
||||||
|
if format == "json" {
|
||||||
|
dec := json.NewDecoder(&out)
|
||||||
|
var value any
|
||||||
|
if err := dec.Decode(&value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := dec.Decode(&value); err != io.EOF {
|
||||||
|
t.Fatalf("extra JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"bytes"
|
"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')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package ckconfig
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.yaml.in/yaml/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEffectiveYAMLFramingPreservesOneNativeDocument(t *testing.T) {
|
|
||||||
for _, native := range []string{
|
|
||||||
"--- {setting: value}\n", "---\t{setting: value}\n", "--- value\n",
|
|
||||||
"--- |\n first\n second\n", "--- # authored\nsetting: value\n",
|
|
||||||
"setting: value\n", "%YAML 1.1\n--- {setting: value}\n",
|
|
||||||
} {
|
|
||||||
t.Run(native, func(t *testing.T) {
|
|
||||||
var want any
|
|
||||||
if err := yaml.Unmarshal([]byte(native), &want); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
framed, _ := frameEffectiveYAML("app", []byte(native))
|
|
||||||
withoutLabel := bytes.Replace(framed, []byte("# scope: app\n"), nil, 1)
|
|
||||||
if !strings.HasPrefix(native, "---") && !strings.HasPrefix(native, "%") {
|
|
||||||
withoutLabel = bytes.TrimPrefix(withoutLabel, []byte("---\n"))
|
|
||||||
}
|
|
||||||
if !bytes.Equal(withoutLabel, []byte(native)) {
|
|
||||||
t.Fatalf("native content changed: %q", framed)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(framed, []byte("# scope: app\n")) {
|
|
||||||
t.Fatalf("missing label: %q", framed)
|
|
||||||
}
|
|
||||||
decoder := yaml.NewDecoder(bytes.NewReader(framed))
|
|
||||||
var got any
|
|
||||||
if err := decoder.Decode(&got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("got=%#v want=%#v", got, want)
|
|
||||||
}
|
|
||||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
|
||||||
t.Fatalf("extra document: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEffectiveYAMLFramingPreservesBoundariesAndDirectives(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
scope string
|
|
||||||
native string
|
|
||||||
directive bool
|
|
||||||
label string
|
|
||||||
}{
|
|
||||||
{name: "no trailing newline", scope: "app", native: "value: plain", label: "# scope: app\n"},
|
|
||||||
{name: "CRLF", scope: "app", native: "# authored\r\n---\r\nvalue: yes\r\n", label: "# scope: app\n"},
|
|
||||||
{name: "directive preamble", scope: "app", native: "%YAML 1.1\n# authored\n---\nvalue: yes", directive: true, label: "# scope: app\n"},
|
|
||||||
{name: "control character label", scope: "line\nbreak", native: "value: yes", label: "# scope: \"line\\nbreak\"\n"},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
var want any
|
|
||||||
if err := yaml.Unmarshal([]byte(test.native), &want); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
framed, hasDirective := frameEffectiveYAML(test.scope, []byte(test.native))
|
|
||||||
if hasDirective != test.directive {
|
|
||||||
t.Fatalf("hasDirective = %t, want %t", hasDirective, test.directive)
|
|
||||||
}
|
|
||||||
withoutLabel := bytes.Replace(framed, []byte(test.label), nil, 1)
|
|
||||||
if !strings.HasPrefix(test.native, "---") && !strings.HasPrefix(test.native, "%") {
|
|
||||||
withoutLabel = bytes.TrimPrefix(withoutLabel, []byte("---\n"))
|
|
||||||
}
|
|
||||||
if !bytes.Equal(withoutLabel, []byte(test.native)) {
|
|
||||||
t.Fatalf("native content changed: got %q want %q", withoutLabel, test.native)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(framed, []byte(test.label)) {
|
|
||||||
t.Fatalf("missing scope label %q in %q", test.label, framed)
|
|
||||||
}
|
|
||||||
decoder := yaml.NewDecoder(bytes.NewReader(framed))
|
|
||||||
var first any
|
|
||||||
if err := decoder.Decode(&first); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(first, want) {
|
|
||||||
t.Fatalf("decoded value = %#v, want %#v", first, want)
|
|
||||||
}
|
|
||||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
|
||||||
t.Fatalf("unexpected extra YAML document: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +1,56 @@
|
|||||||
package ckconfig
|
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))
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user