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

umputun / ralphex / 21343412266

26 Jan 2026 01:36AM UTC coverage: 78.401% (+0.2%) from 78.234%
21343412266

push

github

umputun
test: add tests for plan mode functions

improve coverage for plan creation mode:
- cmd/ralphex: add tests for setupRunnerLogger, getCurrentBranch,
  setupGitForExecution, handlePostExecution, validateFlags,
  printStartupInfo, findRecentPlan
- pkg/processor: add direct test for buildPlanPrompt

coverage improved from 37.6% to 50.4% for cmd/ralphex

3245 of 4139 relevant lines covered (78.4%)

67.54 hits per line

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

92.05
/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
        PhasePlan       = processor.PhasePlan
34
)
35

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

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

20✔
66
        c.phases[PhaseTask] = c.task
20✔
67
        c.phases[PhaseReview] = c.review
20✔
68
        c.phases[PhaseCodex] = c.codex
20✔
69
        c.phases[PhaseClaudeEval] = c.claudeEval
20✔
70
        c.phases[PhasePlan] = c.task // plan phase uses task color (green)
20✔
71

20✔
72
        return c
20✔
73
}
20✔
74

75
// parseColorOrPanic parses RGB string and returns color, panics on invalid input.
76
func parseColorOrPanic(s, name string) *color.Color {
182✔
77
        parseRGB := func(s string) []int {
364✔
78
                if s == "" {
184✔
79
                        return nil
2✔
80
                }
2✔
81
                parts := strings.Split(s, ",")
180✔
82
                if len(parts) != 3 {
185✔
83
                        return nil
5✔
84
                }
5✔
85

86
                // parse each component
87
                r, err := strconv.Atoi(strings.TrimSpace(parts[0]))
175✔
88
                if err != nil || r < 0 || r > 255 {
178✔
89
                        return nil
3✔
90
                }
3✔
91
                g, err := strconv.Atoi(strings.TrimSpace(parts[1]))
172✔
92
                if err != nil || g < 0 || g > 255 {
175✔
93
                        return nil
3✔
94
                }
3✔
95
                b, err := strconv.Atoi(strings.TrimSpace(parts[2]))
169✔
96
                if err != nil || b < 0 || b > 255 {
172✔
97
                        return nil
3✔
98
                }
3✔
99
                return []int{r, g, b}
166✔
100
        }
101

102
        rgb := parseRGB(s)
182✔
103
        if rgb == nil {
198✔
104
                panic(fmt.Sprintf("invalid color_%s value: %q", name, s))
16✔
105
        }
106
        return color.RGB(rgb[0], rgb[1], rgb[2])
166✔
107
}
108

109
// Info returns the info color for informational messages.
110
func (c *Colors) Info() *color.Color { return c.info }
5✔
111

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

115
// Timestamp returns the timestamp color.
116
func (c *Colors) Timestamp() *color.Color { return c.timestamp }
15✔
117

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

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

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

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

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

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

153
        progressPath := progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
21✔
154

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

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

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

21✔
175
        l := &Logger{
21✔
176
                file:      f,
21✔
177
                stdout:    os.Stdout,
21✔
178
                startTime: time.Now(),
21✔
179
                phase:     PhaseTask,
21✔
180
                colors:    colors,
21✔
181
        }
21✔
182

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

21✔
195
        return l, nil
21✔
196
}
197

198
// Path returns the progress file path.
199
func (l *Logger) Path() string {
25✔
200
        if l.file == nil {
25✔
201
                return ""
×
202
        }
×
203
        return l.file.Name()
25✔
204
}
205

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

211
// timestampFormat is the format for timestamps: YY-MM-DD HH:MM:SS
212
const timestampFormat = "06-01-02 15:04:05"
213

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

6✔
219
        // write to file without color
6✔
220
        l.writeFile("[%s] %s\n", timestamp, msg)
6✔
221

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

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

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

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

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

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

269
        return 80 - 20 // default 80 columns minus timestamp
2✔
270
}
271

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

278
        var result strings.Builder
2✔
279
        words := strings.Fields(text)
2✔
280
        lineLen := 0
2✔
281

2✔
282
        for i, word := range words {
11✔
283
                wordLen := len(word)
9✔
284

9✔
285
                if i == 0 {
11✔
286
                        result.WriteString(word)
2✔
287
                        lineLen = wordLen
2✔
288
                        continue
2✔
289
                }
290

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

304
        return result.String()
2✔
305
}
306

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

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

319
        phaseColor := l.colors.ForPhase(l.phase)
1✔
320

1✔
321
        // wrap text to terminal width
1✔
322
        width := getTerminalWidth()
1✔
323

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

337
        for _, line := range lines {
4✔
338
                if line == "" {
3✔
339
                        continue // skip empty lines
×
340
                }
341

342
                // add indent for list items
343
                displayLine := formatListItem(line)
3✔
344

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

3✔
350
                // use red for signal lines
3✔
351
                lineColor := phaseColor
3✔
352

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

359
                l.writeStdout("%s %s\n", tsPrefix, lineColor.Sprint(displayLine))
3✔
360
        }
361
}
362

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

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

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

379
        return line[start+len(prefix) : start+end]
5✔
380
}
381

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

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

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

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

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

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

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

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

