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

umputun / ralphex / 22151409710

18 Feb 2026 05:58PM UTC coverage: 81.795% (-0.09%) from 81.881%
22151409710

push

github

web-flow
fix: append progress files on restart instead of truncating (#130)

* fix: append progress files on restart instead of truncating

Switch from os.Create (truncate) to os.OpenFile with O_APPEND so
progress history is preserved when ralphex is restarted. On restart,
a separator line is written instead of a full header. The separator
matches the web parser sectionRegex and renders as a section boundary
in the dashboard.

Related to #129

* test: add edge case tests and godoc for append-on-restart

Add empty-file edge case test, web parser test for restart separator,
and update NewLogger godoc to document append behavior.

* fix: acquire file lock before stat to prevent TOCTOU race

Move lockFile() call before f.Stat() in NewLogger so the restart
detection (size > 0 check) is protected by the exclusive lock.
Without this ordering, a concurrent process could stat size==0,
block on the lock, then incorrectly write a full header after
another process already wrote content.

Also add proper cleanup (unlock + unregister) on the stat error path.

22 of 29 new or added lines in 1 file covered. (75.86%)

3 existing lines in 2 files now uncovered.

5360 of 6553 relevant lines covered (81.79%)

186.95 hits per line

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

91.83
/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 {
26✔
39
        c := &Colors{phases: make(map[status.Phase]*color.Color)}
26✔
40
        c.task = parseColorOrPanic(cfg.Task, "task")
26✔
41
        c.review = parseColorOrPanic(cfg.Review, "review")
26✔
42
        c.codex = parseColorOrPanic(cfg.Codex, "codex")
26✔
43
        c.claudeEval = parseColorOrPanic(cfg.ClaudeEval, "claude_eval")
26✔
44
        c.warn = parseColorOrPanic(cfg.Warn, "warn")
26✔
45
        c.err = parseColorOrPanic(cfg.Error, "error")
26✔
46
        c.signal = parseColorOrPanic(cfg.Signal, "signal")
26✔
47
        c.timestamp = parseColorOrPanic(cfg.Timestamp, "timestamp")
26✔
48
        c.info = parseColorOrPanic(cfg.Info, "info")
26✔
49

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

26✔
57
        return c
26✔
58
}
26✔
59

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

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

87
        rgb := parseRGB(s)
236✔
88
        if rgb == nil {
252✔
89
                panic(fmt.Sprintf("invalid color_%s value: %q", name, s))
16✔
90
        }
91
        return color.RGB(rgb[0], rgb[1], rgb[2])
220✔
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 {
17✔
99
        if clr, ok := c.phases[p]; ok {
29✔
100
                return clr
12✔
101
        }
12✔
102
        return c.task // fallback to task color for unknown/empty phase
5✔
103
}
104

105
// Timestamp returns the timestamp color.
106
func (c *Colors) Timestamp() *color.Color { return c.timestamp }
19✔
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 content, existing log is preserved
137
// and a restart separator is written instead of a full header.
138
// colors must be provided (created via NewColors from config).
139
// holder is the shared PhaseHolder for reading the current execution phase.
140
func NewLogger(cfg Config, colors *Colors, holder *status.PhaseHolder) (*Logger, error) {
28✔
141
        // set global color setting
28✔
142
        if cfg.NoColor {
40✔
143
                color.NoColor = true
12✔
144
        }
12✔
145

146
        progressPath := progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
28✔
147

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

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

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

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

28✔
181
        l := &Logger{
28✔
182
                file:      f,
28✔
183
                stdout:    os.Stdout,
28✔
184
                startTime: time.Now(),
28✔
185
                holder:    holder,
28✔
186
                colors:    colors,
28✔
187
        }
28✔
188

28✔
189
        if restart {
29✔
190
                // write restart separator (matches sectionRegex in web parser)
1✔
191
                l.writeFile("\n\n--- restarted at %s ---\n\n", time.Now().Format("2006-01-02 15:04:05"))
1✔
192
        } else {
28✔
193
                // write full header for new file
27✔
194
                planStr := cfg.PlanFile
27✔
195
                if planStr == "" {
49✔
196
                        planStr = "(no plan - review only)"
22✔
197
                }
22✔
198
                l.writeFile("# Ralphex Progress Log\n")
27✔
199
                l.writeFile("Plan: %s\n", planStr)
27✔
200
                l.writeFile("Branch: %s\n", cfg.Branch)
27✔
201
                l.writeFile("Mode: %s\n", cfg.Mode)
27✔
202
                l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
27✔
203
                l.writeFile("%s\n\n", strings.Repeat("-", 60))
27✔
204
        }
205

206
        return l, nil
28✔
207
}
208

209
// Path returns the progress file path.
210
func (l *Logger) Path() string {
40✔
211
        if l.file == nil {
40✔
212
                return ""
×
213
        }
×
214
        return l.file.Name()
40✔
215
}
216

217
// timestampFormat is the format for timestamps: YY-MM-DD HH:MM:SS
218
const timestampFormat = "06-01-02 15:04:05"
219

220
// Print writes a timestamped message to both file and stdout.
221
func (l *Logger) Print(format string, args ...any) {
8✔
222
        msg := fmt.Sprintf(format, args...)
8✔
223
        timestamp := time.Now().Format(timestampFormat)
8✔
224

8✔
225
        // write to file without color
8✔
226
        l.writeFile("[%s] %s\n", timestamp, msg)
8✔
227

8✔
228
        // write to stdout with color
8✔
229
        phaseColor := l.colors.ForPhase(l.holder.Get())
8✔
230
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
8✔
231
        msgStr := phaseColor.Sprint(msg)
8✔
232
        l.writeStdout("%s %s\n", tsStr, msgStr)
8✔
233
}
8✔
234

235
// PrintRaw writes without timestamp (for streaming output).
236
func (l *Logger) PrintRaw(format string, args ...any) {
1✔
237
        msg := fmt.Sprintf(format, args...)
1✔
238
        l.writeFile("%s", msg)
1✔
239
        l.writeStdout("%s", msg)
1✔
240
}
1✔
241

242
// PrintSection writes a section header without timestamp in yellow.
243
// format: "\n--- {label} ---\n"
244
func (l *Logger) PrintSection(section status.Section) {
1✔
245
        header := fmt.Sprintf("\n--- %s ---\n", section.Label)
1✔
246
        l.writeFile("%s", header)
1✔
247
        l.writeStdout("%s", l.colors.Warn().Sprint(header))
1✔
248
}
1✔
249

250
// getTerminalWidth returns terminal width, using COLUMNS env var or syscall.
251
// Defaults to 80 if detection fails. Returns content width (total - 20 for timestamp).
252
func getTerminalWidth() int {
4✔
253
        const minWidth = 40
4✔
254

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

266
        // try terminal syscall
267
        if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
2✔
268
                contentWidth := w - 20
×
269
                if contentWidth < minWidth {
×
270
                        return minWidth
×
271
                }
×
272
                return contentWidth
×
273
        }
274

275
        return 80 - 20 // default 80 columns minus timestamp
2✔
276
}
277

