@@ -2,15 +2,18 @@ package cli
22
33import (
44 "fmt"
5+ "html/template"
56 "log/slog"
67 "math"
8+ "os"
79 "sort"
810
911 "github.com/AlexsanderHamir/prof/engine/benchmark"
1012 "github.com/AlexsanderHamir/prof/engine/tracker"
1113 "github.com/AlexsanderHamir/prof/internal/args"
1214 "github.com/AlexsanderHamir/prof/internal/config"
1315 "github.com/AlexsanderHamir/prof/internal/shared"
16+ "github.com/microcosm-cc/bluemonday"
1417)
1518
1619func printConfiguration (benchArgs * args.BenchArgs , functionFilterPerBench map [string ]config.FunctionFilter ) {
@@ -182,3 +185,225 @@ func printDetailedReport(report *tracker.ProfileChangeReport) {
182185 fmt .Print (change .Report ())
183186 }
184187}
188+
189+ type htmlData struct {
190+ TotalFunctions int
191+ Regressions []* tracker.FunctionChangeResult
192+ Improvements []* tracker.FunctionChangeResult
193+ Stable int
194+ }
195+
196+ func generateHTMLSummary (report * tracker.ProfileChangeReport , outputPath string ) error {
197+ var regressionList , improvementList []* tracker.FunctionChangeResult
198+ var stable int
199+
200+ for _ , change := range report .FunctionChanges {
201+ switch change .ChangeType {
202+ case shared .REGRESSION :
203+ regressionList = append (regressionList , change )
204+ case shared .IMPROVEMENT :
205+ improvementList = append (improvementList , change )
206+ default :
207+ stable ++
208+ }
209+ }
210+
211+ sort .Slice (regressionList , func (i , j int ) bool {
212+ return regressionList [i ].FlatChangePercent > regressionList [j ].FlatChangePercent
213+ })
214+ sort .Slice (improvementList , func (i , j int ) bool {
215+ return math .Abs (improvementList [i ].FlatChangePercent ) > math .Abs (improvementList [j ].FlatChangePercent )
216+ })
217+
218+ data := htmlData {
219+ TotalFunctions : len (report .FunctionChanges ),
220+ Regressions : regressionList ,
221+ Improvements : improvementList ,
222+ Stable : stable ,
223+ }
224+
225+ tmpl := `
226+ <!DOCTYPE html>
227+ <html>
228+ <head>
229+ <meta charset="UTF-8">
230+ <title>Performance Tracking Summary</title>
231+ <style>
232+ body {
233+ font-family: Arial, sans-serif;
234+ margin: 2rem;
235+ font-size: 1.25rem; /* Increased again */
236+ }
237+ h2 {
238+ color: #333;
239+ font-size: 1.8rem; /* Bigger main heading */
240+ }
241+ h3 {
242+ font-size: 1.5rem; /* Bigger subheading */
243+ }
244+ .regressions { color: red; }
245+ .improvements { color: green; }
246+ ul { padding-left: 1.5rem; }
247+ </style>
248+ </head>
249+ <body>
250+ <h2>Performance Tracking Summary</h2>
251+ <p><strong>Total Functions Analyzed:</strong> {{.TotalFunctions}}</p>
252+ <p><strong>Regressions:</strong> {{len .Regressions}}</p>
253+ <p><strong>Improvements:</strong> {{len .Improvements}}</p>
254+ <p><strong>Stable:</strong> {{.Stable}}</p>
255+
256+ {{if .Regressions}}
257+ <h3 class="regressions">⚠️ Top Regressions (worst first):</h3>
258+ <ul>
259+ {{range .Regressions}}<li>{{summary .}}</li>{{end}}
260+ </ul>
261+ {{end}}
262+
263+ {{if .Improvements}}
264+ <h3 class="improvements">✅ Top Improvements (best first):</h3>
265+ <ul>
266+ {{range .Improvements}}<li>{{summary .}}</li>{{end}}
267+ </ul>
268+ {{end}}
269+ </body>
270+ </html>
271+ `
272+
273+ funcMap := template.FuncMap {
274+ "summary" : func (fc * tracker.FunctionChangeResult ) string {
275+ return fc .Summary ()
276+ },
277+ }
278+
279+ t , err := template .New ("report" ).Funcs (funcMap ).Parse (tmpl )
280+ if err != nil {
281+ return err
282+ }
283+
284+ file , err := os .Create (outputPath )
285+ if err != nil {
286+ return err
287+ }
288+ defer file .Close ()
289+
290+ return t .Execute (file , data )
291+ }
292+
293+ type detailedHTMLData struct {
294+ Total int
295+ Regressions int
296+ Improvements int
297+ Stable int
298+ Changes []* tracker.FunctionChangeResult
299+ }
300+
301+ func generateDetailedHTMLReport (report * tracker.ProfileChangeReport , outputPath string ) error {
302+ changes := report .FunctionChanges
303+
304+ // Count types
305+ var regressions , improvements , stable int
306+ for _ , change := range changes {
307+ switch change .ChangeType {
308+ case shared .REGRESSION :
309+ regressions ++
310+ case shared .IMPROVEMENT :
311+ improvements ++
312+ default :
313+ stable ++
314+ }
315+ }
316+
317+ // Sort: regressions → improvements → stable, each by magnitude
318+ typePriority := map [string ]int {
319+ shared .REGRESSION : regressionPriority ,
320+ shared .IMPROVEMENT : improvementPriority ,
321+ shared .STABLE : stablePriority ,
322+ }
323+
324+ sort .Slice (changes , func (i , j int ) bool {
325+ if typePriority [changes [i ].ChangeType ] != typePriority [changes [j ].ChangeType ] {
326+ return typePriority [changes [i ].ChangeType ] < typePriority [changes [j ].ChangeType ]
327+ }
328+ return math .Abs (changes [i ].FlatChangePercent ) > math .Abs (changes [j ].FlatChangePercent )
329+ })
330+
331+ data := detailedHTMLData {
332+ Total : len (changes ),
333+ Regressions : regressions ,
334+ Improvements : improvements ,
335+ Stable : stable ,
336+ Changes : changes ,
337+ }
338+
339+ tmpl := `
340+ <!DOCTYPE html>
341+ <html>
342+ <head>
343+ <meta charset="UTF-8">
344+ <title>Detailed Performance Report</title>
345+ <style>
346+ body { font-family: monospace, sans-serif; padding: 2rem; background: #f8f8f8; }
347+ h1, h2 { color: #333; }
348+ .stats { margin-bottom: 1rem; font-family: sans-serif; }
349+ .report-block { margin-bottom: 3rem; white-space: pre-wrap;
350+ background: #fff; padding: 1rem; border-left: 4px solid #ccc;
351+ font-size: 1.25rem; }
352+ </style>
353+ </head>
354+ <body>
355+ <h1>📈 Detailed Performance Report</h1>
356+
357+ <div class="stats">
358+ <p><strong>Total functions:</strong> {{.Total}}</p>
359+ <p><strong>🔴 Regressions:</strong> {{.Regressions}} | <strong>🟢 Improvements:</strong> {{.Improvements}} | <strong>⚪ Stable:</strong> {{.Stable}}</p>
360+ <p><em>Report Order: Regressions (worst → best), then Improvements (best → worst), then Stable</em></p>
361+ </div>
362+
363+ {{range .Changes}}
364+ <div class="report-block">
365+ <pre>{{report .}}</pre>
366+ </div>
367+ {{end}}
368+ </body>
369+ </html>
370+ `
371+
372+ sanitizer := bluemonday .StrictPolicy ()
373+ funcMap := template.FuncMap {
374+ "report" : func (fc * tracker.FunctionChangeResult ) template.HTML {
375+ safe := sanitizer .Sanitize (fc .Report ())
376+ return template .HTML (safe ) //nolint:gosec // input is being sanatized
377+ },
378+ }
379+
380+ t , err := template .New ("detailed" ).Funcs (funcMap ).Parse (tmpl )
381+ if err != nil {
382+ return err
383+ }
384+
385+ file , err := os .Create (outputPath )
386+ if err != nil {
387+ return err
388+ }
389+ defer file .Close ()
390+
391+ return t .Execute (file , data )
392+ }
393+
394+ func chooseOutputFormat (report * tracker.ProfileChangeReport ) {
395+ switch outputFormat {
396+ case "summary" :
397+ printSummary (report )
398+ case "detailed" :
399+ printDetailedReport (report )
400+ case "summary-html" :
401+ if err := generateHTMLSummary (report , "summary.html" ); err != nil {
402+ slog .Info ("summary-html failed" , "err" , err )
403+ }
404+ case "detailed-html" :
405+ if err := generateDetailedHTMLReport (report , "detailed.html" ); err != nil {
406+ slog .Info ("detailed-html failed" , "err" , err )
407+ }
408+ }
409+ }
0 commit comments