Migrate ConfigKit reports to the reporter

This commit is contained in:
2026-10-26 03:20:17 +00:00
parent 5f37aac70f
commit c0e0010683
9 changed files with 177 additions and 129 deletions

View File

@@ -3,41 +3,44 @@ package ckconfig
import (
"errors"
"fmt"
"io"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
)
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
if plan.comparison == nil || plan.comparison.scope == nil {
return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false)
cause := errors.New("configuration comparison was not prepared")
return configFailure("Configuration comparison was not prepared.", commandkit.Explanation{}, cause)
}
selected := plan.comparison
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, nil, false)
return configFailure("Configuration comparison was canceled.", commandkit.Explanation{}, err)
}
compare := selected.scope.scope.Compare
if compare == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false)
cause := fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name)
return configFailure("Configuration scope does not support comparison.", commandkit.Explanation{}, cause)
}
acquired, err := compare(invocation.Context, selected.request, state)
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
reporter := invocation.Reporter()
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure(fmt.Sprintf("Unable to compare configuration scope %q.", selected.scope.scope.Name), acquired.Explanation, err)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
}
report, err := renderDiffReport(acquired.Value)
if err != nil {
return configOperationFailure(err, diagnostics, false)
err = reporter.RenderOutput(func(writer io.Writer) error {
report, err := renderDiffReport(acquired.Value)
if err != nil {
return err
}
_, err = writer.Write(report)
return err
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration comparison was canceled.", acquired.Explanation, invocation.Context.Err())
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := writeConfigReport(invocation.Streams.Out, report); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return commandkit.Success()
}

View File

@@ -101,21 +101,30 @@ func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
wantErr := errors.New("provider unavailable")
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr)
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{diagnostic},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "native comparison context\n")
return err
}}},
}, fmt.Errorf("comparison setup: %w", wantErr)
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
}
if got := diagnosticOutput.String(); !strings.Contains(got, "provider warning") || !strings.Contains(got, "native comparison context") || strings.Index(got, "provider warning") > strings.Index(got, "native comparison context") || strings.Contains(got, wantErr.Error()) {
t.Fatalf("provider messages and terminal context = %q", got)
}
}
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
@@ -128,8 +137,8 @@ func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
outcome := app.Execute(context.Background(), []string{"config", "diff", "--quiet"}, commandkit.Streams{Out: failingOutput{err: errors.New("quiet destination should not be called")}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
}
}
@@ -146,8 +155,8 @@ func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(
}}}))
defer cancel()
var output bytes.Buffer
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
outcome := app.Execute(ctx, []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || output.Len() != 0 {
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
}
}
@@ -170,13 +179,36 @@ func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("delivery failure outcome = %#v", outcome)
}
})
}
}
func TestConfigDiffDiagnosticDestinationFailureIsPresentationEvidence(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}},
}, nil
},
}}}))
writeErr := errors.New("diagnostic destination failed")
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: failingOutput{err: writeErr}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("diagnostic delivery failure outcome = %#v", outcome)
}
if output.Len() == 0 {
t.Fatal("healthy result destination did not receive the diff report")
}
}
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
type input struct {
ID int

View File

@@ -2,7 +2,6 @@ package ckconfig
import (
"bytes"
"errors"
"fmt"
"io"
@@ -54,70 +53,59 @@ type secretReferenceDTO struct {
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
selected := plan.scopes[0]
name := selected.scope.scope.Name
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, nil, false)
return configFailure("Configuration explanation was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), commandkit.Explanation{}, cause)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Explain,
Selection: plan.selection,
}, selected.input, state)
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if acquireErr != nil {
return configOperationFailure(acquireErr, diagnostics, false)
return configFailure(fmt.Sprintf("Unable to explain configuration scope %q.", name), acquired.Explanation, acquireErr)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
if acquired.Value.Explanation == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name)
return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause)
}
explanation, found := acquired.Value.Explanation.Explain(plan.path)
if !found {
return configOperationFailure(fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name), diagnostics, false)
cause := fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name)
return configFailure("The requested configuration path is not available.", acquired.Explanation, cause)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration explanation was canceled.", acquired.Explanation, err)
}
if plan.format != "yaml" {
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, plan.path, diagnostics)
}
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
}
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
return configDeliveryFailure(err, diagnostics, true)
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
}
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
checked := &checkedWriter{writer: writer}
renderErr := reader.WriteExplanation(checked, path)
if checked.failure != nil {
if renderErr == nil {
renderErr = checked.failure
} else {
renderErr = errors.Join(renderErr, checked.failure)
err := reporter.RenderOutput(func(writer io.Writer) error {
return acquired.Value.Explanation.WriteExplanation(writer, plan.path)
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
}
return commandkit.Success()
}
if renderErr != nil {
outcome := configOperationFailure(renderErr, diagnostics, true)
outcome.DeliveryError = checked.failure
return outcome
err := reporter.RenderOutput(func(writer io.Writer) error {
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return err
}
_, err := writer.Write(report.Bytes())
return err
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err())
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return commandkit.Success()
}
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {

View File

@@ -1,6 +1,7 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
@@ -10,7 +11,6 @@ import (
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
type scopeMetadata struct {
@@ -265,19 +265,18 @@ func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.
)},
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
var output []byte
var err error
if input.Format == "yaml" {
output, err = yaml.Marshal(prepared.inventory)
} else {
output = []byte(formatScopeInventory(prepared.inventory))
}
if err != nil {
return commandkit.Failure(err)
}
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
return incompleteConfigReport(err)
}
_ = 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()
},
}
@@ -314,21 +313,3 @@ func checkConfigFormat(format string) error {
}
func parseConfigString(value string) (string, error) { return value, nil }
func writeConfigReport(writer io.Writer, content []byte) error {
written, err := writer.Write(content)
if err != nil {
return err
}
if written != len(content) {
return io.ErrShortWrite
}
return nil
}
func incompleteConfigReport(err error) commandkit.Outcome {
outcome := commandkit.Failure(err)
outcome.ReportIncomplete = true
outcome.DeliveryError = err
return outcome
}

