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

umputun / ralphex / 21652713242

04 Feb 2026 12:01AM UTC coverage: 81.101% (+0.5%) from 80.647%
21652713242

Pull #36

github

melonamin
feat(e2e): add planning UI e2e tests

Cover new plan modal, docked question interactions, plan session mode,
QA output filtering, keyboard shortcuts, and PLAN_READY signal rendering.
Fix pre-existing ineffectual assignment lint warning in session_manager.
Pull Request #36: feat(web): interactive plan creation from web dashboard

986 of 1195 new or added lines in 14 files covered. (82.51%)

5 existing lines in 2 files now uncovered.

5184 of 6392 relevant lines covered (81.1%)

105.41 hits per line

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

92.93
/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/dustin/go-humanize"
14
        "github.com/fatih/color"
15
        "golang.org/x/term"
16

17
        "github.com/umputun/ralphex/pkg/config"
18
        "github.com/umputun/ralphex/pkg/processor"
19
)
20

21
// Phase is an alias to processor.Phase for backwards compatibility.
22
//
23
// Deprecated: use processor.Phase directly.
24
type Phase = processor.Phase
25

26
// Phase constants for execution stages - aliases to processor constants.
27
const (
28
        PhaseTask       = processor.PhaseTask
29
        PhaseReview     = processor.PhaseReview
30
        PhaseCodex      = processor.PhaseCodex
31
        PhaseClaudeEval = processor.PhaseClaudeEval
32
        PhasePlan       = processor.PhasePlan
33
)
34

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

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

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

24✔
71
        return c
24✔
72
}
24✔
73

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

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

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

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

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

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

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

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

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

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

135
// Config holds logger configuration.
136
type Config struct {
137
        PlanFile        string // plan filename (used to derive progress filename)
138
        PlanDescription string // plan description for plan mode (used for filename)
139
        ProgressPath    string // explicit progress file path (overrides derived 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
        Append          bool   // append to existing file instead of creating new (for resuming sessions)
144
}
145

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

154
        progressPath := cfg.ProgressPath
25✔
155
        if progressPath == "" {
48✔
156
                progressPath = progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
23✔
157
        }
23✔
158

159
        // ensure progress files are tracked by creating parent dir
160
        if dir := filepath.Dir(progressPath); dir != "." {
27✔
161
                if err := os.MkdirAll(dir, 0o750); err != nil {
2✔
162
                        return nil, fmt.Errorf("create progress dir: %w", err)
×
163
                }
×
164
        }
165

166
        // open file in appropriate mode
167
        var f *os.File
25✔
168
        var err error
25✔
169
        if cfg.Append {
27✔
170
                f, err = os.OpenFile(progressPath, os.O_APPEND|os.O_WRONLY, 0o644) //nolint:gosec // path derived from plan filename
2✔
171
                if err != nil {
3✔
172
                        return nil, fmt.Errorf("open progress file for append: %w", err)
1✔
173
                }
1✔
174
        } else {
23✔
175
                f, err = os.Create(progressPath) //nolint:gosec // path derived from plan filename
23✔
176
                if err != nil {
23✔
NEW
177
                        return nil, fmt.Errorf("create progress file: %w", err)
×
NEW
178
                }
×
179
        }
180

181
        // acquire exclusive lock on progress file to signal active session
182
        // the lock is held for the duration of execution and released on Close()
183
        if err := lockFile(f); err != nil {
24✔
184
                f.Close()
×
185
                return nil, fmt.Errorf("acquire file lock: %w", err)
×
186
        }
×
187
        registerActiveLock(f.Name())
24✔
188

24✔
189
        l := &Logger{
24✔
190
                file:      f,
24✔
191
                stdout:    os.Stdout,
24✔
192
                startTime: time.Now(),
24✔
193
                phase:     PhaseTask,
24✔
194
                colors:    colors,
24✔
195
        }
24✔
196

