• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 21298353538

23 Jan 2026 07:22PM UTC coverage: 79.367% (-0.03%) from 79.395%
21298353538

Pull #17

github

melonamin
test: improve pkg/web coverage from 87.7% to 89.2%

Add tests for previously uncovered code paths:
- Hub.DroppedEvents (0% -> 100%)
- SessionManager.evictOldCompleted (40% -> 100%)
- Watcher directory creation detection
- Watcher.Close method
Pull Request #17: feat: add web dashboard with real-time streaming and multi-session support

1532 of 1931 new or added lines in 19 files covered. (79.34%)

10 existing lines in 3 files now uncovered.

3085 of 3887 relevant lines covered (79.37%)

228.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.55
/pkg/progress/progress.go
1
// Package progress provides timestamped logging to file and stdout with color support.
2
package progress
3

4
import (
5
        "fmt"
6
        "io"
7
        "os"
8
        "path/filepath"
9
        "strconv"
10
        "strings"
11
        "syscall"
12
        "time"
13

14
        "github.com/dustin/go-humanize"
15
        "github.com/fatih/color"
16
        "golang.org/x/term"
17

18
        "github.com/umputun/ralphex/pkg/config"
19
        "github.com/umputun/ralphex/pkg/processor"
20
)
21

22
// Phase is an alias to processor.Phase for backwards compatibility.
23
//
24
// Deprecated: use processor.Phase directly.
25
type Phase = processor.Phase
26

27
// Phase constants for execution stages - aliases to processor constants.
28
const (
29
        PhaseTask       = processor.PhaseTask
30
        PhaseReview     = processor.PhaseReview
31
        PhaseCodex      = processor.PhaseCodex
32
        PhaseClaudeEval = processor.PhaseClaudeEval
33
)
34

35
// Colors holds all color configuration for output formatting.
36
// use NewColors to create from config.ColorConfig.
37
type Colors struct {
38
        task       *color.Color
39
        review     *color.Color
40
        codex      *color.Color
41
        claudeEval *color.Color
42
        warn       *color.Color
43
        err        *color.Color
44
        signal     *color.Color
45
        timestamp  *color.Color
46
        info       *color.Color
47
        phases     map[Phase]*color.Color
48
}
49

50
// NewColors creates Colors from config.ColorConfig.
51
// all colors must be provided - use config with embedded defaults fallback.
52
// panics if any color value is invalid (configuration error).
53
func NewColors(cfg config.ColorConfig) *Colors {
16✔
54
        c := &Colors{phases: make(map[Phase]*color.Color)}
16✔
55
        c.task = parseColorOrPanic(cfg.Task, "task")
16✔
56
        c.review = parseColorOrPanic(cfg.Review, "review")
16✔
57
        c.codex = parseColorOrPanic(cfg.Codex, "codex")
16✔
58
        c.claudeEval = parseColorOrPanic(cfg.ClaudeEval, "claude_eval")
16✔
59
        c.warn = parseColorOrPanic(cfg.Warn, "warn")
16✔
60
        c.err = parseColorOrPanic(cfg.Error, "error")
16✔
61
        c.signal = parseColorOrPanic(cfg.Signal, "signal")
16✔
62
        c.timestamp = parseColorOrPanic(cfg.Timestamp, "timestamp")
16✔
63
        c.info = parseColorOrPanic(cfg.Info, "info")
16✔
64

16✔
65
        c.phases[PhaseTask] = c.task
16✔
66
        c.phases[PhaseReview] = c.review
16✔
67
        c.phases[PhaseCodex] = c.codex
16✔
68
        c.phases[PhaseClaudeEval] = c.claudeEval
16✔
69

16✔
70
        return c
16✔
71
}
16✔
72

