package inspector import ( "encoding/json" "fmt" "io" "os" "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") sb.WriteString(fmt.Sprintf("- 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 { sb.WriteString(fmt.Sprintf("- 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 { sb.WriteString(fmt.Sprintf("- 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 string, 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 string, 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, } for key, value := range context { if skipKeys[key] { continue } parts = append(parts, fmt.Sprintf("%s=%v", key, value)) } 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 }