Separate ConfigKit messages from explanations

This commit is contained in:
2026-10-26 03:10:18 +00:00
parent f3e9326544
commit 9a4d1db955
5 changed files with 231 additions and 78 deletions

View File

@@ -1,6 +1,6 @@
# Invocation reporting implementation plan # Invocation reporting implementation plan
Status: Stages 1–9 complete. Stage 10 is next. Status: Stages 1–10 complete. Stage 11 is next.
## Objective and authority ## Objective and authority
@@ -380,7 +380,7 @@ root tests remains deferred until the legacy external callers are reconciled.
## Stage 10: Separate ConfigKit provider messages and explanations ## Stage 10: Separate ConfigKit provider messages and explanations
Status: pending. Status: complete.
Primary files: Primary files:
[configkit_adapter.go](../../integrations/ckconfig/configkit_adapter.go), [configkit_adapter.go](../../integrations/ckconfig/configkit_adapter.go),
@@ -408,6 +408,14 @@ context, passive acquisition, retained causes, and data alongside errors.
Use native results as fixtures without testing ConfigKit's resolution or Use native results as fixtures without testing ConfigKit's resolution or
diagnostic classification algorithms. diagnostic classification algorithms.
The ConfigKit adapter now carries independent warnings and terminal native
records separately, preserves partial values and wrapped errors, and snapshots
native writer inputs. Focused tests cover mixed records, source and comparison
side context, related locations, partial values, error identity, and messages
retained through cancellation. The ckconfig suite cannot run yet because
`diff.go`, `explain.go`, and `family.go` still use APIs scheduled for later
migration; Stage 20 will validate the assembled modules.
## Stage 11: Migrate configuration show and validate ## Stage 11: Migrate configuration show and validate
Status: pending. Status: pending.

View File

@@ -192,14 +192,14 @@ func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
if !errors.As(gotErr, &typed) || typed != loadErr { if !errors.As(gotErr, &typed) || typed != loadErr {
t.Fatalf("load error type not preserved: %#v", typed) t.Fatalf("load error type not preserved: %#v", typed)
} }
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError { if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics) t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
} }
var leftText, rightText bytes.Buffer var leftText, rightText bytes.Buffer
if err := acquired.Diagnostics[0].Write(&leftText); err != nil { if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := acquired.Diagnostics[1].Write(&rightText); err != nil { if err := acquired.Explanation.Details[0](&rightText); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) { if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
@@ -231,8 +231,15 @@ func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
t.Fatalf("left failure/right load = %v / %v", err, rightCalled) t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
} }
var typed *configkit.LoadError var typed *configkit.LoadError
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError { if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics) t.Fatalf("left failure identity/explanation = %v / %#v", err, acquired.Explanation)
}
var terminal bytes.Buffer
if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if !strings.Contains(terminal.String(), `Configuration scope "app" left:`) || !strings.Contains(terminal.String(), `"left-source"`) {
t.Fatalf("left terminal context lost side/source identity: %q", terminal.String())
} }
} }

View File