73
// parseColorOrPanic parses RGB string and returns color, panics on invalid input.
74
func parseColorOrPanic(s, name string) *color.Color {
146✔
75
        parseRGB := func(s string) []int {
292✔
76
                if s == "" {
148✔
77
                        return nil
2✔
78
                }
2✔
79
                parts := strings.Split(s, ",")
144✔
80
                if len(parts) != 3 {
149✔
81
                        return nil
5✔
82
                }
5✔
83

84
                // parse each component
85
                r, err := strconv.Atoi(strings.TrimSpace(parts[0]))
139✔
86
                if err != nil || r < 0 || r > 255 {
142✔
87
                        return nil
3✔
88
                }
3✔
89
                g, err := strconv.Atoi(strings.TrimSpace(parts[1]))
136✔
90
                if err != nil || g < 0 || g > 255 {
139✔
91
                        return nil
3✔
92
                }
3✔
93
                b, err := strconv.Atoi(strings.TrimSpace(parts[2]))
133✔
94
                if err != nil || b < 0 || b > 255 {
136✔
95
                        return nil
3✔
96
                }
3✔
97
                return []int{r, g, b}
130✔
98
        }
99

100
        rgb := parseRGB(s)
146✔
101
        if rgb == nil {
162✔
102
                panic(fmt.Sprintf("invalid color_%s value: %q", name, s))
16✔
103
        }
104
        return color.RGB(rgb[0], rgb[1], rgb[2])
130✔
105
}
106

107
// Info returns the info color for informational messages.
108
func (c *Colors) Info() *color.Color { return c.info }
2✔
109

110
// ForPhase returns the color for the given execution phase.
111
func (c *Colors) ForPhase(p Phase) *color.Color { return c.phases[p] }
15✔
112

113
// Timestamp returns the timestamp color.
114
func (c *Colors) Timestamp() *color.Color { return c.timestamp }
13✔
115

116
// Warn returns the warning color.
117
func (c *Colors) Warn() *color.Color { return c.warn }
4✔
118

119
// Error returns the error color.
120
func (c *Colors) Error() *color.Color { return c.err }
3✔
121

122
// Signal returns the signal color.
123
func (c *Colors) Signal() *color.Color { return c.signal }
2✔
124

125
// Logger writes timestamped output to both file and stdout.
126
type Logger struct {
127
        file      *os.File
128
        stdout    io.Writer
129
        startTime time.Time
130
        phase     Phase
131
        colors    *Colors
132
}
133

134
// Config holds logger configuration.
135
type Config struct {
136
        PlanFile string // plan filename (used to derive progress filename)
137
        Mode     string // execution mode: full, review, codex-only
138
        Branch   string // current git branch
139
        NoColor  bool   // disable color output (sets color.NoColor globally)
140
}
141

142
// NewLogger creates a logger writing to both a progress file and stdout.
143
// colors must be provided (created via NewColors from config).
144
func NewLogger(cfg Config, colors *Colors) (*Logger, error) {
17✔
145
        // set global color setting
17✔
146
        if cfg.NoColor {
25✔
147
                color.NoColor = true
8✔
148
        }
8✔
149

150
        progressPath := progressFilename(cfg.PlanFile, cfg.Mode)
17✔
151

17✔
152
        // ensure progress files are tracked by creating parent dir
17✔
153
        if dir := filepath.Dir(progressPath); dir != "." {
17✔
154
                if err := os.MkdirAll(dir, 0o750); err != nil {
×
155
                        return nil, fmt.Errorf("create progress dir: %w", err)
×
156
                }
×
157
        }
158

159
        f, err := os.Create(progressPath) //nolint:gosec // path derived from plan filename
17✔
160
        if err != nil {
17✔
161
                return nil, fmt.Errorf("create progress file: %w", err)
×
162
        }
×
163

164
        // acquire exclusive lock on progress file to signal active session
165
        // the lock is held for the duration of execution and released on Close()
166
        if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
17✔
NEW
167
                f.Close()
×
NEW
168
                return nil, fmt.Errorf("acquire file lock: %w", err)
×
NEW
169
        }
×
170
        registerActiveLock(f.Name())
17✔
171

17✔
172
        l := &Logger{
17✔
173
                file:      f,
17✔
174
                stdout:    os.Stdout,
17✔
175
                startTime: time.Now(),
17✔
176
                phase:     PhaseTask,
17✔
177
                colors:    colors,
17✔
178
        }
17✔
179

17✔
180
        // write header
17✔
181
        planStr := cfg.PlanFile
