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

umputun / ralphex / 21693946322

04 Feb 2026 10:06PM UTC coverage: 80.959% (+0.5%) from 80.435%
21693946322

Pull #36

github

melonamin
Add git diff stats to dashboard
Pull Request #36: feat(web): interactive plan creation from web dashboard

1048 of 1275 new or added lines in 15 files covered. (82.2%)

5319 of 6570 relevant lines covered (80.96%)

112.15 hits per line

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

91.34
/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
        PhaseFinalize   = processor.PhaseFinalize
34
)
35

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

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

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

24✔
73
        return c
24✔
74
}
24✔
75

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

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

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

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

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

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

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

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

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

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

137
// Config holds logger configuration.
138
type Config struct {
139
        PlanFile        string // plan filename (used to derive progress filename)
140
        PlanDescription string // plan description for plan mode (used for filename)
141
        ProgressPath    string // explicit progress file path (overrides derived filename)
142
        Mode            string // execution mode: full, review, codex-only, plan
143
        Branch          string // current git branch
144
        NoColor         bool   // disable color output (sets color.NoColor globally)
145
        Append          bool   // append to existing file instead of creating new (for resuming sessions)
146
}
147

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

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

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

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

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

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

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

221
        return l, nil
24✔
222
}
223

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

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

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

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

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

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

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

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

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

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

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

295
        return 80 - 20 // default 80 columns minus timestamp
2✔
296
}
297

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

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

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

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

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

330
        return result.String()
2✔
331
}
332

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

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

345
        phaseColor := l.colors.ForPhase(l.phase)
1✔
346

1✔
347
        // wrap text to terminal width
1✔
348
        width := getTerminalWidth()
1✔
349

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

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

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

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

3✔
376
                // use red for signal lines
3✔
377
                lineColor := phaseColor
3✔
378

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

509
// LogDiffStats writes git diff stats to the progress file (file-only, no stdout).
510
// format: [timestamp] DIFFSTATS: files=F additions=A deletions=D
NEW
511
func (l *Logger) LogDiffStats(files, additions, deletions int) {
×
NEW
512
        if l.file == nil || files <= 0 {
×
NEW
513
                return
×
NEW
514
        }
×
NEW
515
        timestamp := time.Now().Format(timestampFormat)
×
NEW
516
        l.writeFile("[%s] DIFFSTATS: files=%d additions=%d deletions=%d\n",
×
NEW
517
                timestamp, files, additions, deletions)
×
518
}
519

520
// Elapsed returns formatted elapsed time since start.
521
func (l *Logger) Elapsed() string {
25✔
522
        return humanize.RelTime(l.startTime, time.Now(), "", "")
25✔
523
}
25✔
524

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

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

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

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

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

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

554
// getProgressFilename returns progress file path based on plan and mode.
555
func progressFilename(planFile, planDescription, mode string) string {
34✔
556
        // plan mode uses sanitized plan description
34✔
557
        if mode == "plan" && planDescription != "" {
42✔
558
                sanitized := sanitizePlanName(planDescription)
8✔
559
                return fmt.Sprintf("progress-plan-%s.txt", sanitized)
8✔
560
        }
8✔
561

562
        if planFile != "" {
33✔
563
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
7✔
564
                switch mode {
7✔
565
                case "codex-only":
2✔
566
                        return fmt.Sprintf("progress-%s-codex.txt", stem)
2✔
567
                case "review":
2✔
568
                        return fmt.Sprintf("progress-%s-review.txt", stem)
2✔
569
                default:
3✔
570
                        return fmt.Sprintf("progress-%s.txt", stem)
3✔
571
                }
572
        }
573

574
        switch mode {
19✔
575
        case "codex-only":
2✔
576
                return "progress-codex.txt"
2✔
577
        case "review":
2✔
578
                return "progress-review.txt"
2✔
579
        case "plan":
2✔
580
                return "progress-plan.txt"
2✔
581
        default:
13✔
582
                return "progress.txt"
13✔
583
        }
584
}
585

586
// sanitizePlanName converts plan description to a safe filename component.
587
// replaces spaces with dashes, removes special characters, and limits length.
588
func sanitizePlanName(desc string) string {
19✔
589
        // lowercase and replace spaces with dashes
19✔
590
        result := strings.ToLower(desc)
19✔
591
        result = strings.ReplaceAll(result, " ", "-")
19✔
592

19✔
593
        // keep only alphanumeric and dashes
19✔
594
        var clean strings.Builder
19✔
595
        for _, r := range result {
331✔
596
                if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
613✔
597
                        clean.WriteRune(r)
301✔
598
                }
301✔
599
        }
600
        result = clean.String()
19✔
601

19✔
602
        // collapse multiple dashes
19✔
603
        for strings.Contains(result, "--") {
22✔
604
                result = strings.ReplaceAll(result, "--", "-")
3✔
605
        }
3✔
606

607
        // trim leading/trailing dashes
608
        result = strings.Trim(result, "-")
19✔
609

19✔
610
        // limit length to 50 characters
19✔
611
        if len(result) > 50 {
21✔
612
                result = result[:50]
2✔
613
                // don't end with a dash
2✔
614
                result = strings.TrimRight(result, "-")
2✔
615
        }
2✔
616

617
        if result == "" {
21✔
618
                return "unnamed"
2✔
619
        }
2✔
620
        return result
17✔
621
}
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