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

umputun / ralphex / 21302158116

23 Jan 2026 09:42PM UTC coverage: 79.365% (-0.03%) from 79.395%
21302158116

Pull #17

github

melonamin
refactor: simplify values.go WatchDirs to minimal inline parsing

Reverted helper functions and constants that were out of scope for
WatchDirs feature. Now uses same GetKey inline pattern as existing
config parsing code.
Pull Request #17: feat: add web dashboard with real-time streaming and multi-session support

1478 of 1874 new or added lines in 19 files covered. (78.87%)

9 existing lines in 2 files now uncovered.

3077 of 3877 relevant lines covered (79.37%)

223.36 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