437
// LogQuestion logs a question and its options for plan creation mode.
438
// format: QUESTION: <question>\n OPTIONS: <opt1>, <opt2>, ...
439
func (l *Logger) LogQuestion(question string, options []string) {
1✔
440
        timestamp := time.Now().Format(timestampFormat)
1✔
441

1✔
442
        l.writeFile("[%s] QUESTION: %s\n", timestamp, question)
1✔
443
        l.writeFile("[%s] OPTIONS: %s\n", timestamp, strings.Join(options, ", "))
1✔
444

1✔
445
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
446
        questionStr := l.colors.Info().Sprintf("QUESTION: %s", question)
1✔
447
        optionsStr := l.colors.Info().Sprintf("OPTIONS: %s", strings.Join(options, ", "))
1✔
448
        l.writeStdout("%s %s\n", tsStr, questionStr)
1✔
449
        l.writeStdout("%s %s\n", tsStr, optionsStr)
1✔
450
}
1✔
451

452
// LogAnswer logs the user's answer for plan creation mode.
453
// format: ANSWER: <answer>
454
func (l *Logger) LogAnswer(answer string) {
1✔
455
        timestamp := time.Now().Format(timestampFormat)
1✔
456

1✔
457
        l.writeFile("[%s] ANSWER: %s\n", timestamp, answer)
1✔
458

1✔
459
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
460
        answerStr := l.colors.Info().Sprintf("ANSWER: %s", answer)
1✔
461
        l.writeStdout("%s %s\n", tsStr, answerStr)
1✔
462
}
1✔
463

464
// Elapsed returns formatted elapsed time since start.
465
func (l *Logger) Elapsed() string {
22✔
466
        return humanize.RelTime(l.startTime, time.Now(), "", "")
22✔
467
}
22✔
468

469
// Close writes footer, releases the file lock, and closes the progress file.
470
func (l *Logger) Close() error {
21✔
471
        if l.file == nil {
21✔
472
                return nil
×
473
        }
×
474

475
        l.writeFile("\n%s\n", strings.Repeat("-", 60))
21✔
476
        l.writeFile("Completed: %s (%s)\n", time.Now().Format("2006-01-02 15:04:05"), l.Elapsed())
21✔
477

21✔
478
        // release file lock before closing
21✔
479
        _ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
21✔
480
        unregisterActiveLock(l.file.Name())
21✔
481

21✔
482
        if err := l.file.Close(); err != nil {
21✔
483
                return fmt.Errorf("close progress file: %w", err)
×
484
        }
×
485
        return nil
21✔
486
}
487

488
func (l *Logger) writeFile(format string, args ...any) {
184✔
489
        if l.file != nil {
368✔
490
                fmt.Fprintf(l.file, format, args...)
184✔
491
        }
184✔
492
}
493

494
func (l *Logger) writeStdout(format string, args ...any) {
16✔
495
        fmt.Fprintf(l.stdout, format, args...)
16✔
496
}
16✔
497

498
// getProgressFilename returns progress file path based on plan and mode.
499
func progressFilename(planFile, planDescription, mode string) string {
32✔
500
        // plan mode uses sanitized plan description
32✔
501
        if mode == "plan" && planDescription != "" {
38✔
502
                sanitized := sanitizePlanName(planDescription)
6✔
503
                return fmt.Sprintf("progress-plan-%s.txt", sanitized)
6✔
504
        }
6✔
505

506
        if planFile != "" {
33✔
507
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
7✔
508
                switch mode {
7✔
509
                case "codex-only":
2✔
510
                        return fmt.Sprintf("progress-%s-codex.txt", stem)
2✔
511
                case "review":
2✔
512
                        return fmt.Sprintf("progress-%s-review.txt", stem)
2✔
513
                default:
3✔
514
                        return fmt.Sprintf("progress-%s.txt", stem)
3✔
515
                }
516
        }
517

518
        switch mode {
19✔
519
        case "codex-only":
2✔
520
                return "progress-codex.txt"
2✔
521
        case "review":
2✔
522
                return "progress-review.txt"
2✔
523
        case "plan":
2✔
524
                return "progress-plan.txt"
2✔
525
        default:
13✔
526
                return "progress.txt"
13✔
527
        }
528
}
529

530
// sanitizePlanName converts plan description to a safe filename component.
531
// replaces spaces with dashes, removes special characters, and limits length.
532
func sanitizePlanName(desc string) string {
17✔
533
        // lowercase and replace spaces with dashes
17✔
534
        result := strings.ToLower(desc)
17✔
535
        result = strings.ReplaceAll(result, " ", "-")
17✔
536

17✔
537
        // keep only alphanumeric and dashes
17✔
538
        var clean strings.Builder
17✔
539
        for _, r := range result {
321✔
540
                if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
597✔
541
                        clean.WriteRune(r)
293✔
542
                }
293✔
543
        }
544
        result = clean.String()
17✔
545

17✔
546
        // collapse multiple dashes
17✔
547
        for strings.Contains(result, "--") {
20✔
548
                result = strings.ReplaceAll(result, "--", "-")
3✔
549
        }
3✔
550

551
        // trim leading/trailing dashes
552
        result = strings.Trim(result, "-")
17✔
553

17✔
554
        // limit length to 50 characters
17✔
555
        if len(result) > 50 {
19✔
556
                result = result[:50]
2✔
557
                // don't end with a dash
2✔
558
                result = strings.TrimRight(result, "-")
2✔
559
        }
2✔
560

561
        if result == "" {
19✔
562
                return "unnamed"
2✔
563
        }
2✔
564
        return result
15✔
565
}
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