diff --git a/.gitignore b/.gitignore index c4e16f9..5341bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage.out unit-test-report.json go-covercheck !**/go-covercheck/ +*.png diff --git a/cmd/go-covercheck/root.go b/cmd/go-covercheck/root.go index b0413d8..b2bdb9a 100644 --- a/cmd/go-covercheck/root.go +++ b/cmd/go-covercheck/root.go @@ -101,6 +101,10 @@ const ( InitFlag = "init" InitFlagUsage = "create a sample .go-covercheck.yml config file in the current directory" + HeatmapOutputFlag = "heatmap-output" + HeatmapOutputFlagShort = "o" + HeatmapOutputFlagUsage = "output file path for heatmap formats (default: coverage-heatmap.png or stdout for ASCII)" + // ConfigFilePermissions permissions. ConfigFilePermissions = 0600 ) @@ -145,7 +149,7 @@ var ( config.SortOrderDesc, ) - FormatFlagUsage = fmt.Sprintf("output format [%s|%s|%s|%s|%s|%s|%s]", + FormatFlagUsage = fmt.Sprintf("output format [%s|%s|%s|%s|%s|%s|%s|%s|%s]", config.FormatTable, config.FormatJSON, config.FormatYAML, @@ -153,6 +157,8 @@ var ( config.FormatHTML, config.FormatCSV, config.FormatTSV, + config.FormatHeatmapASCII, + config.FormatHeatmapPNG, ) SkipFlagDefault []string @@ -473,6 +479,10 @@ func applyConfigOverrides(cfg *config.Config, cmd *cobra.Command, noConfigFile b noConfigFile { cfg.ModuleName = v } + if v, _ := cmd.Flags().GetString(HeatmapOutputFlag); cmd.Flags().Changed(HeatmapOutputFlag) || + noConfigFile { + cfg.HeatmapOutput = v + } // set cfg.Total thresholds to the global values, iff no override was specified for each. if v, _ := cmd.Flags().GetFloat64(StatementThresholdFlag); !cmd.Flags().Changed(TotalStatementThresholdFlag) && @@ -651,6 +661,13 @@ func initFlags(cmd *cobra.Command) { false, InitFlagUsage, ) + + cmd.Flags().StringP( + HeatmapOutputFlag, + HeatmapOutputFlagShort, + "", + HeatmapOutputFlagUsage, + ) } func shouldSkip(filename string, skip []string) bool { diff --git a/go.mod b/go.mod index c086987..b60cbce 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/mattn/go-colorable v0.1.14 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 + golang.org/x/image v0.29.0 golang.org/x/term v0.33.0 golang.org/x/tools v0.35.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 73bdd2a..5eb155b 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA= golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= +golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= +golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/config/config.go b/pkg/config/config.go index b5e5c39..c6833b0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -31,14 +31,16 @@ const ( SortOrderDesc = "desc" SortOrderDefault = SortOrderAsc - FormatJSON = "json" - FormatYAML = "yaml" - FormatTable = "table" - FormatCSV = "csv" - FormatHTML = "html" - FormatTSV = "tsv" - FormatMD = "md" - FormatDefault = FormatTable + FormatJSON = "json" + FormatYAML = "yaml" + FormatTable = "table" + FormatCSV = "csv" + FormatHTML = "html" + FormatTSV = "tsv" + FormatMD = "md" + FormatHeatmapASCII = "heatmap-ascii" + FormatHeatmapPNG = "heatmap-png" + FormatDefault = FormatTable thresholdOff = 0 thresholdMax = 100 @@ -80,6 +82,7 @@ type Config struct { Format string `yaml:"format,omitempty"` TerminalWidth int `yaml:"terminalWidth,omitempty"` ModuleName string `yaml:"moduleName,omitempty"` + HeatmapOutput string `yaml:"heatmapOutput,omitempty"` } // Load a Config from a path or produce an error. @@ -141,11 +144,11 @@ func (c *Config) Validate() error { //nolint:cyclop } switch c.Format { - case FormatJSON, FormatYAML, FormatTable, FormatMD, FormatCSV, FormatHTML, FormatTSV: + case FormatJSON, FormatYAML, FormatTable, FormatMD, FormatCSV, FormatHTML, FormatTSV, FormatHeatmapASCII, FormatHeatmapPNG: break default: - return fmt.Errorf("format must be one of %s|%s|%s|%s|%s|%s|%s", - FormatJSON, FormatYAML, FormatTable, FormatCSV, FormatHTML, FormatTSV, FormatMD) + return fmt.Errorf("format must be one of %s|%s|%s|%s|%s|%s|%s|%s|%s", + FormatJSON, FormatYAML, FormatTable, FormatCSV, FormatHTML, FormatTSV, FormatMD, FormatHeatmapASCII, FormatHeatmapPNG) } if c.NoSummary && c.NoTable && c.Format != FormatJSON && c.Format != FormatYAML { diff --git a/pkg/heatmap/ascii.go b/pkg/heatmap/ascii.go new file mode 100644 index 0000000..6d8e5f3 --- /dev/null +++ b/pkg/heatmap/ascii.go @@ -0,0 +1,329 @@ +package heatmap + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/fatih/color" + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +// ASCIIHeatmap represents an ASCII art coverage heat map. +type ASCIIHeatmap struct { + writer io.Writer + config *config.Config +} + +// NewASCIIHeatmap creates a new ASCII heat map generator. +func NewASCIIHeatmap(writer io.Writer, cfg *config.Config) *ASCIIHeatmap { + return &ASCIIHeatmap{ + writer: writer, + config: cfg, + } +} + +// Generate creates an ASCII art grid heat map of the coverage results. +func (h *ASCIIHeatmap) Generate(results compute.Results) error { + // Generate legend at the top + h.writeLegend() + + // Generate header + h.writeHeader() + + // Generate file-level grid heat map + if err := h.generateFileGridHeatmap(results.ByFile); err != nil { + return err + } + + // Generate package-level grid heat map + if err := h.generatePackageGridHeatmap(results.ByPackage); err != nil { + return err + } + + // Generate overall coverage summary + h.generateSummary(results.ByTotal) + + return nil +} + +func (h *ASCIIHeatmap) writeLegend() { + fmt.Fprintln(h.writer, "") + fmt.Fprintln(h.writer, "Coverage Legend:") + if !h.config.NoColor { + fmt.Fprintf(h.writer, " %s Critical 0-29%% (High Priority)\n", color.New(color.FgHiRed, color.Bold).Sprint("██████")) + fmt.Fprintf(h.writer, " %s Poor 30-49%% (Medium Priority)\n", color.RedString("██████")) + fmt.Fprintf(h.writer, " %s Fair 50-69%% (Low Priority)\n", color.New(color.FgHiYellow).Sprint("██████")) + fmt.Fprintf(h.writer, " %s Good 70-89%% (Maintain)\n", color.YellowString("██████")) + fmt.Fprintf(h.writer, " %s Excellent 90-100%% (Well Covered)\n", color.GreenString("██████")) + } else { + fmt.Fprintln(h.writer, " ██████ Critical 0-29% (High Priority)") + fmt.Fprintln(h.writer, " ▓▓▓▓▓▓ Poor 30-49% (Medium Priority)") + fmt.Fprintln(h.writer, " ░░░░░░ Fair 50-69% (Low Priority)") + fmt.Fprintln(h.writer, " ▒▒▒▒▒▒ Good 70-89% (Maintain)") + fmt.Fprintln(h.writer, " ▓▓▓▓▓▓ Excellent 90-100% (Well Covered)") + } + fmt.Fprintln(h.writer, "") +} + +func (h *ASCIIHeatmap) writeHeader() { + fmt.Fprintln(h.writer, "═══════════════════════════════════════════") + fmt.Fprintln(h.writer, " COVERAGE GRID HEAT MAP") + fmt.Fprintln(h.writer, "═══════════════════════════════════════════") + fmt.Fprintln(h.writer, "") +} + +func (h *ASCIIHeatmap) generateFileGridHeatmap(files []compute.ByFile) error { + if len(files) == 0 { + return nil + } + + fmt.Fprintln(h.writer, "By Files:") + fmt.Fprintln(h.writer, strings.Repeat("─", 80)) + + // Sort files by coverage percentage (ascending to highlight worst first) + sortedFiles := make([]compute.ByFile, len(files)) + copy(sortedFiles, files) + sort.Slice(sortedFiles, func(i, j int) bool { + return sortedFiles[i].StatementPercentage < sortedFiles[j].StatementPercentage + }) + + cols := h.calculateGridCols(len(sortedFiles)) + rows := (len(sortedFiles) + cols - 1) / cols + + for row := range make([]int, rows) { + for cellLine := range make([]int, 3) { + for col := range make([]int, cols) { + idx := row*cols + col + if idx >= len(sortedFiles) { + break + } + file := sortedFiles[idx] + cellLines := strings.Split(h.generateCoverageCell7x3(file.StatementPercentage), "\n") + fmt.Fprint(h.writer, cellLines[cellLine]) + } + // Add file names to the right of the grid on the middle line only + if cellLine == 1 { + fmt.Fprint(h.writer, " ") + for col := range make([]int, cols) { + idx := row*cols + col + if idx >= len(sortedFiles) { + break + } + file := sortedFiles[idx] + abbreviated := h.truncateFilename(file.File) + if col > 0 { + fmt.Fprint(h.writer, ", ") + } + fmt.Fprint(h.writer, abbreviated) + } + } + fmt.Fprintln(h.writer) + } + } + fmt.Fprintln(h.writer) + return nil +} + +func (h *ASCIIHeatmap) generatePackageGridHeatmap(packages []compute.ByPackage) error { + if len(packages) == 0 { + return nil + } + + fmt.Fprintln(h.writer, "By Packages:") + fmt.Fprintln(h.writer, strings.Repeat("─", 80)) + + sortedPackages := make([]compute.ByPackage, len(packages)) + copy(sortedPackages, packages) + sort.Slice(sortedPackages, func(i, j int) bool { + return sortedPackages[i].StatementPercentage < sortedPackages[j].StatementPercentage + }) + + cols := h.calculateGridCols(len(sortedPackages)) + rows := (len(sortedPackages) + cols - 1) / cols + + for row := range make([]int, rows) { + for cellLine := range make([]int, 3) { + for col := range make([]int, cols) { + idx := row*cols + col + if idx >= len(sortedPackages) { + break + } + pkg := sortedPackages[idx] + cellLines := strings.Split(h.generateCoverageCell7x3(pkg.StatementPercentage), "\n") + fmt.Fprint(h.writer, cellLines[cellLine]) + } + if cellLine == 1 { + fmt.Fprint(h.writer, " ") + for col := range make([]int, cols) { + idx := row*cols + col + if idx >= len(sortedPackages) { + break + } + pkg := sortedPackages[idx] + abbreviated := h.truncatePackageName(pkg.Package) + if col > 0 { + fmt.Fprint(h.writer, ", ") + } + fmt.Fprint(h.writer, abbreviated) + } + } + fmt.Fprintln(h.writer) + } + } + fmt.Fprintln(h.writer) + return nil +} + +func (h *ASCIIHeatmap) calculateGridCols(itemCount int) int { + if itemCount <= 0 { + return 1 + } + + // With 6-character wide cells, we need to be more conservative with columns + // to fit in terminal width (80 chars typically) + maxCols := 10 // Each cell is 6 chars + 1 space = 7 chars per cell, so ~10 fits in 80 chars + if itemCount <= 2 { + return itemCount + } + if itemCount <= 8 { + return 4 + } + if itemCount <= 20 { + return 6 + } + if itemCount <= 40 { + return 8 + } + return maxCols +} + +func (h *ASCIIHeatmap) generateCoverageCell7x3(percentage float64) string { + // Use 7x3 block characters for grid cells with percentage in the middle + percentStr := fmt.Sprintf("%3.0f", percentage) // Format as 3-character string without % + + // Center the percentage in the middle line (3 chars, 2 blocks on each side) + line1 := " " + line2 := fmt.Sprintf(" %s ", percentStr) + line3 := " " + cellPattern := line1 + "\n" + line2 + "\n" + line3 + + if h.config.NoColor { + // Use different ASCII characters when color is disabled + switch { + case percentage >= 90: + return strings.ReplaceAll(cellPattern, " ", "▓") // Well covered + case percentage >= 70: + return strings.ReplaceAll(cellPattern, " ", "▒") // Good + case percentage >= 50: + return strings.ReplaceAll(cellPattern, " ", "░") // Fair + case percentage >= 30: + return strings.ReplaceAll(cellPattern, " ", "▓") // Poor + default: + return strings.ReplaceAll(cellPattern, " ", "█") // Critical (needs attention) + } + } + + // Color the cell based on coverage level with consistent background colors + // Color all three lines the same way for consistency + var coloredLines [3]string + switch { + case percentage >= 90: + coloredLines[0] = color.New(color.BgGreen, color.FgWhite).Sprint(line1) + coloredLines[1] = color.New(color.BgGreen, color.FgWhite).Sprint(line2) + coloredLines[2] = color.New(color.BgGreen, color.FgWhite).Sprint(line3) + case percentage >= 70: + coloredLines[0] = color.New(color.BgYellow, color.FgBlack).Sprint(line1) + coloredLines[1] = color.New(color.BgYellow, color.FgBlack).Sprint(line2) + coloredLines[2] = color.New(color.BgYellow, color.FgBlack).Sprint(line3) + case percentage >= 50: + coloredLines[0] = color.New(color.BgHiYellow, color.FgBlack).Sprint(line1) + coloredLines[1] = color.New(color.BgHiYellow, color.FgBlack).Sprint(line2) + coloredLines[2] = color.New(color.BgHiYellow, color.FgBlack).Sprint(line3) + case percentage >= 30: + coloredLines[0] = color.New(color.BgRed, color.FgWhite).Sprint(line1) + coloredLines[1] = color.New(color.BgRed, color.FgWhite).Sprint(line2) + coloredLines[2] = color.New(color.BgRed, color.FgWhite).Sprint(line3) + default: + coloredLines[0] = color.New(color.BgHiRed, color.FgWhite, color.Bold).Sprint(line1) + coloredLines[1] = color.New(color.BgHiRed, color.FgWhite, color.Bold).Sprint(line2) + coloredLines[2] = color.New(color.BgHiRed, color.FgWhite, color.Bold).Sprint(line3) + } + return strings.Join(coloredLines[:], "\n") +} + +func (h *ASCIIHeatmap) truncateFilename(filename string) string { + // Truncate file names to 6 characters + if len(filename) <= 6 { + return filename + } + return filename[:6] +} + +func (h *ASCIIHeatmap) truncatePackageName(packageName string) string { + // Prefix truncate to show last 6 characters for package names + if len(packageName) <= 6 { + return packageName + } + return packageName[len(packageName)-6:] +} + +func (h *ASCIIHeatmap) abbreviateText(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + if maxLen <= 3 { + return text[:maxLen] + } + return text[:maxLen-2] + ".." +} + +func (h *ASCIIHeatmap) generateSummary(totals compute.Totals) { + fmt.Fprintln(h.writer, "By Total:") + fmt.Fprintln(h.writer, strings.Repeat("─", 80)) + + // Create simpler single line cells for summary + stmtCell := h.generateSimpleCoverageCell(totals.Statements.Percentage) + blockCell := h.generateSimpleCoverageCell(totals.Blocks.Percentage) + + fmt.Fprintf(h.writer, "%s Statement Coverage %s (%.1f%%)\n", stmtCell, totals.Statements.Coverage, totals.Statements.Percentage) + fmt.Fprintf(h.writer, "%s Block Coverage %s (%.1f%%)\n", blockCell, totals.Blocks.Coverage, totals.Blocks.Percentage) + fmt.Fprintln(h.writer, "") +} + +func (h *ASCIIHeatmap) generateSimpleCoverageCell(percentage float64) string { + // Use simple block characters for summary + cell := "██ " + + if h.config.NoColor { + // Use different ASCII characters when color is disabled + switch { + case percentage >= 90: + return "▓▓ " // Well covered + case percentage >= 70: + return "▒▒ " // Good + case percentage >= 50: + return "░░ " // Fair + case percentage >= 30: + return "▓▓ " // Poor + default: + return "██ " // Critical (needs attention) + } + } + + // Color the cell based on coverage level - prioritize highlighting opportunities + switch { + case percentage >= 90: + return color.GreenString(cell) // Well covered - green + case percentage >= 70: + return color.YellowString(cell) // Good - yellow + case percentage >= 50: + return color.New(color.FgHiYellow).Sprint(cell) // Fair - bright yellow + case percentage >= 30: + return color.RedString(cell) // Poor - red (opportunity) + default: + return color.New(color.FgHiRed, color.Bold).Sprint(cell) // Critical - bright red (high opportunity) + } +} diff --git a/pkg/heatmap/doc.go b/pkg/heatmap/doc.go new file mode 100644 index 0000000..7254d48 --- /dev/null +++ b/pkg/heatmap/doc.go @@ -0,0 +1,4 @@ +// Package heatmap implements coverage heat map generation functionality. +// It supports generating ASCII art and PNG format heat maps to visualize +// test coverage data across files and packages. +package heatmap \ No newline at end of file diff --git a/pkg/heatmap/heatmap.go b/pkg/heatmap/heatmap.go new file mode 100644 index 0000000..8f5fbed --- /dev/null +++ b/pkg/heatmap/heatmap.go @@ -0,0 +1,46 @@ +package heatmap + +import ( + "fmt" + "io" + "os" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" +) + +// Generator represents a heat map generator. +type Generator interface { + Generate(results compute.Results) error +} + +// NewGenerator creates a new heat map generator based on the format. +func NewGenerator(format string, cfg *config.Config) (Generator, io.WriteCloser, error) { + switch format { + case config.FormatHeatmapASCII: + return NewASCIIHeatmap(os.Stdout, cfg), nopWriteCloser{os.Stdout}, nil + case config.FormatHeatmapPNG: + output := cfg.HeatmapOutput + if output == "" { + output = "coverage-heatmap.png" + } + + file, err := os.Create(output) + if err != nil { + return nil, nil, fmt.Errorf("failed to create PNG file %s: %w", output, err) + } + + return NewPNGHeatmap(file, cfg), file, nil + default: + return nil, nil, fmt.Errorf("unsupported heatmap format: %s", format) + } +} + +// nopWriteCloser wraps an io.Writer to make it an io.WriteCloser with a no-op Close method. +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { + return nil +} \ No newline at end of file diff --git a/pkg/heatmap/heatmap_test.go b/pkg/heatmap/heatmap_test.go new file mode 100644 index 0000000..2be41a5 --- /dev/null +++ b/pkg/heatmap/heatmap_test.go @@ -0,0 +1,204 @@ +package heatmap + +import ( + "bytes" + "testing" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" + "github.com/stretchr/testify/assert" +) + +func TestASCIIHeatmap_Generate(t *testing.T) { + cfg := &config.Config{NoColor: true} + var buf bytes.Buffer + heatmap := NewASCIIHeatmap(&buf, cfg) + + // Create test data + results := compute.Results{ + ByFile: []compute.ByFile{ + { + File: "test1.go", + By: compute.By{ + StatementPercentage: 95.0, + Statements: "19/20", + }, + }, + { + File: "test2.go", + By: compute.By{ + StatementPercentage: 75.0, + Statements: "15/20", + }, + }, + }, + ByPackage: []compute.ByPackage{ + { + Package: "pkg/test", + By: compute.By{ + StatementPercentage: 85.0, + Statements: "34/40", + }, + }, + }, + ByTotal: compute.Totals{ + Statements: compute.TotalStatements{ + Coverage: "34/40", + Percentage: 85.0, + }, + Blocks: compute.TotalBlocks{ + Coverage: "30/35", + Percentage: 85.7, + }, + }, + } + + err := heatmap.Generate(results) + assert.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "COVERAGE GRID HEAT MAP") + assert.Contains(t, output, "Coverage Legend:") + assert.Contains(t, output, "By Files:") + assert.Contains(t, output, "By Packages:") + assert.Contains(t, output, "By Total:") + assert.Contains(t, output, "g/test") // Package name truncated to last 6 chars + assert.Contains(t, output, "85.0%") + assert.Contains(t, output, "Statement Coverage") + assert.Contains(t, output, "Block Coverage") + + // Verify both files and packages are displayed in grid format with new layout + assert.Contains(t, output, "test2., test1.") // File names truncated to 6 chars + assert.Contains(t, output, "g/test") // Package name truncated to last 6 chars + assert.Contains(t, output, "95") // Percentage without % in cell + assert.Contains(t, output, "75") // Percentage without % in cell +} + +func TestASCIIHeatmap_GenerateCoverageCell(t *testing.T) { + cfg := &config.Config{NoColor: true} + var buf bytes.Buffer + heatmap := NewASCIIHeatmap(&buf, cfg) + + tests := []struct { + name string + percentage float64 + expected string + }{ + {"excellent coverage", 95.0, "▓▓▓▓▓▓▓\n▓▓▓95▓▓\n▓▓▓▓▓▓▓"}, + {"good coverage", 80.0, "▒▒▒▒▒▒▒\n▒▒▒80▒▒\n▒▒▒▒▒▒▒"}, + {"fair coverage", 60.0, "░░░░░░░\n░░░60░░\n░░░░░░░"}, + {"poor coverage", 40.0, "▓▓▓▓▓▓▓\n▓▓▓40▓▓\n▓▓▓▓▓▓▓"}, + {"critical coverage", 20.0, "███████\n███20██\n███████"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := heatmap.generateCoverageCell7x3(tt.percentage) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNewGenerator(t *testing.T) { + cfg := &config.Config{} + + t.Run("ASCII format", func(t *testing.T) { + generator, writer, err := NewGenerator(config.FormatHeatmapASCII, cfg) + assert.NoError(t, err) + assert.NotNil(t, generator) + assert.NotNil(t, writer) + assert.IsType(t, &ASCIIHeatmap{}, generator) + writer.Close() + }) + + t.Run("Unsupported format", func(t *testing.T) { + generator, writer, err := NewGenerator("unsupported", cfg) + assert.Error(t, err) + assert.Nil(t, generator) + assert.Nil(t, writer) + assert.Contains(t, err.Error(), "unsupported heatmap format") + }) +} + +func TestASCIIHeatmap_TruncateFilename(t *testing.T) { + cfg := &config.Config{NoColor: true} + var buf bytes.Buffer + heatmap := NewASCIIHeatmap(&buf, cfg) + + tests := []struct { + name string + filename string + expected string + }{ + {"short filename", "test.go", "test.g"}, // Still truncated to 6 chars + {"exact 6 chars", "test12", "test12"}, + {"long filename", "verylongfilename.go", "verylo"}, + {"7 chars", "test123", "test12"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := heatmap.truncateFilename(tt.filename) + assert.Equal(t, tt.expected, result) + assert.True(t, len(result) <= 6) + }) + } +} + +func TestASCIIHeatmap_TruncatePackageName(t *testing.T) { + cfg := &config.Config{NoColor: true} + var buf bytes.Buffer + heatmap := NewASCIIHeatmap(&buf, cfg) + + tests := []struct { + name string + packageName string + expected string + }{ + {"short package", "test", "test"}, + {"exact 6 chars", "test12", "test12"}, + {"long package", "github.com/mach6/go-covercheck/pkg/math", "g/math"}, + {"7 chars", "test123", "est123"}, // Last 6 characters + {"very long", "very/long/package/name", "e/name"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := heatmap.truncatePackageName(tt.packageName) + assert.Equal(t, tt.expected, result) + assert.True(t, len(result) <= 6) + }) + } +} + +func TestASCIIHeatmap_AbbreviateText(t *testing.T) { + cfg := &config.Config{NoColor: true} + var buf bytes.Buffer + heatmap := NewASCIIHeatmap(&buf, cfg) + + tests := []struct { + name string + text string + maxLen int + expected string + }{ + {"short text", "test.go", 20, "test.go"}, + {"long text", "very_long_filename.go", 10, "very_long.."}, + {"exact length", "exact.go", 8, "exact.go"}, + {"very short limit", "test.go", 3, "tes"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := heatmap.abbreviateText(tt.text, tt.maxLen) + assert.True(t, len(result) <= tt.maxLen) + if len(tt.text) > tt.maxLen && tt.maxLen > 3 { + assert.Contains(t, result, "..") + } else if len(tt.text) <= tt.maxLen { + assert.Equal(t, tt.text, result) + } + }) + } +} diff --git a/pkg/heatmap/png.go b/pkg/heatmap/png.go new file mode 100644 index 0000000..e3e1cbc --- /dev/null +++ b/pkg/heatmap/png.go @@ -0,0 +1,419 @@ +package heatmap + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "io" + "sort" + + "github.com/mach6/go-covercheck/pkg/compute" + "github.com/mach6/go-covercheck/pkg/config" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +// PNGHeatmap represents a PNG image coverage heat map. +type PNGHeatmap struct { + writer io.Writer + config *config.Config + width int + height int +} + +// NewPNGHeatmap creates a new PNG heat map generator. +func NewPNGHeatmap(writer io.Writer, cfg *config.Config) *PNGHeatmap { + return &PNGHeatmap{ + writer: writer, + config: cfg, + width: 1000, // Will be set dynamically + height: 600, // Will be calculated based on content + } +} + +// Generate creates a PNG grid heat map image of the coverage results. +func (h *PNGHeatmap) Generate(results compute.Results) error { + // Calculate required height based on content + requiredHeight := h.calculateRequiredHeight(results) + h.height = requiredHeight + h.width = 1600 // Make it even wider to fill page better + + // Create the image + img := image.NewRGBA(image.Rect(0, 0, h.width, h.height)) + + // Fill background + draw.Draw(img, img.Bounds(), &image.Uniform{color.RGBA{245, 245, 245, 255}}, image.Point{}, draw.Src) + + // Draw title + h.drawTitle(img, "Coverage Grid Heat Map") + + // Draw legend at top + h.drawLegend(img) + + // Calculate layout + startY := 250 // Start lower to accommodate legend at top + + // Draw file grid heat map + if len(results.ByFile) > 0 { + h.drawSectionTitle(img, "By Files", 60, startY) + startY += 30 + startY = h.drawFileGridHeatmap(img, results.ByFile, startY) + startY += 40 + } + + // Draw package grid heat map + if len(results.ByPackage) > 0 { + h.drawSectionTitle(img, "By Packages", 60, startY) + startY += 30 + startY = h.drawPackageGridHeatmap(img, results.ByPackage, startY) + startY += 40 + } + + // Draw summary + h.drawSummary(img, results.ByTotal, startY) + + // Encode to PNG + return png.Encode(h.writer, img) +} + +func (h *PNGHeatmap) drawTitle(img *image.RGBA, title string) { + x := 50 + y := 40 + c := color.RGBA{0, 0, 0, 255} + + // Draw title text (using basic font since we don't want external dependencies) + h.drawText(img, title, x, y, c, true) +} + +func (h *PNGHeatmap) drawSectionTitle(img *image.RGBA, title string, x, y int) { + c := color.RGBA{60, 60, 60, 255} + h.drawText(img, title, x, y, c, false) +} + +func (h *PNGHeatmap) drawText(img *image.RGBA, text string, x, y int, c color.RGBA, bold bool) { + face := basicfont.Face7x13 + if bold { + // For bold, we'll draw the text twice with slight offset + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(c), + Face: face, + Dot: fixed.Point26_6{fixed.Int26_6(x * 64), fixed.Int26_6(y * 64)}, + } + d.DrawString(text) + d.Dot = fixed.Point26_6{fixed.Int26_6((x + 1) * 64), fixed.Int26_6(y * 64)} + d.DrawString(text) + } else { + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(c), + Face: face, + Dot: fixed.Point26_6{fixed.Int26_6(x * 64), fixed.Int26_6(y * 64)}, + } + d.DrawString(text) + } +} + +func (h *PNGHeatmap) calculateRequiredHeight(results compute.Results) int { + baseHeight := 350 // Title, legend (now vertical), margins space + + // File section + fileHeight := 0 + if len(results.ByFile) > 0 { + cols := h.calculateGridCols(len(results.ByFile)) + rows := (len(results.ByFile) + cols - 1) / cols + fileHeight = 70 + rows*120 // Section title + grid cells with labels (larger spacing) + } + + // Package section + packageHeight := 0 + if len(results.ByPackage) > 0 { + cols := h.calculateGridCols(len(results.ByPackage)) + rows := (len(results.ByPackage) + cols - 1) / cols + packageHeight = 70 + rows*140 // Section title + grid cells with labels (larger spacing) + } + + // Summary section + summaryHeight := 120 // Overall coverage section + + totalHeight := baseHeight + fileHeight + packageHeight + summaryHeight + + // Ensure minimum height + if totalHeight < 600 { + totalHeight = 600 + } + + return totalHeight +} + +func (h *PNGHeatmap) drawFileGridHeatmap(img *image.RGBA, files []compute.ByFile, startY int) int { + if len(files) == 0 { + return startY + } + + // Sort files by coverage percentage (ascending to highlight worst first) + sortedFiles := make([]compute.ByFile, len(files)) + copy(sortedFiles, files) + sort.Slice(sortedFiles, func(i, j int) bool { + return sortedFiles[i].StatementPercentage < sortedFiles[j].StatementPercentage + }) + + cols := h.calculateGridCols(len(sortedFiles)) + rows := (len(sortedFiles) + cols - 1) / cols + + cellSize := 80 + cellSpacing := 80 // No spacing between cells + gridStartX := 80 + gridStartY := startY + + for row := 0; row < rows; row++ { + maxNamesX := gridStartX + cols*cellSpacing + 20 + + for col := 0; col < cols; col++ { + idx := row*cols + col + if idx >= len(sortedFiles) { + break + } + + file := sortedFiles[idx] + x := gridStartX + col*cellSize // No extra spacing + y := gridStartY + row*cellSize // No extra spacing + + // Draw coverage cell + cellColor := h.getCoverageColor(file.StatementPercentage) + h.drawRect(img, x, y, cellSize, cellSize, cellColor) + + // Draw percentage in the middle of the cell + percentText := fmt.Sprintf("%.0f", file.StatementPercentage) + textX := x + cellSize/2 - len(percentText)*3 + textY := y + cellSize/2 + 3 + h.drawText(img, percentText, textX, textY, color.RGBA{255, 255, 255, 255}, true) + } + + // Draw file names to the right of the grid + nameY := gridStartY + row*cellSize + cellSize/2 + 3 + namesList := "" + for col := 0; col < cols; col++ { + idx := row*cols + col + if idx >= len(sortedFiles) { + break + } + file := sortedFiles[idx] + filename := h.extractFilename(file.File) + abbreviated := h.truncateFilename(filename) + if col > 0 { + namesList += ", " + } + namesList += abbreviated + } + h.drawText(img, namesList, maxNamesX, nameY, color.RGBA{0, 0, 0, 255}, false) + } + + return gridStartY + rows*cellSize +} + +func (h *PNGHeatmap) drawPackageGridHeatmap(img *image.RGBA, packages []compute.ByPackage, startY int) int { + if len(packages) == 0 { + return startY + } + + // Sort packages by coverage percentage (ascending to highlight worst first) + sortedPackages := make([]compute.ByPackage, len(packages)) + copy(sortedPackages, packages) + sort.Slice(sortedPackages, func(i, j int) bool { + return sortedPackages[i].StatementPercentage < sortedPackages[j].StatementPercentage + }) + + cols := h.calculateGridCols(len(sortedPackages)) + rows := (len(sortedPackages) + cols - 1) / cols + + cellSize := 100 + cellSpacing := 100 // No spacing between cells + gridStartX := 80 + gridStartY := startY + + for row := 0; row < rows; row++ { + maxNamesX := gridStartX + cols*cellSpacing + 20 + + for col := 0; col < cols; col++ { + idx := row*cols + col + if idx >= len(sortedPackages) { + break + } + + pkg := sortedPackages[idx] + x := gridStartX + col*cellSize // No extra spacing + y := gridStartY + row*cellSize // No extra spacing + + // Draw coverage cell + cellColor := h.getCoverageColor(pkg.StatementPercentage) + h.drawRect(img, x, y, cellSize, cellSize, cellColor) + + // Draw percentage in the middle of the cell + percentText := fmt.Sprintf("%.0f", pkg.StatementPercentage) + textX := x + cellSize/2 - len(percentText)*4 + textY := y + cellSize/2 + 3 + h.drawText(img, percentText, textX, textY, color.RGBA{255, 255, 255, 255}, true) + } + + // Draw package names to the right of the grid + nameY := gridStartY + row*cellSize + cellSize/2 + 3 + namesList := "" + for col := 0; col < cols; col++ { + idx := row*cols + col + if idx >= len(sortedPackages) { + break + } + pkg := sortedPackages[idx] + abbreviated := h.truncatePackageName(pkg.Package) + if col > 0 { + namesList += ", " + } + namesList += abbreviated + } + h.drawText(img, namesList, maxNamesX, nameY, color.RGBA{0, 0, 0, 255}, false) + } + + return gridStartY + rows*cellSize +} + +func (h *PNGHeatmap) calculateGridCols(itemCount int) int { + if itemCount <= 0 { + return 1 + } + + // With larger cells and wider image (1600px), we can fit more columns + // Each cell is roughly 100-130px wide with spacing + maxCols := 12 // Fits in 1600px width + if itemCount <= 4 { + return itemCount + } + if itemCount <= 16 { + return 4 + } + if itemCount <= 36 { + return 6 + } + if itemCount <= 64 { + return 8 + } + if itemCount <= 100 { + return 10 + } + return maxCols +} + +func (h *PNGHeatmap) extractFilename(fullPath string) string { + // Extract just the filename from a full path + lastSlash := -1 + for i := len(fullPath) - 1; i >= 0; i-- { + if fullPath[i] == '/' { + lastSlash = i + break + } + } + if lastSlash != -1 && lastSlash < len(fullPath)-1 { + return fullPath[lastSlash+1:] + } + return fullPath +} + +func (h *PNGHeatmap) drawSummary(img *image.RGBA, totals compute.Totals, startY int) { + h.drawSectionTitle(img, "By Total", 60, startY) + + x := 80 + y := startY + 30 + cellSize := 20 + + // Statements + stmtColor := h.getCoverageColor(totals.Statements.Percentage) + h.drawRect(img, x, y, cellSize, cellSize, stmtColor) + + stmtText := fmt.Sprintf("Statement Coverage %s (%.1f%%)", totals.Statements.Coverage, totals.Statements.Percentage) + h.drawText(img, stmtText, x+cellSize+10, y+15, color.RGBA{0, 0, 0, 255}, false) + + // Blocks + y += 30 + blockColor := h.getCoverageColor(totals.Blocks.Percentage) + h.drawRect(img, x, y, cellSize, cellSize, blockColor) + + blockText := fmt.Sprintf("Block Coverage %s (%.1f%%)", totals.Blocks.Coverage, totals.Blocks.Percentage) + h.drawText(img, blockText, x+cellSize+10, y+15, color.RGBA{0, 0, 0, 255}, false) +} + +func (h *PNGHeatmap) drawLegend(img *image.RGBA) { + legendX := 60 + legendY := 60 + + h.drawText(img, "Coverage Legend:", legendX, legendY, color.RGBA{0, 0, 0, 255}, false) + legendY += 25 + + legend := []struct { + label string + color color.RGBA + }{ + {"0-29% Critical (High Priority)", color.RGBA{156, 39, 176, 255}}, + {"30-49% Poor (Medium Priority)", color.RGBA{244, 67, 54, 255}}, + {"50-69% Fair (Low Priority)", color.RGBA{255, 152, 0, 255}}, + {"70-89% Good (Maintain)", color.RGBA{255, 193, 7, 255}}, + {"90-100% Excellent (Well Covered)", color.RGBA{76, 175, 80, 255}}, + } + + // Arrange legend vertically to avoid overlapping + for _, item := range legend { + h.drawRect(img, legendX, legendY-8, 15, 12, item.color) + h.drawText(img, item.label, legendX+25, legendY, color.RGBA{0, 0, 0, 255}, false) + legendY += 18 // Move to next line + } +} + +func (h *PNGHeatmap) getCoverageColor(percentage float64) color.RGBA { + switch { + case percentage >= 90: + return color.RGBA{76, 175, 80, 255} // Green + case percentage >= 70: + return color.RGBA{255, 193, 7, 255} // Yellow + case percentage >= 50: + return color.RGBA{255, 152, 0, 255} // Orange + case percentage >= 30: + return color.RGBA{244, 67, 54, 255} // Red + default: + return color.RGBA{156, 39, 176, 255} // Purple + } +} + +func (h *PNGHeatmap) drawRect(img *image.RGBA, x, y, width, height int, c color.RGBA) { + for dy := 0; dy < height; dy++ { + for dx := 0; dx < width; dx++ { + if x+dx < h.width && y+dy < h.height && x+dx >= 0 && y+dy >= 0 { + img.Set(x+dx, y+dy, c) + } + } + } +} + +func (h *PNGHeatmap) truncateFilename(filename string) string { + // Truncate file names to 6 characters + if len(filename) <= 6 { + return filename + } + return filename[:6] +} + +func (h *PNGHeatmap) truncatePackageName(packageName string) string { + // Prefix truncate to show last 6 characters for package names + if len(packageName) <= 6 { + return packageName + } + return packageName[len(packageName)-6:] +} + +func (h *PNGHeatmap) truncateText(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + return text[:maxLen-3] + "..." +} diff --git a/pkg/output/report.go b/pkg/output/report.go index ec75d1e..e565c7a 100644 --- a/pkg/output/report.go +++ b/pkg/output/report.go @@ -10,6 +10,7 @@ import ( "github.com/hokaccha/go-prettyjson" "github.com/mach6/go-covercheck/pkg/compute" "github.com/mach6/go-covercheck/pkg/config" + "github.com/mach6/go-covercheck/pkg/heatmap" "gopkg.in/yaml.v3" ) @@ -47,6 +48,31 @@ func FormatAndReport(results compute.Results, cfg *config.Config, hasFailure boo bailOnError(err) yamlColor(y) } + case config.FormatHeatmapASCII, config.FormatHeatmapPNG: + // Warn if using no-color with ASCII heatmap + if cfg.Format == config.FormatHeatmapASCII && cfg.NoColor { + fmt.Fprintf(os.Stderr, "Warning: --no-color (-w) option doesn't make sense for ASCII heatmap format - colors enhance readability\n") + } + + generator, writer, err := heatmap.NewGenerator(cfg.Format, cfg) + bailOnError(err) + defer func() { + if closeErr := writer.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close heatmap output: %v\n", closeErr) + } + }() + + err = generator.Generate(results) + bailOnError(err) + + // For PNG format, show success message + if cfg.Format == config.FormatHeatmapPNG { + output := cfg.HeatmapOutput + if output == "" { + output = "coverage-heatmap.png" + } + fmt.Printf("Coverage heat map saved to: %s\n", output) + } default: bailOnError(errors.New(color.RedString("Unsupported format: %s", cfg.Format))) }