View File

@@ -472,13 +472,28 @@ func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.
}
}
func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T) {
func TestExplainTracksNativeRenderFailureWithoutReplacingOperationCause(t *testing.T) {
renderErr := errors.New("native explanation renderer failed")
reader := &failingNativeExplanation{err: renderErr}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", "/token"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.RenderError, renderErr) || output.String() != "partial native explanation\n" {
t.Fatalf("native explanation failure outcome/output = %#v / %q", outcome, output.String())
}
}
func TestReportWriteFailureIsRecordedByReporterAndPreservesCancellation(t *testing.T) {
writeErr := errors.New("output failed")
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{values: []configkit.SourceInfo{}}}}, nil
}}}}))
outcome := app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
if !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete {
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("source delivery failure outcome = %#v", outcome)
}
@@ -531,6 +546,19 @@ type nativeExplanation struct {
found bool
}
type failingNativeExplanation struct{ err error }
func (reader *failingNativeExplanation) Explain(configkit.Path) (configkit.Explanation, bool) {
return configkit.Explanation{}, true
}
func (reader *failingNativeExplanation) WriteExplanation(writer io.Writer, _ configkit.Path) error {
if _, err := io.WriteString(writer, "partial native explanation\n"); err != nil {
return err
}
return reader.err
}
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
reader.path = path
return reader.value, reader.found

View File

@@ -70,14 +70,14 @@ func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOpera
}
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
if index > 0 && hasDirective {
if err := writeConfigReport(writer, []byte("...\n")); err != nil {
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 := writeConfigReport(writer, framed); err != nil {
if _, err := writer.Write(framed); err != nil {
return err
}
}

View File

@@ -52,25 +52,28 @@ type versionDTO struct {
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
selected := plan.scopes[0]
name := selected.scope.scope.Name
reporter := invocation.Reporter()
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, nil, false)
return configFailure("Configuration source listing was canceled.", commandkit.Explanation{}, err)
}
if selected.scope.scope.Acquire == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
cause := fmt.Errorf("configuration scope %q has no acquisition callback", name)
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), commandkit.Explanation{}, cause)
}
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
Operation: Sources,
Selection: plan.selection,
}, selected.input, state)
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if acquireErr != nil {
return configOperationFailure(acquireErr, diagnostics, false)
return configFailure(fmt.Sprintf("Unable to list sources for configuration scope %q.", name), acquired.Explanation, acquireErr)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
}
if acquired.Value.Sources == nil {
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false)
cause := fmt.Errorf("configuration scope %q did not provide a source reader", name)
return configFailure("Configuration scope did not provide source information.", acquired.Explanation, cause)
}
sources := acquired.Value.Sources.Sources()
@@ -111,23 +114,25 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp
}
document.Sources = append(document.Sources, item)
}
var report bytes.Buffer
if plan.format == "yaml" {
if err := encodeYAML(&report, document); err != nil {
return configOperationFailure(err, diagnostics, false)
}
} else {
writeSourceReportText(&report, document)
}
if err := invocation.Context.Err(); err != nil {
return configOperationFailure(err, diagnostics, false)
return configFailure("Configuration source listing was canceled.", acquired.Explanation, err)
}
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
return configDeliveryFailure(err, diagnostics, true)
err := reporter.RenderOutput(func(writer io.Writer) error {
var report bytes.Buffer
if plan.format == "yaml" {
if err := encodeYAML(&report, document); err != nil {
return err
}
} else {
writeSourceReportText(&report, document)
}
_, err := writer.Write(report.Bytes())
return err
})
if err != nil && invocation.Context.Err() != nil {
return configFailure("Configuration source listing was canceled.", acquired.Explanation, invocation.Context.Err())
}
outcome := commandkit.Success()
outcome.Diagnostics = diagnostics
return outcome
return commandkit.Success()
}
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {

View File

@@ -39,11 +39,12 @@ func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *confi
return err
}
if plan.format == "yaml" && !writtenRecord {
if err := writeConfigReport(writer, []byte("scopes:\n")); err != nil {
if _, err := io.WriteString(writer, "scopes:\n"); err != nil {
return err
}
}
return writeConfigReport(writer, record)
_, err = writer.Write(record)
return err
})
if err != nil {
if ctxErr := invocation.Context.Err(); ctxErr != nil {