package ckconfig import ( "errors" "fmt" "slices" "text/template" commandkit "go.maximumdirect.net/commandkit" configkit "go.maximumdirect.net/configkit" ) type validationReport struct { Scopes []validationScope `json:"scopes"` } type validationScope struct { 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)) 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) } } 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() 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 if selected.scope.scope.Acquire == nil { 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 } acquired, err := selected.scope.scope.Acquire(invocation.Context, Request{Operation: Validate, Selection: selected.selection}, selected.input, state) 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 } } else if err != nil { entry.Outcome = "failed" } 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 } } text := validationTextTemplate if plan.details { text = validationDetailsTemplate } _ = reporter.Report(commandkit.Report{Value: report, TextTemplate: text}) return outcome } 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 }