package main import ( "bytes" "encoding/csv" "encoding/json" "fmt" "os" "strconv" "strings" ) // render turns a schedule into bytes. Both encoders are pure functions of the // data: no timestamps, no maps, fixed column order, LF line endings. Running // the crawler twice on the same page produces identical bytes, which is what // makes -check and diff-based review meaningful. func render(format string, s *Schedule) ([]byte, error) { switch format { case "json": var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) // keep team names readable enc.SetIndent("", " ") if err := enc.Encode(s); err != nil { return nil, err } return buf.Bytes(), nil case "csv": return renderCSV(s) default: return nil, fmt.Errorf("unknown format %q: want json, csv or ndjson", format) } } func renderNDJSON(s *Schedule) ([]byte, error) { var buf bytes.Buffer if err := jsonEncoder(&buf).Encode(s); err != nil { return nil, err } return buf.Bytes(), nil } // jsonEncoder keeps "รค" readable instead of \u00e4, which matters for diffs // of team names. func jsonEncoder(buf *bytes.Buffer) *json.Encoder { enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) return enc } var csvHeader = []string{ "season_id", "game_id", "event_id", "date", "time", "home", "away", "home_goals", "away_goals", "periods", "spectators", "venue", } func renderCSV(s *Schedule) ([]byte, error) { var buf bytes.Buffer w := csv.NewWriter(&buf) // LF, not CRLF if err := w.Write(csvHeader); err != nil { return nil, err } for _, g := range s.Games { periods := make([]string, 0, len(g.Periods)) for _, p := range g.Periods { periods = append(periods, fmt.Sprintf("%s:%d-%d", p.Label, p.Home, p.Away)) } row := []string{ strconv.Itoa(s.SeasonID), g.GameID, g.EventID, g.Date, g.Time, g.Home, g.Away, optInt(g.HomeGoals), optInt(g.AwayGoals), strings.Join(periods, " "), optInt(g.Spectators), g.Venue, } if err := w.Write(row); err != nil { return nil, err } } w.Flush() return buf.Bytes(), w.Error() } func optInt(p *int) string { if p == nil { return "" } return strconv.Itoa(*p) } // emit writes the bytes to -out or stdout and, when -check is set, compares // them with a committed golden file. A mismatch exits 3 so CI turns an upstream // change into a reviewable diff instead of a silent data change. func emit(out, check string, b []byte) error { if check != "" { golden, err := os.ReadFile(check) switch { case err == nil && bytes.Equal(golden, b): case err != nil: return fmt.Errorf("golden file %s: %w", check, err) default: // A golden mismatch is not a layout problem, so it gets its own // advice instead of the "extend layouts[]" one. return &DriftError{Msg: fmt.Sprintf("output no longer matches %s: %s", check, firstDiff(golden, b)), Hint: " - regenerate the golden file only after reviewing the change"} } } if out == "" { _, err := os.Stdout.Write(b) return err } return os.WriteFile(out, b, 0o644) } // firstDiff describes the first differing line, for a readable failure. func firstDiff(a, b []byte) string { al, bl := strings.Split(string(a), "\n"), strings.Split(string(b), "\n") for i := 0; i < len(al) || i < len(bl); i++ { var x, y string if i < len(al) { x = al[i] } if i < len(bl) { y = bl[i] } if x != y { return fmt.Sprintf("line %d: %q != %q", i+1, trim(x), trim(y)) } } return "documents differ only in length" } func trim(s string) string { if len(s) > 80 { return s[:80] + "..." } return s }