1// SPDX-License-Identifier: MIT23package processor45import (6 "os"7 "strings"8 "time"910 "github.com/mattn/go-isatty"11)1213// historyDateLayout is the canonical date-only format used in window14// headers and metadata. Matches the spec example "2024-01-09 → 2026-05-20".15const historyDateLayout = "2006-01-02"1617// historyHeader renders the centred two-line "<break> <name> · last N18// commits · from → to <break>" block that every tabular report uses.19func historyHeader(reportName string, w HistoryWindow, wide bool) string {20 break_ := tabularBreakFor(wide)21 var sb strings.Builder22 sb.WriteString(break_)23 sb.WriteString(formatHeaderLine(reportName, w))24 sb.WriteByte('\n')25 sb.WriteString(break_)26 return sb.String()27}2829func formatHeaderLine(reportName string, w HistoryWindow) string {30 if w.Commits == 0 {31 return reportName + " · no commits"32 }33 from := w.From.UTC().Format(historyDateLayout)34 to := w.To.UTC().Format(historyDateLayout)35 return reportName + " · last " + itoa(w.Commits) + " commits · " + from + " → " + to36}3738// tabularBreakFor returns the 79- or 109-column break the existing renderers39// produce, honouring --no-hborder and --ci. Centralised here so every40// history renderer agrees with the language tables.41func tabularBreakFor(wide bool) string {42 if wide {43 return getTabularWideBreak()44 }45 return getTabularShortBreak()46}4748// renderBar returns a unicode bar of the given cell width filled to ratio.49// Falls back to ASCII '#' when --ci is on or output is not a TTY (CSV-safe50// callers should not use this helper).51func renderBar(ratio float64, width int) string {52 if width <= 0 {53 return ""54 }55 if ratio < 0 {56 ratio = 057 }58 if ratio > 1 {59 ratio = 160 }6162 if asciiOutput() {63 filled := int(ratio*float64(width) + 0.5)64 return strings.Repeat("#", filled) + strings.Repeat(" ", width-filled)65 }6667 // 8 sub-cell levels using the Block Elements range U+2581..U+2588.68 const blocks = "▏▎▍▌▋▊▉█"69 total := ratio * float64(width)70 full := int(total)71 remainder := total - float64(full)72 var sb strings.Builder73 for i := 0; i < full; i++ {74 sb.WriteRune('█')75 }76 if full < width {77 idx := int(remainder * 8)78 if idx > 0 {79 runes := []rune(blocks)80 sb.WriteRune(runes[idx-1])81 full++82 }83 }84 for i := full; i < width; i++ {85 sb.WriteRune(' ')86 }87 return sb.String()88}8990// renderSparkline downsamples series to the given cell width and renders it91// with U+2581..U+2587 spark characters (or ASCII when --ci / no TTY). The92// full block U+2588 is intentionally excluded so peak cells keep a 1-pixel93// gap at the top — without it, adjacent tall cells merge into a solid wall94// when trajectories rise monotonically.95// Used by plans 04–05; placed here so the helpers travel together.96func renderSparkline(series []float64, width int) string {97 if width <= 0 || len(series) == 0 {98 return ""99 }100 buckets := downsampleSeries(series, width)101 mx := 0.0102 for _, v := range buckets {103 if v > mx {104 mx = v105 }106 }107 if mx == 0 {108 if asciiOutput() {109 return strings.Repeat(".", width)110 }111 return strings.Repeat("▁", width)112 }113114 if asciiOutput() {115 const ramp = " .:-=+*#%@"116 var sb strings.Builder117 for _, v := range buckets {118 idx := int(v / mx * float64(len(ramp)-1))119 sb.WriteByte(ramp[idx])120 }121 return sb.String()122 }123124 const ticks = "▁▂▃▄▅▆▇"125 runes := []rune(ticks)126 var sb strings.Builder127 for _, v := range buckets {128 idx := int(v / mx * float64(len(runes)-1))129 sb.WriteRune(runes[idx])130 }131 return sb.String()132}133134// downsampleSeries averages contiguous chunks of series into n buckets. If135// len(series) <= n it pads to n with the trailing value.136func downsampleSeries(series []float64, n int) []float64 {137 if n <= 0 {138 return nil139 }140 out := make([]float64, n)141 if len(series) == 0 {142 return out143 }144 if len(series) <= n {145 copy(out, series)146 for i := len(series); i < n; i++ {147 out[i] = series[len(series)-1]148 }149 return out150 }151 step := float64(len(series)) / float64(n)152 for i := range n {153 lo := int(float64(i) * step)154 hi := min(int(float64(i+1)*step), len(series))155 if hi <= lo {156 hi = lo + 1157 }158 sum := 0.0159 for j := lo; j < hi; j++ {160 sum += series[j]161 }162 out[i] = sum / float64(hi-lo)163 }164 return out165}166167// asciiOutput returns true when callers should avoid box/block glyphs168// (CI mode or non-TTY stdout).169func asciiOutput() bool {170 if Ci {171 return true172 }173 if FileOutput != "" {174 return true175 }176 return !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd())177}178179// itoa is a tiny strconv.Itoa wrapper kept local so this file doesn't pull180// strconv just for one call site.181func itoa(n int) string {182 if n == 0 {183 return "0"184 }185 neg := false186 if n < 0 {187 neg = true188 n = -n189 }190 buf := make([]byte, 0, 12)191 for n > 0 {192 buf = append(buf, byte('0'+n%10))193 n /= 10194 }195 if neg {196 buf = append(buf, '-')197 }198 for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {199 buf[i], buf[j] = buf[j], buf[i]200 }201 return string(buf)202}203204// formatWindowComment builds the "# window: depth=… commits=… from=… to=…"205// comment line used by CSV outputs.206func formatWindowComment(w HistoryWindow) string {207 var depth string208 if w.Depth == 0 {209 depth = "all"210 } else {211 depth = itoa(w.Depth)212 }213 return "# window: depth=" + depth +214 " commits=" + itoa(w.Commits) +215 " from=" + formatWindowDate(w.From) +216 " to=" + formatWindowDate(w.To)217}218219func formatWindowDate(t time.Time) string {220 if t.IsZero() {221 return ""222 }223 return t.UTC().Format(historyDateLayout)224}
Findings
✓ No findings reported for this file.