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

umputun / ralphex / 22169734258

19 Feb 2026 05:09AM UTC coverage: 81.679% (-0.06%) from 81.734%
22169734258

Pull #134

github

umputun
docs: update CLAUDE.md with fresh start behavior and move plan to completed
Pull Request #134: fix: detect completed progress files and start fresh on reuse

18 of 25 new or added lines in 1 file covered. (72.0%)

2 existing lines in 1 file now uncovered.

5372 of 6577 relevant lines covered (81.68%)

187.99 hits per line

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

90.65
/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
        "time"
12

13
        "github.com/fatih/color"
14
        "golang.org/x/term"
15

16
        "github.com/umputun/ralphex/pkg/config"
17
        "github.com/umputun/ralphex/pkg/status"
18
)
19

20
// Colors holds all color configuration for output formatting.
21
// use NewColors to create from config.ColorConfig.
22
type Colors struct {
23
        task       *color.Color
24
        review     *color.Color
25
        codex      *color.Color
26
        claudeEval *color.Color
27
        warn       *color.Color
28
        err        *color.Color
29
        signal     *color.Color
30
        timestamp  *color.Color
31
        info       *color.Color
32
        phases     map[status.Phase]*color.Color
33
}
34

35
// NewColors creates Colors from config.ColorConfig.
36
// all colors must be provided - use config with embedded defaults fallback.
37
// panics if any color value is invalid (configuration error).
38
func NewColors(cfg config.ColorConfig) *Colors {
27✔
39
        c := &Colors{phases: make(map[status.Phase]*color.Color)}
27✔
40
        c.task = parseColorOrPanic(cfg.Task, "task")
27✔
41
        c.review = parseColorOrPanic(cfg.Review, "review")
27✔
42
        c.codex = parseColorOrPanic(cfg.Codex, "codex")
27✔
43
        c.claudeEval = parseColorOrPanic(cfg.ClaudeEval, "claude_eval")
27✔
44
        c.warn = parseColorOrPanic(cfg.Warn, "warn")
27✔
45
        c.err = parseColorOrPanic(cfg.Error, "error")
27✔
46
        c.signal = parseColorOrPanic(cfg.Signal, "signal")
27✔
47
        c.timestamp = parseColorOrPanic(cfg.Timestamp, "timestamp")
27✔
48
        c.info = parseColorOrPanic(cfg.Info, "info")
27✔
49

27✔
50
        c.phases[status.PhaseTask] = c.task
27✔
51
        c.phases[status.PhaseReview] = c.review
27✔
52
        c.phases[status.PhaseCodex] = c.codex
27✔
53
        c.phases[status.PhaseClaudeEval] = c.claudeEval
27✔
54
        c.phases[status.PhasePlan] = c.task     // plan phase uses task color (green)
27✔
55
        c.phases[status.PhaseFinalize] = c.task // finalize phase uses task color (green)
27✔
56

27✔
57
        return c
27✔
58
}
27✔
59

60
// parseColorOrPanic parses RGB string and returns color, panics on invalid input.
61
func parseColorOrPanic(s, name string) *color.Color {
245✔
62
        parseRGB := func(s string) []int {
490✔
63
                if s == "" {
247✔
64
                        return nil
2✔
65
                }
2✔
66
                parts := strings.Split(s, ",")
243✔
67
                if len(parts) != 3 {
248✔
68
                        return nil
5✔
69
                }
5✔
70

71
                // parse each component
72
                r, err := strconv.Atoi(strings.TrimSpace(parts[0]))
238✔
73
                if err != nil || r < 0 || r > 255 {
241✔
74
                        return nil
3✔
75
                }
3✔
76
                g, err := strconv.Atoi(strings.TrimSpace(parts[1]))
235✔
77
                if err != nil || g < 0 || g > 255 {
238✔
78
                        return nil
3✔
79
                }
3✔
80
                b, err := strconv.Atoi(strings.TrimSpace(parts[2]))
232✔
81
                if err != nil || b < 0 || b > 255 {
235✔
82
                        return nil
3✔
83
                }
3✔
84
                return []int{r, g, b}
229✔
85
        }
86

87
        rgb := parseRGB(s)
245✔
88
        if rgb == nil {
261✔
89
                panic(fmt.Sprintf("invalid color_%s value: %q", name, s))
16✔
90
        }
91
        return color.RGB(rgb[0], rgb[1], rgb[2])
229✔
92
}
93