24✔
197
        if cfg.Append {
25✔
198
                // write resume separator instead of full header
1✔
199
                l.writeFile("\n%s\n", strings.Repeat("-", 60))
1✔
200
                l.writeFile("Resumed: %s\n", time.Now().Format("2006-01-02 15:04:05"))
1✔
201
                l.writeFile("%s\n\n", strings.Repeat("-", 60))
1✔
202
        } else {
24✔
203
                // write full header for new sessions
23✔
204
                planStr := cfg.PlanFile
23✔
205
                if cfg.Mode == "plan" && cfg.PlanDescription != "" {
28✔
206
                        planStr = cfg.PlanDescription
5✔
207
                }
5✔
208
                if planStr == "" {
38✔
209
                        planStr = "(no plan - review only)"
15✔
210
                }
15✔
211
                l.writeFile("# Ralphex Progress Log\n")
23✔
212
                l.writeFile("Plan: %s\n", planStr)
23✔
213
                l.writeFile("Branch: %s\n", cfg.Branch)
23✔
214
                l.writeFile("Mode: %s\n", cfg.Mode)
23✔
215
                l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
23✔
216
                l.writeFile("%s\n\n", strings.Repeat("-", 60))
23✔
217
        }
218

219
        return l, nil
24✔
220
}
221

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

230
// SetPhase sets the current execution phase for color coding.
231
func (l *Logger) SetPhase(phase Phase) {
4✔
232
        l.phase = phase
4✔
233
}
4✔
234

235
// timestampFormat is the format for timestamps: YY-MM-DD HH:MM:SS
236
const timestampFormat = "06-01-02 15:04:05"
237

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

7✔
243
        // write to file without color
7✔
244
        l.writeFile("[%s] %s\n", timestamp, msg)
7✔
245

7✔
246
        // write to stdout with color
7✔
247
        phaseColor := l.colors.ForPhase(l.phase)
7✔
248
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
7✔
249
        msgStr := phaseColor.Sprint(msg)
7✔
250
        l.writeStdout("%s %s\n", tsStr, msgStr)
7✔
251
}
7✔
252

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

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

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

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

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

293
        return 80 - 20 // default 80 columns minus timestamp
2✔
294
}
295

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

302
        var result strings.Builder
2✔
303
        words := strings.Fields(text)
2✔
304
        lineLen := 0
2✔
305

2✔
306
        for i, word := range words {
11✔
307
                wordLen := len(word)
9✔
308

9✔
309
                if i == 0 {
11✔
310
                        result.WriteString(word)
2✔
311
                        lineLen = wordLen
2✔
312
                        continue
2✔
313
                }
314

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

328
        return result.String()
2✔
329
}
330

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

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

343
        phaseColor := l.colors.ForPhase(l.phase)
1✔
344

1✔
345
        // wrap text to terminal width
1✔
346
        width := getTerminalWidth()
1✔
347

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

361
        for _, line := range lines {
4✔
362
                if line == "" {
3✔
363
                        continue // skip empty lines
×
364
                }
365

366
                // add indent for list items
367
                displayLine := formatListItem(line)
3✔
368

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

3✔
374
                // use red for signal lines
3✔
375
                lineColor := phaseColor
3✔
376

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

383
                l.writeStdout("%s %s\n", tsPrefix, lineColor.Sprint(displayLine))
3✔
384
        }
385
}
386

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

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

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

403
        return line[start+len(prefix) : start+end]
5✔
404
}
405

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

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

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

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

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

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

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

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

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

1✔
466
        l.writeFile("[%s] QUESTION: %s\n", timestamp, question)
1✔
467
        l.writeFile("[%s] OPTIONS: %s\n", timestamp, strings.Join(options, ", "))
1✔
468

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

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

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

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

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

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

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

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

507
// Elapsed returns formatted elapsed time since start.
508
func (l *Logger) Elapsed() string {
25✔
509
        return humanize.RelTime(l.startTime, time.Now(), "", "")
25✔
510
}
25✔
511

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

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

24✔
521
        // release file lock before closing
24✔
522
        _ = unlockFile(l.file)
24✔
523
        unregisterActiveLock(l.file.Name())
24✔
524

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

531
func (l *Logger) writeFile(format string, args ...any) {
209✔
532
        if l.file != nil {
418✔
533
                fmt.Fprintf(l.file, format, args...)
209✔
534
        }
209✔
535
}
536

537
func (l *Logger) writeStdout(format string, args ...any) {
20✔
538
        fmt.Fprintf(l.stdout, format, args...)
20✔
539
}
20✔
540

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

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

561
        switch mode {
19✔
562
        case "codex-only":
2✔
563
                return "progress-codex.txt"
2✔
564
        case "review":
2✔
565
                return "progress-review.txt"
2✔
566
        case "plan":
2✔
567
                return "progress-plan.txt"
2✔
568
        default:
13✔
569
                return "progress.txt"
13✔
570
        }
571
}
572

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

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

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

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

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

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