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

umputun / ralphex / 22150394692

18 Feb 2026 05:28PM UTC coverage: 81.761% (-0.1%) from 81.881%
22150394692

Pull #130

github

umputun
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
Pull Request #130: fix: append progress files on restart instead of truncating

20 of 24 new or added lines in 1 file covered. (83.33%)

7 existing lines in 2 files now uncovered.

5357 of 6552 relevant lines covered (81.76%)

374.4 hits per line

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

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

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

50✔
57
        return c
50✔
58
}
50✔
59

60
// parseColorOrPanic parses RGB string and returns color, panics on invalid input.
61
func parseColorOrPanic(s, name string) *color.Color {
454✔
62
        parseRGB := func(s string) []int {
908✔
63
                if s == "" {
458✔
64
                        return nil
4✔
65
                }
4✔
66
                parts := strings.Split(s, ",")
450✔
67
                if len(parts) != 3 {
460✔
68
                        return nil
10✔
69
                }
10✔
70

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

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

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

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

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

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

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

114
// Signal returns the signal color.
115
func (c *Colors) Signal() *color.Color { return c.signal }
4✔
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
// colors must be provided (created via NewColors from config).
137
// holder is the shared PhaseHolder for reading the current execution phase.
138
func NewLogger(cfg Config, colors *Colors, holder *status.PhaseHolder) (*Logger, error) {
54✔
139
        // set global color setting
54✔
140
        if cfg.NoColor {
78✔
141
                color.NoColor = true
24✔
142
        }
24✔
143

144
        progressPath := progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
54✔
145

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

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

158
        // check if file already has content (restart case)
159
        fi, err := f.Stat()
54✔
160
        if err != nil {
54✔
NEW
161
                f.Close()
×
NEW
162
                return nil, fmt.Errorf("stat progress file: %w", err)
×
NEW
163
        }
×
164
        restart := fi.Size() > 0
54✔
165

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

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

54✔
182
        if restart {
56✔
183
                // write restart separator (matches sectionRegex in web parser)
2✔
184
                l.writeFile("\n\n--- restarted at %s ---\n\n", time.Now().Format("2006-01-02 15:04:05"))
2✔
185
        } else {
54✔
186
                // write full header for new file
52✔
187
                planStr := cfg.PlanFile
52✔
188
                if planStr == "" {
96✔
189
                        planStr = "(no plan - review only)"
44✔
190
                }
44✔
191
                l.writeFile("# Ralphex Progress Log\n")
52✔
192
                l.writeFile("Plan: %s\n", planStr)
52✔
193
                l.writeFile("Branch: %s\n", cfg.Branch)
52✔
194
                l.writeFile("Mode: %s\n", cfg.Mode)
52✔
195
                l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
52✔
196
                l.writeFile("%s\n\n", strings.Repeat("-", 60))
52✔
197
        }
198

199
        return l, nil
54✔
200
}
201

202
// Path returns the progress file path.
203
func (l *Logger) Path() string {
78✔
204
        if l.file == nil {
78✔
205
                return ""
×
206
        }
×
207
        return l.file.Name()
78✔
208
}
209

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

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

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

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

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

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

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

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

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

268
        return 80 - 20 // default 80 columns minus timestamp
4✔
269
}
270

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

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

4✔
281
        for i, word := range words {
22✔
282
                wordLen := len(word)
18✔
283

18✔
284
                if i == 0 {
22✔
285
                        result.WriteString(word)
4✔
286
                        lineLen = wordLen
4✔
287
                        continue
4✔
288
                }
289

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

303
        return result.String()
4✔
304
}
305

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

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

318
        phaseColor := l.colors.ForPhase(l.holder.Get())
2✔
319

2✔
320
        // wrap text to terminal width
2✔
321
        width := getTerminalWidth()
2✔
322

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

336
        for _, line := range lines {
8✔
337
                if line == "" {
6✔
338
                        continue // skip empty lines
×
339
                }
340

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

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

6✔
349
                // use red for signal lines
6✔
350
                lineColor := phaseColor
6✔
351

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

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

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

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

373
        end := strings.Index(line[start:], suffix)
12✔
374
        if end == -1 {
14✔
375
                return ""
2✔
376
        }
2✔
377

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

463
// LogDraftReview logs the user's draft review action and optional feedback.
464
// format: DRAFT REVIEW: <action>
465
// if feedback is non-empty: FEEDBACK: <feedback>
466
func (l *Logger) LogDraftReview(action, feedback string) {
4✔
467
        timestamp := time.Now().Format(timestampFormat)
4✔
468

4✔
469
        l.writeFile("[%s] DRAFT REVIEW: %s\n", timestamp, action)
4✔
470

4✔
471
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
4✔
472
        actionStr := l.colors.Info().Sprintf("DRAFT REVIEW: %s", action)
4✔
473
        l.writeStdout("%s %s\n", tsStr, actionStr)
4✔
474

4✔
475
        if feedback != "" {
6✔
476
                l.writeFile("[%s] FEEDBACK: %s\n", timestamp, feedback)
2✔
477
                feedbackStr := l.colors.Info().Sprintf("FEEDBACK: %s", feedback)
2✔
478
                l.writeStdout("%s %s\n", tsStr, feedbackStr)
2✔
479
        }
2✔
480
}
481

482
// LogDiffStats writes git diff stats to the progress file (file-only, no stdout).
483
// format: [timestamp] DIFFSTATS: files=F additions=A deletions=D
484
func (l *Logger) LogDiffStats(files, additions, deletions int) {
4✔
485
        if l.file == nil || files <= 0 {
6✔
486
                return
2✔
487
        }
2✔
488
        timestamp := time.Now().Format(timestampFormat)
2✔
489
        l.writeFile("[%s] DIFFSTATS: files=%d additions=%d deletions=%d\n",
2✔
490
                timestamp, files, additions, deletions)
2✔
491
}
492

493
// Elapsed returns formatted elapsed time since start.
494
// for durations >= 1 hour, truncates to minutes (e.g. "1h23m"); otherwise to seconds (e.g. "5m30s").
495
func (l *Logger) Elapsed() string {
64✔
496
        d := time.Since(l.startTime)
64✔
497
        if d >= time.Hour {
70✔
498
                return strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
6✔
499
        }
6✔
500
        return d.Truncate(time.Second).String()
58✔
501
}
502

503
// Close writes footer, releases the file lock, and closes the progress file.
504
func (l *Logger) Close() error {
54✔
505
        if l.file == nil {
54✔
506
                return nil
×
507
        }
×
508

509
        l.writeFile("\n%s\n", strings.Repeat("-", 60))
54✔
510
        l.writeFile("Completed: %s (%s)\n", time.Now().Format("2006-01-02 15:04:05"), l.Elapsed())
54✔
511

54✔
512
        // release file lock before closing
54✔
513
        _ = unlockFile(l.file)
54✔
514
        unregisterActiveLock(l.file.Name())
54✔
515

54✔
516
        if err := l.file.Close(); err != nil {
54✔
517
                return fmt.Errorf("close progress file: %w", err)
×
518
        }
×
519
        return nil
54✔
520
}
521

522
func (l *Logger) writeFile(format string, args ...any) {
466✔
523
        if l.file != nil {
932✔
524
                fmt.Fprintf(l.file, format, args...)
466✔
525
        }
466✔
526
}
527

528
func (l *Logger) writeStdout(format string, args ...any) {
42✔
529
        fmt.Fprintf(l.stdout, format, args...)
42✔
530
}
42✔
531

532
// progressDir is the directory for progress files within the project.
533
const progressDir = ".ralphex/progress"
534

535
// progressFilename returns progress file path based on plan and mode.
536
func progressFilename(planFile, planDescription, mode string) string {
76✔
537
        // plan mode uses sanitized plan description
76✔
538
        if mode == "plan" && planDescription != "" {
92✔
539
                sanitized := sanitizePlanName(planDescription)
16✔
540
                return filepath.Join(progressDir, fmt.Sprintf("progress-plan-%s.txt", sanitized))
16✔
541
        }
16✔
542

543
        if planFile != "" {
78✔
544
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
18✔
545
                switch mode {
18✔
546
                case "codex-only":
4✔
547
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-codex.txt", stem))
4✔
548
                case "review":
4✔
549
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s-review.txt", stem))
4✔
550
                default:
10✔
551
                        return filepath.Join(progressDir, fmt.Sprintf("progress-%s.txt", stem))
10✔
552
                }
553
        }
554

555
        switch mode {
42✔
556
        case "codex-only":
4✔
557
                return filepath.Join(progressDir, "progress-codex.txt")
4✔
558
        case "review":
4✔
559
                return filepath.Join(progressDir, "progress-review.txt")
4✔
560
        case "plan":
4✔
561
                return filepath.Join(progressDir, "progress-plan.txt")
4✔
562
        default:
30✔
563
                return filepath.Join(progressDir, "progress.txt")
30✔
564
        }
565
}
566

567
// sanitizePlanName converts plan description to a safe filename component.
568
// replaces spaces with dashes, removes special characters, and limits length.
569
func sanitizePlanName(desc string) string {
38✔
570
        // lowercase and replace spaces with dashes
38✔
571
        result := strings.ToLower(desc)
38✔
572
        result = strings.ReplaceAll(result, " ", "-")
38✔
573

38✔
574
        // keep only alphanumeric and dashes
38✔
575
        var clean strings.Builder
38✔
576
        for _, r := range result {
662✔
577
                if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
1,226✔
578
                        clean.WriteRune(r)
602✔
579
                }
602✔
580
        }
581
        result = clean.String()
38✔
582

38✔
583
        // collapse multiple dashes
38✔
584
        for strings.Contains(result, "--") {
44✔
585
                result = strings.ReplaceAll(result, "--", "-")
6✔
586
        }
6✔
587

588
        // trim leading/trailing dashes
589
        result = strings.Trim(result, "-")
38✔
590

38✔
591
        // limit length to 50 characters
38✔
592
        if len(result) > 50 {
42✔
593
                result = result[:50]
4✔
594
                // don't end with a dash
4✔
595
                result = strings.TrimRight(result, "-")
4✔
596
        }
4✔
597

598
        if result == "" {
42✔
599
                return "unnamed"
4✔
600
        }
4✔
601
        return result
34✔
602
}
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