94
// Info returns the info color for informational messages.
95
func (c *Colors) Info() *color.Color { return c.info }
8✔
96

97
// ForPhase returns the color for the given execution phase.
98
func (c *Colors) ForPhase(p status.Phase) *color.Color {
19✔
99
        if clr, ok := c.phases[p]; ok {
31✔
100
                return clr
12✔
101
        }
12✔
102
        return c.task // fallback to task color for unknown/empty phase
7✔
103
}
104

105
// Timestamp returns the timestamp color.
106
func (c *Colors) Timestamp() *color.Color { return c.timestamp }
21✔
107

108
// Warn returns the warning color.
109
func (c *Colors) Warn() *color.Color { return c.warn }
4✔
110

111
// Error returns the error color.
112
func (c *Colors) Error() *color.Color { return c.err }
3✔
113

114
// Signal returns the signal color.
115
func (c *Colors) Signal() *color.Color { return c.signal }
2✔
116

117
// Logger writes timestamped output to both file and stdout.
118
type Logger struct {
119
        file      *os.File
120
        stdout    io.Writer
121
        startTime time.Time
122
        holder    *status.PhaseHolder
123
        colors    *Colors
124
}
125

126
// Config holds logger configuration.
127
type Config struct {
128
        PlanFile        string // plan filename (used to derive progress filename)
129
        PlanDescription string // plan description for plan mode (used for filename)
130
        Mode            string // execution mode: full, review, codex-only, plan
131
        Branch          string // current git branch
132
        NoColor         bool   // disable color output (sets color.NoColor globally)
133
}
134