278
// wrapText wraps text to specified width, breaking on word boundaries.
279
func wrapText(text string, width int) string {
6✔
280
        if width <= 0 || len(text) <= width {
10✔
281
                return text
4✔
282
        }
4✔
283

284
        var result strings.Builder
2✔
285
        words := strings.Fields(text)
2✔
286
        lineLen := 0
2✔
287

2✔
288
        for i, word := range words {
11✔
289
                wordLen := len(word)
9✔
290

9✔
291
                if i == 0 {
11✔
292
                        result.WriteString(word)
2✔
293
                        lineLen = wordLen
2✔
294
                        continue
2✔
295
                }
296

297
                // check if word fits on current line
298
                if lineLen+1+wordLen <= width {
12✔
299
                        result.WriteString(" ")
5✔
300
                        result.WriteString(word)
5✔
301
                        lineLen += 1 + wordLen
5✔
302
                } else {
7✔
303
                        // start new line
2✔
304
                        result.WriteString("\n")
2✔
305
                        result.WriteString(word)
2✔
306
                        lineLen = wordLen
2✔
307
                }
2✔
308
        }
309

310
        return result.String()
2✔
311
}
312

313
// PrintAligned writes text with timestamp on each line, suppressing empty lines.
314
func (l *Logger) PrintAligned(text string) {
2✔
315
        if text == "" {
3✔
316
                return
1✔
317
        }
1✔
318

319
        // trim trailing newlines to avoid extra blank lines
320
        text = strings.TrimRight(text, "\n")
1✔
321
        if text == "" {
1✔
322
                return
×
323
        }
×
324

325
        phaseColor := l.colors.ForPhase(l.holder.Get())
1✔
326

1✔
327
        // wrap text to terminal width
1✔
328
        width := getTerminalWidth()
1✔
329

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

343
        for _, line := range lines {
4✔
344
                if line == "" {
3✔
345
                        continue // skip empty lines
×
346
                }
347

348
                // add indent for list items
349
                displayLine := formatListItem(line)
3✔
350

3✔
351
                // timestamp each line
3✔
352
                timestamp := time.Now().Format(timestampFormat)
3✔
353
                tsPrefix := l.colors.Timestamp().Sprintf("[%s]", timestamp)
3✔
354
                l.writeFile("[%s] %s\n", timestamp, displayLine)
3✔
355

3✔
356
                // use red for signal lines
3✔
357
                lineColor := phaseColor
3✔
358

3✔
359
                // format signal lines nicely
3✔
360
                if sig := extractSignal(line); sig != "" {
3✔
361
                        displayLine = sig
×
362
                        lineColor = l.colors.Signal()
×
363
                }
×
364

365
                l.writeStdout("%s %s\n", tsPrefix, lineColor.Sprint(displayLine))
3✔
366
        }
367
}
368