17✔
182
        if planStr == "" {
31✔
183
                planStr = "(no plan - review only)"
14✔
184
        }
14✔
185
        l.writeFile("# Ralphex Progress Log\n")
17✔
186
        l.writeFile("Plan: %s\n", planStr)
17✔
187
        l.writeFile("Branch: %s\n", cfg.Branch)
17✔
188
        l.writeFile("Mode: %s\n", cfg.Mode)
17✔
189
        l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
17✔
190
        l.writeFile("%s\n\n", strings.Repeat("-", 60))
17✔
191

17✔
192
        return l, nil
17✔
193
}
194

195
// Path returns the progress file path.
196
func (l *Logger) Path() string {
19✔
197
        if l.file == nil {
19✔
198
                return ""
×
199
        }
×
200
        return l.file.Name()
19✔
201
}
202

203
// SetPhase sets the current execution phase for color coding.
204
func (l *Logger) SetPhase(phase Phase) {
4✔
205
        l.phase = phase
4✔
206
}
4✔
207

208
// timestampFormat is the format for timestamps: YY-MM-DD HH:MM:SS
209
const timestampFormat = "06-01-02 15:04:05"
210

211
// Print writes a timestamped message to both file and stdout.
212
func (l *Logger) Print(format string, args ...any) {
6✔
213
        msg := fmt.Sprintf(format, args...)
6✔
214
        timestamp := time.Now().Format(timestampFormat)
6✔
215

6✔
216
        // write to file without color
6✔
217
        l.writeFile("[%s] %s\n", timestamp, msg)
6✔
218

6✔
219
        // write to stdout with color
6✔
220
        phaseColor := l.colors.ForPhase(l.phase)
6✔
221
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
6✔
222
        msgStr := phaseColor.Sprint(msg)
6✔
223
        l.writeStdout("%s %s\n", tsStr, msgStr)
6✔
224
}
6✔
225

226
// PrintRaw writes without timestamp (for streaming output).
227
func (l *Logger) PrintRaw(format string, args ...any) {
1✔
228
        msg := fmt.Sprintf(format, args...)
1✔
229
        l.writeFile("%s", msg)
1✔
230
        l.writeStdout("%s", msg)
1✔
231
}
1✔
232

233
// PrintSection writes a section header without timestamp in yellow.
234
// format: "\n--- {label} ---\n"
235
func (l *Logger) PrintSection(section processor.Section) {
1✔
236
        header := fmt.Sprintf("\n--- %s ---\n", section.Label)
1✔
237
        l.writeFile("%s", header)
1✔
238
        l.writeStdout("%s", l.colors.Warn().Sprint(header))
1✔
239
}
1✔
240

241
// getTerminalWidth returns terminal width, using COLUMNS env var or syscall.
242
// Defaults to 80 if detection fails. Returns content width (total - 20 for timestamp).
243
func getTerminalWidth() int {
4✔
244
        const minWidth = 40
4✔
245

4✔
246
        // try COLUMNS env var first
4✔
247
        if cols := os.Getenv("COLUMNS"); cols != "" {
7✔
248
                if w, err := strconv.Atoi(cols); err == nil && w > 0 {
5✔
249
                        contentWidth := w - 20 // leave room for timestamp prefix
2✔
250
                        if contentWidth < minWidth {
3✔
251
                                return minWidth
1✔
252
                        }
1✔
253
                        return contentWidth
1✔
254
                }
255
        }
256

257
        // try terminal syscall
258
        if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
2✔
259
                contentWidth := w - 20
×
260
                if contentWidth < minWidth {
×
261
                        return minWidth
×
262
                }
×
263
                return contentWidth
×
264
        }
265

266
        return 80 - 20 // default 80 columns minus timestamp
2✔
267
}
268