135
// NewLogger creates a logger writing to both a progress file and stdout.
136
// if the progress file already exists with a completion footer, it is truncated
137
// and a fresh header is written. if the file exists without a completion footer
138
// (interrupted run), existing log is preserved and a restart separator is written.
139
// colors must be provided (created via NewColors from config).
140
// holder is the shared PhaseHolder for reading the current execution phase.
141
func NewLogger(cfg Config, colors *Colors, holder *status.PhaseHolder) (*Logger, error) {
30✔
142
        // set global color setting
30✔
143
        if cfg.NoColor {
42✔
144
                color.NoColor = true
12✔
145
        }
12✔
146

147
        progressPath := progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
30✔
148

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

156
        f, err := os.OpenFile(progressPath, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path derived from plan filename
30✔
157
        if err != nil {
30✔
158
                return nil, fmt.Errorf("open progress file: %w", err)
×
159
        }
×
160

161
        // acquire exclusive lock on progress file to signal active session.
162
        // the lock is held for the duration of execution and released on Close().
163
        // lock MUST be acquired before stat to avoid TOCTOU race:
164
        // without this ordering, a concurrent process could stat size==0, block on lock,
165
        // then write a full header instead of restart separator after another process already wrote content.
166
        if lockErr := lockFile(f); lockErr != nil {
30✔
167
                f.Close()
×
168
                return nil, fmt.Errorf("acquire file lock: %w", lockErr)
×
169
        }
×
170
        registerActiveLock(f.Name())
30✔
171

30✔
172
        // check if file already has content (restart case) — safe after lock acquisition
30✔
173
        fi, err := f.Stat()
30✔
174
        if err != nil {
30✔
175
                _ = unlockFile(f)
×
176
                unregisterActiveLock(f.Name())
×
177
                f.Close()
×
178
                return nil, fmt.Errorf("stat progress file: %w", err)
×
179
        }
×
180

181
        restart := fi.Size() > 0
30✔
182

30✔
183
        // if the file has a completion footer from a previous run, truncate and start fresh.
30✔
184
        // this prevents mixing unrelated content when the same plan filename is reused.
30✔
185
        // reads from the locked fd directly to avoid TOCTOU path-vs-inode mismatch.
30✔
186
        if restart && isProgressCompleted(f, fi.Size()) {
31✔
187
                if tErr := f.Truncate(0); tErr != nil {
1✔
NEW
188
                        _ = unlockFile(f)
×
NEW
189
                        unregisterActiveLock(f.Name())
×
NEW
190
                        f.Close()
×
NEW
191
                        return nil, fmt.Errorf("truncate completed progress file: %w", tErr)
×
NEW
192
                }
×
193
                restart = false
1✔
194
        }
195

196
        l := &Logger{
30✔
197
                file:      f,
30✔
198
                stdout:    os.Stdout,
30✔
199
                startTime: time.Now(),
30✔
200
                holder:    holder,
30✔
201
                colors:    colors,
30✔
202
        }
30✔
203

30✔
204
        if restart {
31✔
205
                // write restart separator (matches sectionRegex in web parser)
1✔
206
                l.writeFile("\n\n--- restarted at %s ---\n\n", time.Now().Format("2006-01-02 15:04:05"))
1✔
207
        } else {
30✔
208
                // write full header for new file
29✔
209
                planStr := cfg.PlanFile
29✔
210
                if planStr == "" {
51✔
211
                        planStr = "(no plan - review only)"
22✔
212
                }
22✔
213
                l.writeFile("# Ralphex Progress Log\n")
29✔
214
                l.writeFile("Plan: %s\n", planStr)
29✔
215
                l.writeFile("Branch: %s\n", cfg.Branch)
29✔
216
                l.writeFile("Mode: %s\n", cfg.Mode)
29✔
217
                l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
29✔
218
                l.writeFile("%s\n\n", strings.Repeat("-", 60))
29✔
219
        }
220

221
        return l, nil
30✔
222
}
223

224
// Path returns the progress file path.
225
func (l *Logger) Path() string {
43✔
226
        if l.file == nil {
43✔
227
                return ""
×
228
        }
×
229
        return l.file.Name()
43✔
230
}
231

232
// timestampFormat is the format for timestamps: YY-MM-DD HH:MM:SS
233
const timestampFormat = "06-01-02 15:04:05"
234

235
// Print writes a timestamped message to both file and stdout.
236
func (l *Logger) Print(format string, args ...any) {
10✔
237
        msg := fmt.Sprintf(format, args...)
10✔
238
        timestamp := time.Now().Format(timestampFormat)
10✔
239

10✔
240
        // write to file without color
10✔
241
        l.writeFile("[%s] %s\n", timestamp, msg)
10✔
242

10✔
243
        // write to stdout with color
10✔
244
        phaseColor := l.colors.ForPhase(l.holder.Get())
10✔
245
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
10✔
246
        msgStr := phaseColor.Sprint(msg)
10✔
247
        l.writeStdout("%s %s\n", tsStr, msgStr)
10✔
248
}
10✔
249

250
// PrintRaw writes without timestamp (for streaming output).
251
func (l *Logger) PrintRaw(format string, args ...any) {
1✔
252
        msg := fmt.Sprintf(format, args...)
1✔
253
        l.writeFile("%s", msg)
1✔
254
        l.writeStdout("%s", msg)
1✔
255
}
1✔
256

257
// PrintSection writes a section header without timestamp in yellow.
258
// format: "\n--- {label} ---\n"
259
func (l *Logger) PrintSection(section status.Section) {
1✔
260
        header := fmt.Sprintf("\n--- %s ---\n", section.Label)
1✔
261
        l.writeFile("%s", header)
1✔
262
        l.writeStdout("%s", l.colors.Warn().Sprint(header))
1✔
263
}
1✔
264

265
// getTerminalWidth returns terminal width, using COLUMNS env var or syscall.
266
// Defaults to 80 if detection fails. Returns content width (total - 20 for timestamp).
267
func getTerminalWidth() int {
4✔
268
        const minWidth = 40
4✔
269

4✔
270
        // try COLUMNS env var first
4✔
271
        if cols := os.Getenv("COLUMNS"); cols != "" {
7✔
272
                if w, err := strconv.Atoi(cols); err == nil && w > 0 {
5✔
273
                        contentWidth := w - 20 // leave room for timestamp prefix
2✔
274
                        if contentWidth < minWidth {
3✔
275
                                return minWidth
1✔
276
                        }
1✔
277
                        return contentWidth
1✔
278
                }
279
        }
280

281
        // try terminal syscall
282
        if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
2✔
283
                contentWidth := w - 20
×
284
                if contentWidth < minWidth {
×
285
                        return minWidth
×
286
                }
×
287
                return contentWidth
×
288
        }
289

290
        return 80 - 20 // default 80 columns minus timestamp
2✔
291
}
292