369
// extractSignal extracts signal name from <<<RALPHEX:SIGNAL_NAME>>> format.
370
// returns empty string if no signal found.
371
func extractSignal(line string) string {
12✔
372
        const prefix = "<<<RALPHEX:"
12✔
373
        const suffix = ">>>"
12✔
374

12✔
375
        start := strings.Index(line, prefix)
12✔
376
        if start == -1 {
18✔
377
                return ""
6✔
378
        }
6✔
379

380
        end := strings.Index(line[start:], suffix)
6✔
381
        if end == -1 {
7✔
382
                return ""
1✔
383
        }
1✔
384

385
        return line[start+len(prefix) : start+end]
5✔
386
}
387

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

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

419
// Error writes an error message in red.
420
func (l *Logger) Error(format string, args ...any) {
1✔
421
        msg := fmt.Sprintf(format, args...)
1✔
422
        timestamp := time.Now().Format(timestampFormat)
1✔
423

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

1✔
426
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
427
        errStr := l.colors.Error().Sprintf("ERROR: %s", msg)
1✔
428
        l.writeStdout("%s %s\n", tsStr, errStr)
1✔
429
}
1✔
430

431
// Warn writes a warning message in yellow.
432
func (l *Logger) Warn(format string, args ...any) {
1✔
433
        msg := fmt.Sprintf(format, args...)
1✔
434
        timestamp := time.Now().Format(timestampFormat)
1✔
435

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

1✔
438
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
439
        warnStr := l.colors.Warn().Sprintf("WARN: %s", msg)
1✔
440
        l.writeStdout("%s %s\n", tsStr, warnStr)
1✔
441
}
1✔
442

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

1✔
448
        l.writeFile("[%s] QUESTION: %s\n", timestamp, question)
1✔
449
        l.writeFile("[%s] OPTIONS: %s\n", timestamp, strings.Join(options, ", "))
1✔
450

1✔
451
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
452
        questionStr := l.colors.Info().Sprintf("QUESTION: %s", question)
1✔
453
        optionsStr := l.colors.Info().Sprintf("OPTIONS: %s", strings.Join(options, ", "))
1✔
454
        l.writeStdout("%s %s\n", tsStr, questionStr)
1✔
455
        l.writeStdout("%s %s\n", tsStr, optionsStr)
1✔
456
}
1✔
457

458
// LogAnswer logs the user's answer for plan creation mode.
459
// format: ANSWER: <answer>
460
func (l *Logger) LogAnswer(answer string) {
1✔
461
        timestamp := time.Now().Format(timestampFormat)
1✔
462

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

1✔
465
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
466
        answerStr := l.colors.Info().Sprintf("ANSWER: %s", answer)
1✔
467
        l.writeStdout("%s %s\n", tsStr, answerStr)
1✔
468
}
1✔
469

470
// LogDraftReview logs the user's draft review action and optional feedback.
471
// format: DRAFT REVIEW: <action>
472
// if feedback is non-empty: FEEDBACK: <feedback>
473
func (l *Logger) LogDraftReview(action, feedback string) {
2✔
474
        timestamp := time.Now().Format(timestampFormat)
2✔
475

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

2✔
478
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
2✔
479
        actionStr := l.colors.Info().Sprintf("DRAFT REVIEW: %s", action)
2✔
480
        l.writeStdout("%s %s\n", tsStr, actionStr)
2✔
481

2✔
482
        if feedback != "" {
3✔
483
                l.writeFile("[%s] FEEDBACK: %s\n", timestamp, feedback)
1✔
484
                feedbackStr := l.colors.Info().Sprintf("FEEDBACK: %s", feedback)
1✔
485
                l.writeStdout("%s %s\n", tsStr, feedbackStr)
1✔
486
        }
1✔
487
}
488

489
// LogDiffStats writes git diff stats to the progress file (file-only, no stdout).
490
// format: [timestamp] DIFFSTATS: files=F additions=A deletions=D
491
func (l *Logger) LogDiffStats(files, additions, deletions int) {
2✔
492
        if l.file == nil || files <= 0 {
3✔
493
                return
1✔
494
        }
1✔
495
        timestamp := time.Now().Format(timestampFormat)
1✔
496
        l.writeFile("[%s] DIFFSTATS: files=%d additions=%d deletions=%d\n",
1✔
497
                timestamp, files, additions, deletions)
1✔
498
}
499