@@ -33,53 +33,58 @@ func FromConfigKit[I, S, T any](
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) { scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
result, err := load(ctx, request.Selection, input, state) result, err := load(ctx, request.Selection, input, state)
acquired := commandkit.Acquired[View]{}
if result != nil {
acquired.Value = View{Effective: result, Sources: result, Explanation: result}
acquired.Diagnostics, acquired.Explanation = configKitDiagnostics(name, result.Sources(), result.Diagnostics())
}
if err != nil { if err != nil {
var loadError *configkit.LoadError var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil { if errors.As(err, &loadError) && loadError != nil {
return commandkit.Acquired[View]{Diagnostics: configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err warnings, explanation := configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())
acquired.Diagnostics = append(acquired.Diagnostics, warnings...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, explanation)
} }
return commandkit.Acquired[View]{}, err return acquired, err
} }
if result == nil { if result == nil {
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name) return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
} }
return acquired, nil
sources := result.Sources()
return commandkit.Acquired[View]{
Value: View{Effective: result, Sources: result, Explanation: result},
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
}, nil
} }
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) { scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err) return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
} }
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state) acquired := commandkit.Acquired[[]configkit.Difference]{}
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...) left, leftDiagnostics, leftExplanation, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
acquired.Diagnostics = append(acquired.Diagnostics, leftDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, leftExplanation)
if err != nil { if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err return acquired, err
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err) return acquired, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
} }
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state) right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
diagnostics = append(diagnostics, rightDiagnostics...) acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
if err != nil { if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err return acquired, err
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err) return acquired, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
} }
differences, err := configkit.Diff(left, right) acquired.Value, err = configkit.Diff(left, right)
if err != nil { if err != nil {
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err) return acquired, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
} }
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil return acquired, nil
} }
return scope return scope
} }
@@ -90,67 +95,110 @@ func loadComparisonSide[I, S, T any](
load func(context.Context, Selection, I, S) (*configkit.Result[T], error), load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
request ComparisonSide[I], request ComparisonSide[I],
state S, state S,
) (*configkit.Result[T], []commandkit.Diagnostic, error) { ) (*configkit.Result[T], []commandkit.Diagnostic, commandkit.Explanation, error) {
result, err := load(ctx, request.Selection, request.Input, state) result, err := load(ctx, request.Selection, request.Input, state)
var diagnostics []commandkit.Diagnostic
var explanation commandkit.Explanation
if result != nil {
warnings, terminal := configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
}
if err != nil { if err != nil {
var loadError *configkit.LoadError var loadError *configkit.LoadError
var diagnostics []commandkit.Diagnostic
if errors.As(err, &loadError) && loadError != nil { if errors.As(err, &loadError) && loadError != nil {
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics()) warnings, terminal := configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
} }
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err) return result, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
} }
if result == nil { if result == nil {
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side) return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
} }
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil return result, diagnostics, explanation, nil
} }
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic { func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records) return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
} }
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic { func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records) return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
} }
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic { func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
if len(records) == 0 { if len(records) == 0 {
return nil return nil, commandkit.Explanation{}
} }
severity := commandkit.SeverityWarning sources = append([]configkit.SourceInfo(nil), sources...)
records = cloneConfigKitDiagnostics(records)
warnings := make([]configkit.Diagnostic, 0, len(records))
terminal := make([]configkit.Diagnostic, 0, len(records))
for _, record := range records { for _, record := range records {
if record.Severity == configkit.SeverityError { if record.Severity == configkit.SeverityError {
severity = commandkit.SeverityError terminal = append(terminal, record)
break } else {
warnings = append(warnings, record)
} }
} }
// Capture the upstream snapshots so later calls to Write do not depend on if len(warnings) == 0 {
// slices returned by the provider. return nil, explanationWithConfigKitRecords(heading, sources, terminal)
sources = append([]configkit.SourceInfo(nil), sources...) }
records = append([]configkit.Diagnostic(nil), records...) warning := commandkit.Diagnostic{
return []commandkit.Diagnostic{{ Severity: commandkit.SeverityWarning,
Severity: severity, Write: configKitDiagnosticWriter(heading, sources, warnings),
Write: func(writer io.Writer) error { }
if err := writeDiagnosticText(writer, heading); err != nil { return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
}
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
if len(records) == 0 {
return commandkit.Explanation{}
}
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
}
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
return func(writer io.Writer) error {
if err := writeDiagnosticText(writer, heading); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
return err return err
} }
if len(sources) > 0 { for _, source := range sources {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil { line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err return err
} }
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
}
} }
return configkit.WriteDiagnostics(writer, records) }
}, return configkit.WriteDiagnostics(writer, records)
}} }
}
func cloneConfigKitDiagnostics(records []configkit.Diagnostic) []configkit.Diagnostic {
cloned := append([]configkit.Diagnostic(nil), records...)
for index := range cloned {
if records[index].Primary != nil {
primary := *records[index].Primary
cloned[index].Primary = &primary
}
cloned[index].Related = append([]configkit.Location(nil), records[index].Related...)
}
return cloned
}
func appendConfigKitExplanation(left, right commandkit.Explanation) commandkit.Explanation {
if left.Message == "" {
left.Message = right.Message
}
left.Details = append(left.Details, right.Details...)
return left
} }
func writeDiagnosticText(writer io.Writer, value string) error { func writeDiagnosticText(writer io.Writer, value string) error {

View File

@@ -98,12 +98,13 @@ type Scope[I, S any] struct {
// Operations selects the standard handlers exposed for this scope. // Operations selects the standard handlers exposed for this scope.
Operations []Operation Operations []Operation
// Acquire loads the selected configuration for one ordinary inspection // Acquire loads the selected configuration for one ordinary inspection
// invocation. // invocation. It returns independent diagnostics and terminal context
// separately from the native value and optional operation error.
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error) Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
// Compare compares two selections for this scope using one shared state. // Compare compares two selections for this scope using one shared state.
// Its Acquired result carries native difference records and diagnostics; // Its Acquired result carries native difference records, independent
// returned errors retain their supported cause identity. It is required when // diagnostics, and terminal context; returned errors retain their supported
// Diff is declared in Operations. // cause identity. It is required when Diff is declared in Operations.
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error) Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
issues []error issues []error

View File

@@ -126,14 +126,20 @@ func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
} }
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) { func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
type settings struct { path := configkit.Keys("schema_version")
Name string `yaml:"name"` _, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
} Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}}) Path: path,
Check: func(_ configkit.Version, validation *configkit.Validation) error {
validation.Warning(path, "review schema compatibility")
return errors.New("private checker error")
},
}},
})
if loadErr == nil { if loadErr == nil {
t.Fatal("fixture did not produce a ConfigKit load error") t.Fatal("fixture did not produce a ConfigKit load error")
} }
scope := FromConfigKit[struct{}, struct{}, settings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[settings], error) { scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
return nil, loadErr return nil, loadErr
}) })
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{}) acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
@@ -144,38 +150,121 @@ func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
if !errors.As(gotErr, &typed) { if !errors.As(gotErr, &typed) {
t.Fatalf("load error type was not retained: %T", gotErr) t.Fatalf("load error type was not retained: %T", gotErr)
} }
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError { if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics) t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
} }
var rendered bytes.Buffer if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
if err := acquired.Diagnostics[0].Write(&rendered); err != nil { t.Fatalf("failure explanation = %#v", acquired.Explanation)
}
var warning, terminal bytes.Buffer
if err := acquired.Diagnostics[0].Write(&warning); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) { if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String()) t.Fatal(err)
}
if !strings.Contains(warning.String(), `Configuration scope "app":`) || !strings.Contains(warning.String(), `review schema compatibility`) {
t.Fatalf("warning output lost its scope or record: %q", warning.String())
}
if !strings.Contains(terminal.String(), `Configuration scope "app":`) || !strings.Contains(terminal.String(), `"bad source"`) || strings.Contains(terminal.String(), "review schema compatibility") {
t.Fatalf("terminal explanation lost native context or included an independent warning: %q", terminal.String())
}
if strings.Contains(terminal.String(), "private checker error") {
t.Fatalf("terminal explanation exposed the upstream cause: %q", terminal.String())
}
}
func TestFromConfigKitPreservesValueAndDiagnosticsAlongsideError(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
loadErr := errors.New("loader returned a value with an error")
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return result, loadErr
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr {
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
}
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
t.Fatalf("acquired value facets were lost: %#v", acquired.Value)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("independent diagnostics = %#v", acquired.Diagnostics)
}
if len(acquired.Explanation.Details) != 0 {
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
} }
} }
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) { func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
warning := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}}) warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning { if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
t.Fatalf("warning diagnostics = %#v", warning) t.Fatalf("warning diagnostics = %#v", warning)
} }
withError := configKitDiagnostics("app", nil, []configkit.Diagnostic{ if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
t.Fatalf("warning-only explanation = %#v", noExplanation)
}
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
{Severity: configkit.SeverityWarning, Message: "safe warning"}, {Severity: configkit.SeverityWarning, Message: "safe warning"},
{Severity: configkit.SeverityError, Message: "safe error"}, {Severity: configkit.SeverityError, Message: "safe error"},
}) })
if withError[0].Severity != commandkit.SeverityError { if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity) t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
} }
if err := withError[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) { if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite) t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
} }
if err := withExplanation.Details[0](shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("terminal short writer error = %v, want %v", err, io.ErrShortWrite)
}
wantErr := errors.New("writer failed") wantErr := errors.New("writer failed")
if err := withError[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) { if err := withWarning[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("writer error = %v, want %v", err, wantErr) t.Fatalf("writer error = %v, want %v", err, wantErr)
} }
if err := withExplanation.Details[0](failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("terminal writer error = %v, want %v", err, wantErr)
}
}
func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.T) {
primary := &configkit.Location{SourceID: "primary-source", Line: 4, Column: 2}
related := []configkit.Location{{SourceID: "related-source", Line: 2, Column: 1}}
sources := []configkit.SourceInfo{
{ID: "primary-source", Label: "primary", Path: "primary.yml"},
{ID: "related-source", Label: "related", Path: "related.yml"},
}
records := []configkit.Diagnostic{{
Severity: configkit.SeverityError,
Path: configkit.Keys("name"),
Message: "native terminal context",
Primary: primary,
Related: related,
}}
_, explanation := configKitDiagnostics("app", sources, records)
primary.SourceID = "mutated-primary"
related[0].SourceID = "mutated-related"
sources[0].Path = "mutated.yml"
if len(explanation.Details) != 1 {
t.Fatalf("explanation = %#v", explanation)
}
var output bytes.Buffer
if err := explanation.Details[0](&output); err != nil {
t.Fatal(err)
}
for _, fragment := range []string{"primary.yml", "primary-source", "related-source", "native terminal context"} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("terminal context %q omits %q", output.String(), fragment)
}
}
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
}
} }
type shortWriter struct{} type shortWriter struct{}