293
// wrapText wraps text to specified width, breaking on word boundaries.
294
func wrapText(text string, width int) string {
6✔
295
        if width <= 0 || len(text) <= width {
10✔
296
                return text
4✔
297
        }
4✔
298

299
        var result strings.Builder
2✔
300
        words := strings.Fields(text)
2✔
301
        lineLen := 0
2✔
302

2✔
303
        for i, word := range words {
11✔
304
                wordLen := len(word)
9✔
305

9✔
306
                if i == 0 {
11✔
307
                        result.WriteString(word)
2✔
308
                        lineLen = wordLen
2✔
309
                        continue
2✔
310
                }
311

312
                // check if word fits on current line
313
                if lineLen+1+wordLen <= width {
12✔
314
                        result.WriteString(" ")
5✔
315
                        result.WriteString(word)
5✔
316
                        lineLen += 1 + wordLen
5✔
317
                } else {
7✔
318
                        // start new line
2✔
319
                        result.WriteString("\n")
2✔
320
                        result.WriteString(word)
2✔
321
                        lineLen = wordLen
2✔
322
                }
2✔
323
        }
324

325
        return result.String()
2✔
326
}
327

328
// PrintAligned writes text with timestamp on each line, suppressing empty lines.
329
func (l *Logger) PrintAligned(text string) {
2✔
330
        if text == "" {
3✔
331
                return
1✔
332
        }
1✔
333

334
        // trim trailing newlines to avoid extra blank lines
335
        text = strings.TrimRight(text, "\n")
1✔
336
        if text == "" {
1✔
337
                return
×
338
        }
×
339

340
        phaseColor := l.colors.ForPhase(l.holder.Get())
1✔
341

1✔
342
        // wrap text to terminal width
1✔
343
        width := getTerminalWidth()
1✔
344

1✔
345
        // split into lines, wrap each long line, then process
1✔
346
        var lines []string
1✔
347
        for line := range strings.SplitSeq(text, "\n") {
4✔
348
                if len(line) > width {
3✔
349
                        wrapped := wrapText(line, width)
×
350
                        for wrappedLine := range strings.SplitSeq(wrapped, "\n") {
×
351
                                lines = append(lines, wrappedLine)
×
352
                        }
×
353
                } else {
3✔
354
                        lines = append(lines, line)
3✔
355
                }
3✔
356
        }
357

358
        for _, line := range lines {
4✔
359
                if line == "" {
3✔
360
                        continue // skip empty lines
×
361
                }
362

363
                // add indent for list items
364
                displayLine := formatListItem(line)
3✔
365

3✔
366
                // timestamp each line
3✔
367
                timestamp := time.Now().Format(timestampFormat)
3✔
368
                tsPrefix := l.colors.Timestamp().Sprintf("[%s]", timestamp)
3✔
369
                l.writeFile("[%s] %s\n", timestamp, displayLine)
3✔
370

3✔
371
                // use red for signal lines
3✔
372
                lineColor := phaseColor
3✔
373

3✔
374
                // format signal lines nicely
3✔
375
                if sig := extractSignal(line); sig != "" {
3✔
376
                        displayLine = sig
×
377
                        lineColor = l.colors.Signal()
×
378
                }
×
379

380
                l.writeStdout("%s %s\n", tsPrefix, lineColor.Sprint(displayLine))
3✔
381
        }
382
}
383