269
// wrapText wraps text to specified width, breaking on word boundaries.
270
func wrapText(text string, width int) string {
6✔
271
        if width <= 0 || len(text) <= width {
10✔
272
                return text
4✔
273
        }
4✔
274

275
        var result strings.Builder
2✔
276
        words := strings.Fields(text)
2✔
277
        lineLen := 0
2✔
278

2✔
279
        for i, word := range words {
11✔
280
                wordLen := len(word)
9✔
281

9✔
282
                if i == 0 {
11✔
283
                        result.WriteString(word)
2✔
284
                        lineLen = wordLen
2✔
285
                        continue
2✔
286
                }
287

288
                // check if word fits on current line
289
                if lineLen+1+wordLen <= width {
12✔
290
                        result.WriteString(" ")
5✔
291
                        result.WriteString(word)
5✔
292
                        lineLen += 1 + wordLen
5✔
293
                } else {
7✔
294
                        // start new line
2✔
295
                        result.WriteString("\n")
2✔
296
                        result.WriteString(word)
2✔
297
                        lineLen = wordLen
2✔
298
                }
2✔
299
        }
300

301
        return result.String()
2✔
302
}
303

304
// PrintAligned writes text with timestamp on each line, suppressing empty lines.
305
func (l *Logger) PrintAligned(text string) {
2✔
306
        if text == "" {
3✔
307
                return
1✔
308
        }
1✔
309

310
        // trim trailing newlines to avoid extra blank lines
311
        text = strings.TrimRight(text, "\n")
1✔
312
        if text == "" {
1✔
313
                return
×
314
        }
×
315

316
        phaseColor := l.colors.ForPhase(l.phase)
1✔
317

1✔
318
        // wrap text to terminal width
1✔
319
        width := getTerminalWidth()
1✔
320

1✔
321
        // split into lines, wrap each long line, then process
1✔
322
        var lines []string
1✔
323
        for line := range strings.SplitSeq(text, "\n") {
4✔
324
                if len(line) > width {
3✔
325
                        wrapped := wrapText(line, width)
×
326
                        for wrappedLine := range strings.SplitSeq(wrapped, "\n") {
×
327
                                lines = append(lines, wrappedLine)
×
328
                        }
×
329
                } else {
3✔
330
                        lines = append(lines, line)
3✔
331
                }
3✔
332
        }
333

334
        for _, line := range lines {
4✔
335
                if line == "" {
3✔
336
                        continue // skip empty lines
×
337
                }
338

339
                // add indent for list items
340
                displayLine := formatListItem(line)
3✔
341

3✔
342
                // timestamp each line
3✔
343
                timestamp := time.Now().Format(timestampFormat)
3✔
344
                tsPrefix := l.colors.Timestamp().Sprintf("[%s]", timestamp)
3✔
345
                l.writeFile("[%s] %s\n", timestamp, displayLine)
3✔
346

3✔
347
                // use red for signal lines
3✔
348
                lineColor := phaseColor
3✔
349

3✔
350
                // format signal lines nicely
3✔
351
                if sig := extractSignal(line); sig != "" {
3✔
352
                        displayLine = sig
×
353
                        lineColor = l.colors.Signal()
×
354
                }
×
355

356
                l.writeStdout("%s %s\n", tsPrefix, lineColor.Sprint(displayLine))
3✔
357
        }
358
}
359

360
// extractSignal extracts signal name from <<<RALPHEX:SIGNAL_NAME>>> format.
361
// returns empty string if no signal found.
362
func extractSignal(line string) string {
12✔
363
        const prefix = "<<<RALPHEX:"
12✔
364
        const suffix = ">>>"
12✔
365

12✔
366
        start := strings.Index(line, prefix)
12✔
367
        if start == -1 {
18✔
368
                return ""
6✔
369
        }
6✔
370

371
        end := strings.Index(line[start:], suffix)
6✔
372
        if end == -1 {
7✔
373
                return ""
1✔
374
        }
1✔
375

376
        return line[start+len(prefix) : start+end]
5✔
377
}
378

379
// formatListItem adds 2-space indent for list items (numbered or bulleted).
380
// detects patterns like "1. ", "12. ", "- ", "* " at line start.
381
func formatListItem(line string) string {
9✔
382
        trimmed := strings.TrimLeft(line, " \t")
9✔
383
        if trimmed == line { // no leading whitespace
17✔
384
                if isListItem(trimmed) {
12✔
385
                        return "  " + line
4✔
386
                }
4✔
387
        }
388
        return line
5✔
389
}
390

