Files
commandkit/integrations/ckconfig/validate.go

157 lines
5.5 KiB
Go
Raw Permalink Normal View History

package ckconfig
import (
2026-11-07 04:52:58 +00:00
"errors"
"fmt"
2026-11-07 04:52:58 +00:00
"slices"
"text/template"
commandkit "go.maximumdirect.net/commandkit"
2026-11-07 04:52:58 +00:00
configkit "go.maximumdirect.net/configkit"
)
type validationReport struct {
Scopes []validationScope `json:"scopes"`
}
type validationScope struct {
2026-11-07 04:52:58 +00:00
Scope string `json:"scope"`
Outcome string `json:"outcome"`
Details *validationDetails `json:"details,omitempty"`
}
type validationDetails struct {
State string `json:"state"`
Findings []validationFinding `json:"findings"`
Sources []validationSource `json:"sources"`
}
type validationFinding struct {
Code string `json:"code"`
Severity string `json:"severity"`
Path string `json:"path"`
Message string `json:"message"`
Primary *locationDTO `json:"primary"`
Related []locationDTO `json:"related"`
}
type validationSource struct {
ID string `json:"id"`
Label string `json:"label"`
Path string `json:"path"`
}
var validationTextTemplate = template.Must(commandkit.ParseTextTemplate("validation", "{{range .Scopes}}{{.Scope}}: {{.Outcome}}\n{{end}}", nil))
2026-11-07 04:52:58 +00:00
var validationDetailsTemplate = template.Must(commandkit.ParseTextTemplate("validation-details", `{{range .Scopes}}{{.Scope}}: {{.Outcome}} ({{.Details.State}})
{{range .Details.Findings}} {{.Severity}} {{.Code}} {{printf "%q" .Path}}: {{.Message}}
{{if .Primary}} {{.Primary.SourceID}}:{{.Primary.Line}}:{{.Primary.Column}}
{{end}}{{range .Related}} {{.SourceID}}:{{.Line}}:{{.Column}}
{{end}}{{end}}{{range .Details.Sources}} source {{printf "%q" .ID}}: {{.Label}} {{.Path}}
{{end}}{{end}}`, nil))
func parseValidationSeverity(raw string) (configkit.Severity, error) {
switch configkit.Severity(raw) {
case configkit.SeverityError, configkit.SeverityWarning:
return configkit.Severity(raw), nil
default:
return "", fmt.Errorf("unknown validation severity %q", raw)
}
}
2026-10-11 01:39:23 +00:00
func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
reporter := invocation.Reporter()
report := validationReport{Scopes: make([]validationScope, 0, len(plan.scopes))}
outcome := commandkit.Success()
2026-10-11 01:39:23 +00:00
for _, selected := range plan.scopes {
if err := invocation.Context.Err(); err != nil {
outcome = configFailure("Configuration validation was canceled.", commandkit.Explanation{}, err)
break
}
name := selected.scope.scope.Name
2026-10-11 01:39:23 +00:00
if selected.scope.scope.Acquire == nil {
2026-11-07 04:52:58 +00:00
entry := validationScope{Scope: name, Outcome: "failed"}
if plan.details {
entry.Details = projectValidationDetails(View{}, plan.severities, plan.codes)
}
report.Scopes = append(report.Scopes, entry)
outcome = commandkit.Failure(fmt.Sprintf("Configuration scope %q has no acquisition callback.", name))
break
2026-10-11 01:39:23 +00:00
}
2026-11-07 04:39:34 +00:00
acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: selected.selection}, selected.input, state)
2026-11-07 04:52:58 +00:00
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if !plan.details && acquired.Value.Findings != nil {
queueConfigDiagnostics(reporter, acquired.Value.Findings.Messages)
}
entry := validationScope{Scope: name, Outcome: "valid"}
if plan.details {
entry.Details = projectValidationDetails(acquired.Value, plan.severities, plan.codes)
}
if canceled := invocation.Context.Err(); canceled != nil {
entry.Outcome = "canceled"
if err != nil && !errors.Is(err, canceled) {
err = errors.Join(err, canceled)
} else if err == nil {
err = canceled
}
2026-11-07 04:52:58 +00:00
} else if err != nil {
entry.Outcome = "failed"
}
2026-11-07 04:52:58 +00:00
report.Scopes = append(report.Scopes, entry)
if err != nil {
message := fmt.Sprintf("Unable to validate configuration scope %q.", name)
if entry.Outcome == "canceled" {
message = "Configuration validation was canceled."
}
outcome = configFailure(message, acquired.Explanation, err)
break
}
}
2026-11-07 04:52:58 +00:00
text := validationTextTemplate
if plan.details {
text = validationDetailsTemplate
}
2026-11-07 04:52:58 +00:00
_ = reporter.Report(commandkit.Report{Value: report, TextTemplate: text})
return outcome
}
2026-11-07 04:52:58 +00:00
func projectValidationDetails(view View, severities []configkit.Severity, codes []string) *validationDetails {
details := &validationDetails{State: "unavailable", Findings: []validationFinding{}, Sources: []validationSource{}}
if view.Findings == nil {
return details
}
if len(view.Findings.Records) == 0 {
details.State = "empty"
return details
}
details.State = "filtered"
references := make(map[string]bool)
for _, record := range view.Findings.Records {
if len(severities) > 0 && !slices.Contains(severities, record.Severity) {
continue
}
if len(codes) > 0 && !slices.Contains(codes, string(record.Code)) {
continue
}
finding := validationFinding{Code: string(record.Code), Severity: string(record.Severity), Path: record.Path.String(), Message: record.Message, Related: locationReports(record.Related)}
if record.Primary != nil {
finding.Primary = &locationDTO{SourceID: record.Primary.SourceID, Line: record.Primary.Line, Column: record.Primary.Column}
references[record.Primary.SourceID] = true
}
for _, location := range record.Related {
references[location.SourceID] = true
}
details.Findings = append(details.Findings, finding)
}
if len(details.Findings) > 0 {
details.State = "present"
}
if view.Sources != nil {
for _, source := range view.Sources.Sources() {
if references[source.ID] {
details.Sources = append(details.Sources, validationSource{ID: source.ID, Label: source.Label, Path: source.Path})
}
}
}
return details
}