384
// extractSignal extracts signal name from <<<RALPHEX:SIGNAL_NAME>>> format.
385
// returns empty string if no signal found.
386
func extractSignal(line string) string {
12✔
387
        const prefix = "<<<RALPHEX:"
12✔
388
        const suffix = ">>>"
12✔
389

12✔
390
        start := strings.Index(line, prefix)
12✔
391
        if start == -1 {
18✔
392
                return ""
6✔
393
        }
6✔
394

395
        end := strings.Index(line[start:], suffix)
6✔
396
        if end == -1 {
7✔
397
                return ""
1✔
398
        }
1✔
399

400
        return line[start+len(prefix) : start+end]
5✔
401
}
402

403
// formatListItem adds 2-space indent for list items (numbered or bulleted).
404
// detects patterns like "1. ", "12. ", "- ", "* " at line start.
405
func formatListItem(line string) string {
9✔
406
        trimmed := strings.TrimLeft(line, " \t")
9✔
407
        if trimmed == line { // no leading whitespace
17✔
408
                if isListItem(trimmed) {
12✔
409
                        return "  " + line
4✔
410
                }
4✔
411
        }
412
        return line
5✔
413
}
414

415
// isListItem returns true if line starts with a list marker.
416
func isListItem(line string) bool {
19✔
417
        // check for "- " or "* " (bullet lists)
19✔
418
        if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
23✔
419
                return true
4✔
420
        }
4✔
421
        // check for numbered lists like "1. ", "12. ", "123. "
422
        for i, r := range line {
40✔
423
                if r >= '0' && r <= '9' {
36✔
424
                        continue
11✔
425
                }
426
                if r == '.' && i > 0 && i < len(line)-1 && line[i+1] == ' ' {
19✔
427
                        return true
5✔
428
                }
5✔
429
                break
9✔
430
        }
431
        return false
10✔
432
}
433

434
// Error writes an error message in red.
435
func (l *Logger) Error(format string, args ...any) {
1✔
436
        msg := fmt.Sprintf(format, args...)
1✔
437
        timestamp := time.Now().Format(timestampFormat)
1✔
438

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

1✔
441
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
442
        errStr := l.colors.Error().Sprintf("ERROR: %s", msg)
1✔
443
        l.writeStdout("%s %s\n", tsStr, errStr)
1✔
444
}
1✔
445

446
// Warn writes a warning message in yellow.
447
func (l *Logger) Warn(format string, args ...any) {
1✔
448
        msg := fmt.Sprintf(format, args...)
1✔
449
        timestamp := time.Now().Format(timestampFormat)
1✔
450

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

1✔
453
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
454
        warnStr := l.colors.Warn().Sprintf("WARN: %s", msg)
1✔
455
        l.writeStdout("%s %s\n", tsStr, warnStr)
1✔
456
}
1✔
457

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

1✔
463
        l.writeFile("[%s] QUESTION: %s\n", timestamp, question)
1✔
464
        l.writeFile("[%s] OPTIONS: %s\n", timestamp, strings.Join(options, ", "))
1✔
465

1✔
466
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
467
        questionStr := l.colors.Info().Sprintf("QUESTION: %s", question)
1✔
468
        optionsStr := l.colors.Info().Sprintf("OPTIONS: %s", strings.Join(options, ", "))
1✔
469
        l.writeStdout("%s %s\n", tsStr, questionStr)
1✔
470
        l.writeStdout("%s %s\n", tsStr, optionsStr)