500
// Elapsed returns formatted elapsed time since start.
501
// for durations >= 1 hour, truncates to minutes (e.g. "1h23m"); otherwise to seconds (e.g. "5m30s").
502
func (l *Logger) Elapsed() string {
33✔
503
        d := time.Since(l.startTime)
33✔
504
        if d >= time.Hour {
36✔
505
                return strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
3✔
506
        }
3✔
507
        return d.Truncate(time.Second).String()
30✔
508
}
509

510
// Close writes footer, releases the file lock, and closes the progress file.
511
func (l *Logger) Close() error {
28✔
512
        if l.file == nil {
28✔
513
                return nil
×
514
        }
×
515

516
        l.writeFile("\n%s\n", strings.Repeat("-", 60))
28✔
517
        l.writeFile("Completed: %s (%s)\n", time.Now().Format("2006-01-02 15:04:05"), l.Elapsed())
28✔
518

28✔
519
        // release file lock before closing
28✔
520
        _ = unlockFile(l.file)
28✔
521
        unregisterActiveLock(l.file.Name())
28✔
522

28✔
523
        if err := l.file.Close(); err != nil {
28✔
524
                return fmt.Errorf("close progress file: %w", err)
×
525
        }
×
526
        return nil
28✔
527
}
528

529
func (l *Logger) writeFile(format string, args ...any) {
241✔
530
        if l.file != nil {
482✔
531
                fmt.Fprintf(l.file, format, args...)
241✔
532
        }
241✔
533
}
534

535
func (l *Logger) writeStdout(format string, args ...any) {
21✔
536
        fmt.Fprintf(l.stdout, format, args...)
21✔
537
}
21✔
538

539
// progressDir is the directory for progress files within the project.
540
const progressDir = ".ralphex/progress"
541

542
// progressFilename returns progress file path based on plan and mode.
543
func progressFilename(planFile, planDescription, mode string) string {
39✔
544
        // plan mode uses sanitized plan description
39✔
545
        if mode == "plan" && planDescription != "" {
47✔
546
                sanitized := sanitizePlanName(planDescription)
8✔
547
                return filepath.Join(progressDir, fmt.Sprintf("progress-plan-%s.txt", sanitized))
8✔
548
        }
8✔
549

550
        if planFile != "" {
41✔
551
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
10✔
552
                switch mode {
10✔
553
                case "codex-only":
2✔
554
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-codex.txt", stem))
2✔
555
                case "review":
2✔
556
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-review.txt", stem))
2✔
557
                default:
6✔
558
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s.txt", stem))
6✔
559
                }
560
        }
561

562
        switch mode {
21✔
563
        case "codex-only":
2✔
564
                return filepath.Join(progressDir, "progress-codex.txt")
2✔
565
        case "review":
2✔
566
                return filepath.Join(progressDir, "progress-review.txt")
2✔
567
        case "plan":
2✔
568
                return filepath.Join(progressDir, "progress-plan.txt")
2✔
569
        default:
15✔
570
                return filepath.Join(progressDir, "progress.txt")
15✔
571
        }
572
}
573

574
// sanitizePlanName converts plan description to a safe filename component.
575
// replaces spaces with dashes, removes special characters, and limits length.
576
func sanitizePlanName(desc string) string {
19✔
577
        // lowercase and replace spaces with dashes
19✔
578
        result := strings.ToLower(desc)
19✔
579
        result = strings.ReplaceAll(result, " ", "-")
19✔
580

19✔
581
        // keep only alphanumeric and dashes
19✔
582
        var clean strings.Builder
19✔
583
        for _, r := range result {
331✔
584
                if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
613✔
585
                        clean.WriteRune(r)
301✔
586
                }
301✔
587
        }
588
        result = clean.String()
19✔
589

19✔
590
        // collapse multiple dashes
19✔
591
        for strings.Contains(result, "--") {
22✔
592
                result = strings.ReplaceAll(result, "--", "-")
3✔
593
        }
3✔
594

595
        // trim leading/trailing dashes
596
        result = strings.Trim(result, "-")
19✔
597

19✔
598
        // limit length to 50 characters
19✔
599
        if len(result) > 50 {
21✔
600
                result = result[:50]
2✔
601
                // don't end with a dash
2✔
602
                result = strings.TrimRight(result, "-")
2✔
603
        }
2✔
604

605
        if result == "" {
21✔
606
                return "unnamed"
2✔
607
        }
2✔
608
        return result
17✔
609
}
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