Files
relspecgo/pkg/inspector/report.go
T
warkanum 96281c9f03
Release / test (push) Failing after 2m20s
Release / release (push) Skipped
Release / pkg-aur (push) Skipped
Release / pkg-deb (push) Skipped
Release / pkg-rpm (push) Skipped
chore(ci): add govulncheck, staticcheck, go vet, gofumpt gates
* Add lint/format checks to the Gitea release workflow and Makefile
  (targets: vet, fmt, fmt-check, staticcheck, govulncheck, check)
* Switch .golangci.json formatter from gofmt to gofumpt (extra.group-params)
* Bump golang.org/x/text 0.37.0 -> 0.39.0 for GO-2026-5970; re-vendor
* Fix staticcheck S1011 in pkg/diff; drop unused pgsql writer helpers
* Fix gocritic unnamedResult (pkg/diff) and rangeValCopy (pkg/pgsql)
* Apply gofumpt + goimports formatting across the tree
2026-09-03 21:17:03 +02:00

237 lines
5.7 KiB
Go

package inspector
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
)
// ANSI color codes
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorYellow = "\033[33m"
colorGreen = "\033[32m"
colorBold = "\033[1m"
)
// ReportFormatter defines the interface for report formatters
type ReportFormatter interface {
Format(report *InspectorReport) (string, error)
}
// MarkdownFormatter formats reports as markdown
type MarkdownFormatter struct {
UseColors bool
}
// JSONFormatter formats reports as JSON
type JSONFormatter struct{}
// NewMarkdownFormatter creates a markdown formatter with color support detection
func NewMarkdownFormatter(writer io.Writer) *MarkdownFormatter {
return &MarkdownFormatter{
UseColors: isTerminal(writer),
}
}
// NewJSONFormatter creates a JSON formatter
func NewJSONFormatter() *JSONFormatter {
return &JSONFormatter{}
}
// Format generates a markdown report
func (f *MarkdownFormatter) Format(report *InspectorReport) (string, error) {
var sb strings.Builder
// Header
sb.WriteString(f.formatHeader("RelSpec Inspector Report"))
sb.WriteString("\n\n")
// Metadata
sb.WriteString(f.formatBold("Database:") + " " + report.Database + "\n")
sb.WriteString(f.formatBold("Source Format:") + " " + report.SourceFormat + "\n")
sb.WriteString(f.formatBold("Generated:") + " " + report.GeneratedAt.Format(time.RFC3339) + "\n")
sb.WriteString("\n")
// Summary
sb.WriteString(f.formatHeader("Summary"))
sb.WriteString("\n")
fmt.Fprintf(&sb, "- Rules Checked: %d\n", report.Summary.RulesChecked)
// Color-code error and warning counts
if report.Summary.ErrorCount > 0 {
sb.WriteString(f.colorize(fmt.Sprintf("- Errors: %d\n", report.Summary.ErrorCount), colorRed))
} else {
fmt.Fprintf(&sb, "- Errors: %d\n", report.Summary.ErrorCount)
}
if report.Summary.WarningCount > 0 {
sb.WriteString(f.colorize(fmt.Sprintf("- Warnings: %d\n", report.Summary.WarningCount), colorYellow))
} else {
fmt.Fprintf(&sb, "- Warnings: %d\n", report.Summary.WarningCount)
}
if report.Summary.PassedCount > 0 {
sb.WriteString(f.colorize(fmt.Sprintf("- Passed: %d\n", report.Summary.PassedCount), colorGreen))
}
sb.WriteString("\n")
// Group violations by level
errors := []ValidationResult{}
warnings := []ValidationResult{}
for _, v := range report.Violations {
if !v.Passed {
if v.Level == "error" {
errors = append(errors, v)
} else {
warnings = append(warnings, v)
}
}
}
// Report violations
if len(errors) > 0 || len(warnings) > 0 {
sb.WriteString(f.formatHeader("Violations"))
sb.WriteString("\n")
// Errors
if len(errors) > 0 {
sb.WriteString(f.formatSubheader(fmt.Sprintf("Errors (%d)", len(errors)), colorRed))
sb.WriteString("\n")
for _, violation := range errors {
sb.WriteString(f.formatViolation(violation, colorRed))
}
sb.WriteString("\n")
}
// Warnings
if len(warnings) > 0 {
sb.WriteString(f.formatSubheader(fmt.Sprintf("Warnings (%d)", len(warnings)), colorYellow))
sb.WriteString("\n")
for _, violation := range warnings {
sb.WriteString(f.formatViolation(violation, colorYellow))
}
}
} else {
sb.WriteString(f.colorize("✓ No violations found!\n", colorGreen))
}
return sb.String(), nil
}
// Format generates a JSON report
func (f *JSONFormatter) Format(report *InspectorReport) (string, error) {
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal report to JSON: %w", err)
}
return string(data), nil
}
// Helper methods for MarkdownFormatter
func (f *MarkdownFormatter) formatHeader(text string) string {
return f.formatBold("# " + text)
}
func (f *MarkdownFormatter) formatSubheader(text, color string) string {
header := "### " + text
if f.UseColors {
return color + colorBold + header + colorReset
}
return header
}
func (f *MarkdownFormatter) formatBold(text string) string {
if f.UseColors {
return colorBold + text + colorReset
}
return "**" + text + "**"
}
func (f *MarkdownFormatter) colorize(text, color string) string {
if f.UseColors {
return color + text + colorReset
}
return text
}
func (f *MarkdownFormatter) formatViolation(v ValidationResult, color string) string {
var sb strings.Builder
// Rule name as header
if f.UseColors {
sb.WriteString(color + "#### " + v.RuleName + colorReset + "\n")
} else {
sb.WriteString("#### " + v.RuleName + "\n")
}
// Location and message
sb.WriteString(f.formatBold("Location:") + " " + v.Location + "\n")
sb.WriteString(f.formatBold("Message:") + " " + v.Message + "\n")
// Context details (optional, only show interesting ones)
if len(v.Context) > 0 {
contextStr := f.formatContext(v.Context)
if contextStr != "" {
sb.WriteString(f.formatBold("Details:") + " " + contextStr + "\n")
}
}
sb.WriteString("\n")
return sb.String()
}
func (f *MarkdownFormatter) formatContext(context map[string]interface{}) string {
// Extract relevant context information
var parts []string
// Skip schema, table, column as they're in location
skipKeys := map[string]bool{
"schema": true,
"table": true,
"column": true,
}
keys := make([]string, 0, len(context))
for key := range context {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if skipKeys[key] {
continue
}
parts = append(parts, fmt.Sprintf("%s=%v", key, context[key]))
}
return strings.Join(parts, ", ")
}
// isTerminal checks if the writer is a terminal (supports ANSI colors)
func isTerminal(w io.Writer) bool {
file, ok := w.(*os.File)
if !ok {
return false
}
// Check if the file descriptor is a terminal
stat, err := file.Stat()
if err != nil {
return false
}
// Check if it's a character device (terminal)
// This works on Unix-like systems
return (stat.Mode() & os.ModeCharDevice) != 0
}