1✔
471
}
1✔
472

473
// LogAnswer logs the user's answer for plan creation mode.
474
// format: ANSWER: <answer>
475
func (l *Logger) LogAnswer(answer string) {
1✔
476
        timestamp := time.Now().Format(timestampFormat)
1✔
477

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

1✔
480
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
481
        answerStr := l.colors.Info().Sprintf("ANSWER: %s", answer)
1✔
482
        l.writeStdout("%s %s\n", tsStr, answerStr)
1✔
483
}
1✔
484

485
// LogDraftReview logs the user's draft review action and optional feedback.
486
// format: DRAFT REVIEW: <action>
487
// if feedback is non-empty: FEEDBACK: <feedback>
488
func (l *Logger) LogDraftReview(action, feedback string) {
2✔
489
        timestamp := time.Now().Format(timestampFormat)
2✔
490

2✔
491
        l.writeFile("[%s] DRAFT REVIEW: %s\n", timestamp, action)
2✔
492

2✔
493
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
2✔
494
        actionStr := l.colors.Info().Sprintf("DRAFT REVIEW: %s", action)
2✔
495
        l.writeStdout("%s %s\n", tsStr, actionStr)
2✔
496

2✔
497
        if feedback != "" {
3✔
498
                l.writeFile("[%s] FEEDBACK: %s\n", timestamp, feedback)
1✔
499
                feedbackStr := l.colors.Info().Sprintf("FEEDBACK: %s", feedback)
1✔
500
                l.writeStdout("%s %s\n", tsStr, feedbackStr)
1✔
501
        }
1✔
502
}
503

504
// LogDiffStats writes git diff stats to the progress file (file-only, no stdout).
505
// format: [timestamp] DIFFSTATS: files=F additions=A deletions=D
506
func (l *Logger) LogDiffStats(files, additions, deletions int) {
2✔
507
        if l.file == nil || files <= 0 {
3✔
508
                return
1✔
509
        }
1✔
510
        timestamp := time.Now().Format(timestampFormat)
1✔
511
        l.writeFile("[%s] DIFFSTATS: files=%d additions=%d deletions=%d\n",
1✔
512
                timestamp, files, additions, deletions)
1✔
513
}
514

515
// Elapsed returns formatted elapsed time since start.
516
// for durations >= 1 hour, truncates to minutes (e.g. "1h23m"); otherwise to seconds (e.g. "5m30s").
517
func (l *Logger) Elapsed() string {
34✔
518
        d := time.Since(l.startTime)
34✔
519
        if d >= time.Hour {
37✔
520
                return strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
3✔
521
        }
3✔
522
        return d.Truncate(time.Second).String()
31✔
523
}
524

525
// Close writes footer, releases the file lock, and closes the progress file.
526
func (l *Logger) Close() error {
29✔
527
        if l.file == nil {
29✔
528
                return nil
×
529
        }
×
530

531
        l.writeFile("\n%s\n", strings.Repeat("-", 60))
29✔
532
        l.writeFile("Completed: %s (%s)\n", time.Now().Format("2006-01-02 15:04:05"), l.Elapsed())
29✔
533

29✔
534
        // release file lock before closing
29✔
535
        _ = unlockFile(l.file)
29✔
536
        unregisterActiveLock(l.file.Name())
29✔
537

29✔
538
        if err := l.file.Close(); err != nil {
29✔
539
                return fmt.Errorf("close progress file: %w", err)
×
540
        }
×
541
        return nil
29✔
542
}
543

544
func (l *Logger) writeFile(format string, args ...any) {
257✔
545
        if l.file != nil {
514✔
546
                fmt.Fprintf(l.file, format, args...)
257✔
547
        }
257✔
548
}
549

550
func (l *Logger) writeStdout(format string, args ...any) {
23✔
551
        fmt.Fprintf(l.stdout, format, args...)
23✔
552
}
23✔
553