391
// isListItem returns true if line starts with a list marker.
392
func isListItem(line string) bool {
19✔
393
        // check for "- " or "* " (bullet lists)
19✔
394
        if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
23✔
395
                return true
4✔
396
        }
4✔
397
        // check for numbered lists like "1. ", "12. ", "123. "
398
        for i, r := range line {
40✔
399
                if r >= '0' && r <= '9' {
36✔
400
                        continue
11✔
401
                }
402
                if r == '.' && i > 0 && i < len(line)-1 && line[i+1] == ' ' {
19✔
403
                        return true
5✔
404
                }
5✔
405
                break
9✔
406
        }
407
        return false
10✔
408
}
409

410
// Error writes an error message in red.
411
func (l *Logger) Error(format string, args ...any) {
1✔
412
        msg := fmt.Sprintf(format, args...)
1✔
413
        timestamp := time.Now().Format(timestampFormat)
1✔
414

1✔
415
        l.writeFile("[%s] ERROR: %s\n", timestamp, msg)
1✔
416

1✔
417
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
418
        errStr := l.colors.Error().Sprintf("ERROR: %s", msg)
1✔
419
        l.writeStdout("%s %s\n", tsStr, errStr)
1✔
420
}
1✔
421

422
// Warn writes a warning message in yellow.
423
func (l *Logger) Warn(format string, args ...any) {
1✔
424
        msg := fmt.Sprintf(format, args...)
1✔
425
        timestamp := time.Now().Format(timestampFormat)
1✔
426

1✔
427
        l.writeFile("[%s] WARN: %s\n", timestamp, msg)
1✔
428

1✔
429
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
430
        warnStr := l.colors.Warn().Sprintf("WARN: %s", msg)
1✔
431
        l.writeStdout("%s %s\n", tsStr, warnStr)
1✔
432
}
1✔
433

434
// Elapsed returns formatted elapsed time since start.
435
func (l *Logger) Elapsed() string {
18✔
436
        return humanize.RelTime(l.startTime, time.Now(), "", "")
18✔
437
}
18✔
438

439
// Close writes footer, releases the file lock, and closes the progress file.
440
func (l *Logger) Close() error {
17✔
441
        if l.file == nil {
17✔
442
                return nil
×
443
        }
×
444

445
        l.writeFile("\n%s\n", strings.Repeat("-", 60))
17✔
446
        l.writeFile("Completed: %s (%s)\n", time.Now().Format("2006-01-02 15:04:05"), l.Elapsed())
17✔
447

17✔
448
        // release file lock before closing
17✔
449
        _ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
17✔
450
        unregisterActiveLock(l.file.Name())
17✔
451

17✔
452
        if err := l.file.Close(); err != nil {
17✔
453
                return fmt.Errorf("close progress file: %w", err)
×
454
        }
×
455
        return nil
17✔
456
}
457

458
func (l *Logger) writeFile(format string, args ...any) {
149✔
459
        if l.file != nil {
298✔
460
                fmt.Fprintf(l.file, format, args...)
149✔
461
        }
149✔
462
}
463

464
func (l *Logger) writeStdout(format string, args ...any) {
13✔
465
        fmt.Fprintf(l.stdout, format, args...)
13✔
466
}
13✔
467

468
// getProgressFilename returns progress file path based on plan and mode.
469
func progressFilename(planFile, mode string) string {
24✔
470
        if planFile != "" {
31✔
471
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
7✔
472
                switch mode {
7✔
473
                case "codex-only":
2✔
474
                        return fmt.Sprintf("progress-%s-codex.txt", stem)
2✔
475
                case "review":
2✔
476
                        return fmt.Sprintf("progress-%s-review.txt", stem)
2✔
477
                default:
3✔
478
                        return fmt.Sprintf("progress-%s.txt", stem)
3✔
479
                }
480
        }
481

482
        switch mode {
17✔
483
        case "codex-only":
2✔
484
                return "progress-codex.txt"
2✔
485
        case "review":
2✔
486
                return "progress-review.txt"
2✔
487
        default:
13✔
488
                return "progress.txt"
13✔
489
        }
490
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc