Skip to content

Commit d787921

Browse files
committed
feat: colored console output
1 parent d89c5a4 commit d787921

5 files changed

Lines changed: 177 additions & 52 deletions

File tree

cmd/test.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ var (
1717
testNoRestore bool
1818
testForceRestore bool
1919
testFailOnSkipped bool
20+
testColor bool
21+
testNoColor bool
22+
testFullDiff bool
23+
testNoDiff bool
2024

2125
testCmd = &cobra.Command{
2226
Use: "test [flags]",
@@ -26,7 +30,21 @@ var (
2630
fmt.Print(err.Error())
2731
os.Exit(1)
2832
}
29-
regresql.Test(testCwd, testRunFilter, testFormat, testOutputPath, testCommit, testNoRestore, testForceRestore, testFailOnSkipped)
33+
opts := regresql.TestOptions{
34+
Root: testCwd,
35+
RunFilter: testRunFilter,
36+
FormatName: testFormat,
37+
OutputPath: testOutputPath,
38+
Commit: testCommit,
39+
NoRestore: testNoRestore,
40+
ForceRestore: testForceRestore,
41+
FailOnSkipped: testFailOnSkipped,
42+
Color: testColor,
43+
NoColor: testNoColor,
44+
FullDiff: testFullDiff,
45+
NoDiff: testNoDiff,
46+
}
47+
regresql.Test(opts)
3048
},
3149
}
3250
)
@@ -42,4 +60,8 @@ func init() {
4260
testCmd.Flags().BoolVar(&testNoRestore, "no-restore", false, "Skip snapshot restore before test")
4361
testCmd.Flags().BoolVar(&testForceRestore, "force-restore", false, "Force snapshot restore even if unchanged")
4462
testCmd.Flags().BoolVar(&testFailOnSkipped, "fail-on-skipped", false, "Exit with code 2 if skipped tests exist")
63+
testCmd.Flags().BoolVar(&testColor, "color", false, "Force colored output")
64+
testCmd.Flags().BoolVar(&testNoColor, "no-color", false, "Disable colored output")
65+
testCmd.Flags().BoolVar(&testFullDiff, "diff", false, "Show full diff output (no truncation)")
66+
testCmd.Flags().BoolVar(&testNoDiff, "no-diff", false, "Suppress diff output entirely")
4567
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,7 @@ require (
2323
github.com/stretchr/testify v1.11.1 // indirect
2424
golang.org/x/crypto v0.31.0 // indirect
2525
golang.org/x/sync v0.16.0 // indirect
26+
golang.org/x/sys v0.40.0 // indirect
27+
golang.org/x/term v0.39.0 // indirect
2628
gopkg.in/yaml.v2 v2.4.0 // indirect
2729
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
4343
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
4444
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
4545
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
46+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
47+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
48+
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
49+
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
4650
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
4751
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
4852
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

regresql/formatter_console.go

Lines changed: 111 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,67 @@ package regresql
33
import (
44
"fmt"
55
"io"
6+
"os"
67
"strings"
8+
9+
"golang.org/x/term"
710
)
811

12+
type ConsoleOptions struct {
13+
Color bool
14+
NoColor bool
15+
FullDiff bool
16+
NoDiff bool
17+
}
18+
919
type ConsoleFormatter struct {
10-
results []TestResult
20+
results []TestResult
21+
options ConsoleOptions
22+
useColor bool
23+
}
24+
25+
func (f *ConsoleFormatter) SetOptions(opts ConsoleOptions) {
26+
f.options = opts
27+
f.useColor = f.shouldUseColor()
28+
}
29+
30+
func (f *ConsoleFormatter) shouldUseColor() bool {
31+
// Explicit flags take precedence
32+
if f.options.NoColor {
33+
return false
34+
}
35+
if f.options.Color {
36+
return true
37+
}
38+
39+
// Respect NO_COLOR environment variable (https://no-color.org/)
40+
if _, exists := os.LookupEnv("NO_COLOR"); exists {
41+
return false
42+
}
43+
44+
// Check TERM for dumb terminals
45+
if os.Getenv("TERM") == "dumb" {
46+
return false
47+
}
48+
49+
// Auto-detect: use color if stdout is a terminal
50+
return term.IsTerminal(int(os.Stdout.Fd()))
51+
}
52+
53+
const (
54+
colorReset = "\033[0m"
55+
colorRed = "\033[31m"
56+
colorGreen = "\033[32m"
57+
colorYellow = "\033[33m"
58+
colorCyan = "\033[36m"
59+
colorDim = "\033[2m"
60+
)
61+
62+
func (f *ConsoleFormatter) colorize(text, color string) string {
63+
if !f.useColor {
64+
return text
65+
}
66+
return color + text + colorReset
1167
}
1268

1369
func (f *ConsoleFormatter) Start(w io.Writer) error {
@@ -19,18 +75,18 @@ func (f *ConsoleFormatter) Start(w io.Writer) error {
1975
func (f *ConsoleFormatter) AddResult(r TestResult, w io.Writer) error {
2076
f.results = append(f.results, r)
2177

22-
// Show progress indicator
78+
// Show progress indicator with color
2379
switch r.Status {
2480
case "passed":
25-
fmt.Fprint(w, ".")
81+
fmt.Fprint(w, f.colorize(".", colorGreen))
2682
case "failed":
27-
fmt.Fprint(w, "F")
83+
fmt.Fprint(w, f.colorize("F", colorRed))
2884
case "pending":
29-
fmt.Fprint(w, "?")
85+
fmt.Fprint(w, f.colorize("?", colorCyan))
3086
case "warning":
31-
fmt.Fprint(w, "W")
87+
fmt.Fprint(w, f.colorize("W", colorYellow))
3288
case "skipped":
33-
fmt.Fprint(w, "S")
89+
fmt.Fprint(w, f.colorize("S", colorDim))
3490
}
3591
return nil
3692
}
@@ -87,18 +143,31 @@ func (f *ConsoleFormatter) printOutputDiff(r TestResult, w io.Writer) {
87143

88144
lines := strings.Split(r.Diff, "\n")
89145
shown := 0
146+
maxLines := 5
147+
if f.options.FullDiff {
148+
maxLines = len(lines) // No limit
149+
}
150+
90151
for _, line := range lines {
91-
if shown >= 5 {
152+
if shown >= maxLines {
92153
break
93154
}
94-
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") ||
95-
strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
96-
fmt.Fprintf(w, " %s\n", line)
155+
// Show hunk headers to separate different change locations
156+
if strings.HasPrefix(line, "@@") {
157+
fmt.Fprintf(w, " %s\n", f.colorize(line, colorCyan))
158+
// Don't count headers toward the limit
159+
continue
160+
}
161+
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
162+
fmt.Fprintf(w, " %s\n", f.colorize(line, colorGreen))
163+
shown++
164+
} else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
165+
fmt.Fprintf(w, " %s\n", f.colorize(line, colorRed))
97166
shown++
98167
}
99168
}
100-
if shown >= 5 {
101-
fmt.Fprintln(w, " ...")
169+
if !f.options.FullDiff && shown >= maxLines {
170+
fmt.Fprintln(w, f.colorize(" ... (use --diff to see full output)", colorDim))
102171
}
103172
}
104173

@@ -112,49 +181,49 @@ func (f *ConsoleFormatter) printStructuredDiff(diff *StructuredDiff, w io.Writer
112181
case DiffTypeOrdering:
113182
fmt.Fprintln(w, " └─ Result: Same data, different order")
114183
fmt.Fprintln(w)
115-
fmt.Fprintln(w, " ⚠️ Rows are identical but in different order.")
184+
fmt.Fprintln(w, f.colorize(" ⚠️ Rows are identical but in different order.", colorYellow))
116185
fmt.Fprintln(w, " Consider adding ORDER BY clause to ensure deterministic results.")
117186

118187
case DiffTypeRowCount, DiffTypeMultiple:
119188
if diff.RemovedRows > 0 && diff.AddedRows > 0 {
120189
fmt.Fprintf(w, " ├─ Matching: %d rows\n", diff.MatchingRows)
121-
fmt.Fprintf(w, " ├─ Added: %d rows\n", diff.AddedRows)
122-
fmt.Fprintf(w, " └─ Removed: %d rows\n", diff.RemovedRows)
190+
fmt.Fprintf(w, " ├─ %s\n", f.colorize(fmt.Sprintf("Added: %d rows", diff.AddedRows), colorGreen))
191+
fmt.Fprintf(w, " └─ %s\n", f.colorize(fmt.Sprintf("Removed: %d rows", diff.RemovedRows), colorRed))
123192
} else if diff.RemovedRows > 0 {
124-
fmt.Fprintf(w, " └─ Result: %d rows removed\n", diff.RemovedRows)
193+
fmt.Fprintf(w, " └─ Result: %s\n", f.colorize(fmt.Sprintf("%d rows removed", diff.RemovedRows), colorRed))
125194
} else if diff.AddedRows > 0 {
126-
fmt.Fprintf(w, " └─ Result: %d rows added\n", diff.AddedRows)
195+
fmt.Fprintf(w, " └─ Result: %s\n", f.colorize(fmt.Sprintf("%d rows added", diff.AddedRows), colorGreen))
127196
}
128197

129198
fmt.Fprintln(w)
130199

131200
if len(diff.RemovedSamples) > 0 {
132-
fmt.Fprintf(w, " REMOVED ROWS (showing %d of %d):\n", len(diff.RemovedSamples), diff.RemovedRows)
201+
fmt.Fprintf(w, " %s\n", f.colorize(fmt.Sprintf("REMOVED ROWS (showing %d of %d):", len(diff.RemovedSamples), diff.RemovedRows), colorRed))
133202
for _, row := range diff.RemovedSamples {
134-
fmt.Fprintf(w, " %s\n", f.formatRow(diff.Columns, row))
203+
fmt.Fprintf(w, " %s\n", f.colorize(f.formatRow(diff.Columns, row), colorRed))
135204
}
136205
fmt.Fprintln(w)
137206
}
138207

139208
if len(diff.AddedSamples) > 0 {
140-
fmt.Fprintf(w, " ADDED ROWS (showing %d of %d):\n", len(diff.AddedSamples), diff.AddedRows)
209+
fmt.Fprintf(w, " %s\n", f.colorize(fmt.Sprintf("ADDED ROWS (showing %d of %d):", len(diff.AddedSamples), diff.AddedRows), colorGreen))
141210
for _, row := range diff.AddedSamples {
142-
fmt.Fprintf(w, " %s\n", f.formatRow(diff.Columns, row))
211+
fmt.Fprintf(w, " %s\n", f.colorize(f.formatRow(diff.Columns, row), colorGreen))
143212
}
144213
fmt.Fprintln(w)
145214
}
146215

147216
case DiffTypeValues:
148217
fmt.Fprintf(w, " ├─ Matching: %d rows\n", diff.MatchingRows)
149-
fmt.Fprintf(w, " └─ Modified: %d rows\n", diff.ModifiedRows)
218+
fmt.Fprintf(w, " └─ %s\n", f.colorize(fmt.Sprintf("Modified: %d rows", diff.ModifiedRows), colorYellow))
150219
fmt.Fprintln(w)
151220

152221
if len(diff.ModifiedSamples) > 0 {
153-
fmt.Fprintf(w, " MODIFIED ROWS (showing %d of %d):\n", len(diff.ModifiedSamples), diff.ModifiedRows)
222+
fmt.Fprintf(w, " %s\n", f.colorize(fmt.Sprintf("MODIFIED ROWS (showing %d of %d):", len(diff.ModifiedSamples), diff.ModifiedRows), colorYellow))
154223
for i, sample := range diff.ModifiedSamples {
155224
fmt.Fprintf(w, " Row #%d:\n", i+1)
156-
fmt.Fprintf(w, " Expected: %s\n", f.formatRow(diff.Columns, sample.ExpectedRow))
157-
fmt.Fprintf(w, " Actual: %s\n", f.formatRow(diff.Columns, sample.ActualRow))
225+
fmt.Fprintf(w, " %s %s\n", f.colorize("Expected:", colorRed), f.formatRow(diff.Columns, sample.ExpectedRow))
226+
fmt.Fprintf(w, " %s %s\n", f.colorize("Actual: ", colorGreen), f.formatRow(diff.Columns, sample.ActualRow))
158227
}
159228
}
160229
}
@@ -190,9 +259,9 @@ func formatValue(v any) string {
190259
func (f *ConsoleFormatter) printWarnings(warnings []PlanWarning, w io.Writer) {
191260
for _, warning := range warnings {
192261
if warning.Severity == "warning" {
193-
fmt.Fprintf(w, " ⚠️ %s\n", warning.Message)
262+
fmt.Fprintf(w, " %s %s\n", f.colorize("⚠️", colorYellow), warning.Message)
194263
if warning.Suggestion != "" {
195-
fmt.Fprintf(w, " Suggestion: %s\n", warning.Suggestion)
264+
fmt.Fprintf(w, " %s %s\n", f.colorize("Suggestion:", colorDim), warning.Suggestion)
196265
}
197266
}
198267
}
@@ -204,32 +273,34 @@ func (f *ConsoleFormatter) Finish(s *TestSummary, w io.Writer) error {
204273

205274
// Summary section
206275
fmt.Fprintln(w, "RESULTS:")
207-
fmt.Fprintf(w, " %d passing\n", s.Passed)
276+
fmt.Fprintf(w, " %s %d passing\n", f.colorize("✓", colorGreen), s.Passed)
208277
if s.Failed > 0 {
209-
fmt.Fprintf(w, " %d failing\n", s.Failed)
278+
fmt.Fprintf(w, " %s %d failing\n", f.colorize("✗", colorRed), s.Failed)
210279
}
211280
if s.Pending > 0 {
212-
fmt.Fprintf(w, " ? %d pending (no baseline)\n", s.Pending)
281+
fmt.Fprintf(w, " %s %d pending (no baseline)\n", f.colorize("?", colorCyan), s.Pending)
213282
}
214283
if s.Skipped > 0 {
215-
fmt.Fprintf(w, " - %d skipped\n", s.Skipped)
284+
fmt.Fprintf(w, " %s %d skipped\n", f.colorize("-", colorDim), s.Skipped)
216285
}
217286
fmt.Fprintf(w, " %.2fs total\n", s.Duration)
218287

219288
// Failing tests details
220289
if s.Failed > 0 {
221290
fmt.Fprintln(w)
222-
fmt.Fprintln(w, "FAILING:")
291+
fmt.Fprintln(w, f.colorize("FAILING:", colorRed))
223292
for _, r := range f.results {
224293
if r.Status == "failed" {
225294
fmt.Fprintf(w, " %s\n", r.Name)
226-
if r.Type == "cost" {
227-
f.printCostFailure(r, w)
228-
} else if r.Type == "output" {
229-
f.printOutputDiff(r, w)
295+
if !f.options.NoDiff {
296+
if r.Type == "cost" {
297+
f.printCostFailure(r, w)
298+
} else if r.Type == "output" {
299+
f.printOutputDiff(r, w)
300+
}
230301
}
231302
if r.Error != "" {
232-
fmt.Fprintf(w, " Error: %s\n", r.Error)
303+
fmt.Fprintf(w, " %s %s\n", f.colorize("Error:", colorRed), r.Error)
233304
}
234305
}
235306
}
@@ -244,7 +315,7 @@ func (f *ConsoleFormatter) Finish(s *TestSummary, w io.Writer) error {
244315
}
245316
if len(warnings) > 0 {
246317
fmt.Fprintln(w)
247-
fmt.Fprintln(w, "WARNINGS:")
318+
fmt.Fprintln(w, f.colorize("WARNINGS:", colorYellow))
248319
for _, r := range warnings {
249320
fmt.Fprintf(w, " %s\n", r.Name)
250321
f.printWarnings(r.PlanWarnings, w)
@@ -254,7 +325,7 @@ func (f *ConsoleFormatter) Finish(s *TestSummary, w io.Writer) error {
254325
// Pending tests
255326
if s.Pending > 0 {
256327
fmt.Fprintln(w)
257-
fmt.Fprintln(w, "PENDING (no baseline):")
328+
fmt.Fprintln(w, f.colorize("PENDING (no baseline):", colorCyan))
258329
for _, r := range f.results {
259330
if r.Status == "pending" {
260331
fmt.Fprintf(w, " %s\n", r.Name)

0 commit comments

Comments
 (0)