554
// isProgressCompleted checks if a progress file has a completion footer written by Close().
555
// reads the last ~256 bytes from the provided file descriptor and checks for the dash separator
556
// followed by "Completed:" — the exact pattern Close() writes.
557
// uses the already-locked fd to avoid TOCTOU path-vs-inode mismatch.
558
// returns false for zero-size files or read errors.
559
func isProgressCompleted(f *os.File, size int64) bool {
10✔
560
        if size == 0 {
12✔
561
                return false
2✔
562
        }
2✔
563

564
        // read the last 256 bytes (or less if file is smaller)
565
        const tailSize int64 = 256
8✔
566
        offset := max(0, size-tailSize)
8✔
567

8✔
568
        buf := make([]byte, tailSize)
8✔
569
        n, err := f.ReadAt(buf, offset)
8✔
570
        if err != nil && n == 0 {
8✔
NEW
571
                return false
×
NEW
572
        }
×
573

574
        // match the exact pattern written by Close(): 60-dash separator followed by "Completed:".
575
        // a plain "Completed:" check would false-positive on Claude output containing that text.
576
        return strings.Contains(string(buf[:n]), strings.Repeat("-", 60)+"\nCompleted:")
8✔
577
}
578

579
// progressDir is the directory for progress files within the project.
580
const progressDir = ".ralphex/progress"
581

582
// progressFilename returns progress file path based on plan and mode.
583
func progressFilename(planFile, planDescription, mode string) string {
41✔
584
        // plan mode uses sanitized plan description
41✔
585
        if mode == "plan" && planDescription != "" {
49✔
586
                sanitized := sanitizePlanName(planDescription)
8✔
587
                return filepath.Join(progressDir, fmt.Sprintf("progress-plan-%s.txt", sanitized))
8✔
588
        }
8✔
589

590
        if planFile != "" {
45✔
591
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
12✔
592
                switch mode {
12✔
593
                case "codex-only":
2✔
594
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-codex.txt", stem))
2✔
595
                case "review":
2✔
596
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-review.txt", stem))
2✔
597
                default:
8✔
598
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s.txt", stem))
8✔
599
                }
600
        }
601

602
        switch mode {
21✔
603
        case "codex-only":
2✔
604
                return filepath.Join(progressDir, "progress-codex.txt")
2✔
605
        case "review":
2✔
606
                return filepath.Join(progressDir, "progress-review.txt")
2✔
607
        case "plan":
2✔
608
                return filepath.Join(progressDir, "progress-plan.txt")
2✔
609
        default:
15✔
610
                return filepath.Join(progressDir, "progress.txt")
15✔
611
        }
612
}
613

614
// sanitizePlanName converts plan description to a safe filename component.
615
// replaces spaces with dashes, removes special characters, and limits length.
616
func sanitizePlanName(desc string) string {
19✔
617
        // lowercase and replace spaces with dashes
19✔
618
        result := strings.ToLower(desc)
19✔
619
        result = strings.ReplaceAll(result, " ", "-")
19✔
620

19✔
621
        // keep only alphanumeric and dashes
19✔
622
        var clean strings.Builder
19✔
623
        for _, r := range result {
331✔
624
                if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
613✔
625
                        clean.WriteRune(r)
301✔
626
                }
301✔
627
        }
628
        result = clean.String()
19✔
629

19✔
630
        // collapse multiple dashes
19✔
631
        for strings.Contains(result, "--") {
22✔
632
                result = strings.ReplaceAll(result, "--", "-")
3✔
633
        }
3✔
634

635
        // trim leading/trailing dashes
636
        result = strings.Trim(result, "-")
19✔
637

19✔
638
        // limit length to 50 characters
19✔
639
        if len(result) > 50 {
21✔
640
                result = result[:50]
2✔
641
                // don't end with a dash
2✔
642
                result = strings.TrimRight(result, "-")
2✔
643
        }
2✔
644

645
        if result == "" {
21✔
646
                return "unnamed"
2✔
647
        }
2✔
648
        return result
17✔
649
}
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