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

umputun / ralphex / 21848286276

10 Feb 2026 01:43AM UTC coverage: 80.772% (+0.006%) from 80.766%
21848286276

Pull #76

github

melonamin
fix(web): use local timezone for timestamp parsing and improve session liveness logic

Progress log timestamps are written in local time without zone offsets,
so ParseInLocation with time.Local matches the actual semantics instead
of silently interpreting them as UTC.

Frontend session liveness now prefers server-provided session state over
the recency heuristic, centralizes end-time calculation in
getElapsedEndTime(), and guards the elapsed timer against running for
non-live sessions. Also fixes temporal consistency in tail.go by
capturing time.Now() once per event batch, and uses sessionIDFromPath
in watcher test for correctness.
Pull Request #76: Web dashboard fixes: diff stats, session replay, watcher improvements

154 of 171 new or added lines in 9 files covered. (90.06%)

241 existing lines in 6 files now uncovered.

5314 of 6579 relevant lines covered (80.77%)

198.26 hits per line

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

92.58
/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/status"
19
)
20

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

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

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

24✔
58
        return c
24✔
59
}
60

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

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

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

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

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

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

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

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

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

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

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

136
// NewLogger creates a logger writing to both a progress file and stdout.
137
// colors must be provided (created via NewColors from config).
138
// holder is the shared PhaseHolder for reading the current execution phase.
25✔
139
func NewLogger(cfg Config, colors *Colors, holder *status.PhaseHolder) (*Logger, error) {
25✔
140
        // set global color setting
37✔
141
        if cfg.NoColor {
12✔
142
                color.NoColor = true
12✔
143
        }
144

25✔
145
        progressPath := progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
25✔
146

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

25✔
154
        f, err := os.Create(progressPath) //nolint:gosec // path derived from plan filename
25✔
UNCOV
155
        if err != nil {
×
156
                return nil, fmt.Errorf("create progress file: %w", err)
×
157
        }
158

159
        // acquire exclusive lock on progress file to signal active session
160
        // the lock is held for the duration of execution and released on Close()
25✔
UNCOV
161
        if err := lockFile(f); err != nil {
×
162
                f.Close()
×
163
                return nil, fmt.Errorf("acquire file lock: %w", err)
×
164
        }
25✔
165
        registerActiveLock(f.Name())
25✔
166

25✔
167
        l := &Logger{
25✔
168
                file:      f,
25✔
169
                stdout:    os.Stdout,
25✔
170
                startTime: time.Now(),
25✔
171
                holder:    holder,
25✔
172
                colors:    colors,
25✔
173
        }
25✔
174

25✔
175
        // write header
25✔
176
        planStr := cfg.PlanFile
47✔
177
        if planStr == "" {
22✔
178
                planStr = "(no plan - review only)"
22✔
179
        }
25✔
180
        l.writeFile("# Ralphex Progress Log\n")
25✔
181
        l.writeFile("Plan: %s\n", planStr)
25✔
182
        l.writeFile("Branch: %s\n", cfg.Branch)
25✔
183
        l.writeFile("Mode: %s\n", cfg.Mode)
25✔
184
        l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
25✔
185
        l.writeFile("%s\n\n", strings.Repeat("-", 60))
25✔
186

25✔
187
        return l, nil
188
}
189

190
// Path returns the progress file path.
29✔
191
func (l *Logger) Path() string {
29✔
UNCOV
192
        if l.file == nil {
×
193
                return ""
×
194
        }
29✔
195
        return l.file.Name()
196
}
197

198
// timestampFormat is the format for timestamps: YY-MM-DD HH:MM:SS
199
const timestampFormat = "06-01-02 15:04:05"
200

201
// Print writes a timestamped message to both file and stdout.
6✔
202
func (l *Logger) Print(format string, args ...any) {
6✔
203
        msg := fmt.Sprintf(format, args...)
6✔
204
        timestamp := time.Now().Format(timestampFormat)
6✔
205

6✔
206
        // write to file without color
6✔
207
        l.writeFile("[%s] %s\n", timestamp, msg)
6✔
208

6✔
209
        // write to stdout with color
6✔
210
        phaseColor := l.colors.ForPhase(l.holder.Get())
6✔
211
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
6✔
212
        msgStr := phaseColor.Sprint(msg)
6✔
213
        l.writeStdout("%s %s\n", tsStr, msgStr)
6✔
214
}
215

216
// PrintRaw writes without timestamp (for streaming output).
1✔
217
func (l *Logger) PrintRaw(format string, args ...any) {
1✔
218
        msg := fmt.Sprintf(format, args...)
1✔
219
        l.writeFile("%s", msg)
1✔
220
        l.writeStdout("%s", msg)
1✔
221
}
222

223
// PrintSection writes a section header without timestamp in yellow.
224
// format: "\n--- {label} ---\n"
1✔
225
func (l *Logger) PrintSection(section status.Section) {
1✔
226
        header := fmt.Sprintf("\n--- %s ---\n", section.Label)
1✔
227
        l.writeFile("%s", header)
1✔
228
        l.writeStdout("%s", l.colors.Warn().Sprint(header))
1✔
229
}
230

231
// getTerminalWidth returns terminal width, using COLUMNS env var or syscall.
232
// Defaults to 80 if detection fails. Returns content width (total - 20 for timestamp).
4✔
233
func getTerminalWidth() int {
4✔
234
        const minWidth = 40
4✔
235

4✔
236
        // try COLUMNS env var first
7✔
237
        if cols := os.Getenv("COLUMNS"); cols != "" {
5✔
238
                if w, err := strconv.Atoi(cols); err == nil && w > 0 {
2✔
239
                        contentWidth := w - 20 // leave room for timestamp prefix
3✔
240
                        if contentWidth < minWidth {
1✔
241
                                return minWidth
1✔
242
                        }
1✔
243
                        return contentWidth
244
                }
245
        }
246

247
        // try terminal syscall
2✔
UNCOV
248
        if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
×
249
                contentWidth := w - 20
×
250
                if contentWidth < minWidth {
×
251
                        return minWidth
×
252
                }
×
253
                return contentWidth
254
        }
255

2✔
256
        return 80 - 20 // default 80 columns minus timestamp
257
}
258

259
// wrapText wraps text to specified width, breaking on word boundaries.
6✔
260
func wrapText(text string, width int) string {
10✔
261
        if width <= 0 || len(text) <= width {
4✔
262
                return text
4✔
263
        }
264

2✔
265
        var result strings.Builder
2✔
266
        words := strings.Fields(text)
2✔
267
        lineLen := 0
2✔
268

11✔
269
        for i, word := range words {
9✔
270
                wordLen := len(word)
9✔
271

11✔
272
                if i == 0 {
2✔
273
                        result.WriteString(word)
2✔
274
                        lineLen = wordLen
2✔
275
                        continue
276
                }
277

278
                // check if word fits on current line
12✔
279
                if lineLen+1+wordLen <= width {
5✔
280
                        result.WriteString(" ")
5✔
281
                        result.WriteString(word)
5✔
282
                        lineLen += 1 + wordLen
7✔
283
                } else {
2✔
284
                        // start new line
2✔
285
                        result.WriteString("\n")
2✔
286
                        result.WriteString(word)
2✔
287
                        lineLen = wordLen
2✔
288
                }
289
        }
290

2✔
291
        return result.String()
292
}
293

294
// PrintAligned writes text with timestamp on each line, suppressing empty lines.
2✔
295
func (l *Logger) PrintAligned(text string) {
3✔
296
        if text == "" {
1✔
297
                return
1✔
298
        }
299

300
        // trim trailing newlines to avoid extra blank lines
1✔
301
        text = strings.TrimRight(text, "\n")
1✔
UNCOV
302
        if text == "" {
×
303
                return
×
304
        }
305

1✔
306
        phaseColor := l.colors.ForPhase(l.holder.Get())
1✔
307

1✔
308
        // wrap text to terminal width
1✔
309
        width := getTerminalWidth()
1✔
310

1✔
311
        // split into lines, wrap each long line, then process
1✔
312
        var lines []string
4✔
313
        for line := range strings.SplitSeq(text, "\n") {
3✔
UNCOV
314
                if len(line) > width {
×
315
                        wrapped := wrapText(line, width)
×
316
                        for wrappedLine := range strings.SplitSeq(wrapped, "\n") {
×
317
                                lines = append(lines, wrappedLine)
×
318
                        }
3✔
319
                } else {
3✔
320
                        lines = append(lines, line)
3✔
321
                }
322
        }
323

4✔
324
        for _, line := range lines {
3✔
UNCOV
325
                if line == "" {
×
326
                        continue // skip empty lines
327
                }
328

329
                // add indent for list items
3✔
330
                displayLine := formatListItem(line)
3✔
331

3✔
332
                // timestamp each line
3✔
333
                timestamp := time.Now().Format(timestampFormat)
3✔
334
                tsPrefix := l.colors.Timestamp().Sprintf("[%s]", timestamp)
3✔
335
                l.writeFile("[%s] %s\n", timestamp, displayLine)
3✔
336

3✔
337
                // use red for signal lines
3✔
338
                lineColor := phaseColor
3✔
339

3✔
340
                // format signal lines nicely
3✔
UNCOV
341
                if sig := extractSignal(line); sig != "" {
×
342
                        displayLine = sig
×
343
                        lineColor = l.colors.Signal()
×
344
                }
345

3✔
346
                l.writeStdout("%s %s\n", tsPrefix, lineColor.Sprint(displayLine))
347
        }
348
}
349

350
// extractSignal extracts signal name from <<<RALPHEX:SIGNAL_NAME>>> format.
351
// returns empty string if no signal found.
12✔
352
func extractSignal(line string) string {
12✔
353
        const prefix = "<<<RALPHEX:"
12✔
354
        const suffix = ">>>"
12✔
355

12✔
356
        start := strings.Index(line, prefix)
18✔
357
        if start == -1 {
6✔
358
                return ""
6✔
359
        }
360

6✔
361
        end := strings.Index(line[start:], suffix)
7✔
362
        if end == -1 {
1✔
363
                return ""
1✔
364
        }
365

5✔
366
        return line[start+len(prefix) : start+end]
367
}
368

369
// formatListItem adds 2-space indent for list items (numbered or bulleted).
370
// detects patterns like "1. ", "12. ", "- ", "* " at line start.
9✔
371
func formatListItem(line string) string {
9✔
372
        trimmed := strings.TrimLeft(line, " \t")
17✔
373
        if trimmed == line { // no leading whitespace
12✔
374
                if isListItem(trimmed) {
4✔
375
                        return "  " + line
4✔
376
                }
377
        }
5✔
378
        return line
379
}
380

381
// isListItem returns true if line starts with a list marker.
19✔
382
func isListItem(line string) bool {
19✔
383
        // check for "- " or "* " (bullet lists)
23✔
384
        if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
4✔
385
                return true
4✔
386
        }
387
        // check for numbered lists like "1. ", "12. ", "123. "
40✔
388
        for i, r := range line {
36✔
389
                if r >= '0' && r <= '9' {
11✔
390
                        continue
391
                }
19✔
392
                if r == '.' && i > 0 && i < len(line)-1 && line[i+1] == ' ' {
5✔
393
                        return true
5✔
394
                }
9✔
395
                break
396
        }
10✔
397
        return false
398
}
399

400
// Error writes an error message in red.
1✔
401
func (l *Logger) Error(format string, args ...any) {
1✔
402
        msg := fmt.Sprintf(format, args...)
1✔
403
        timestamp := time.Now().Format(timestampFormat)
1✔
404

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

1✔
407
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
408
        errStr := l.colors.Error().Sprintf("ERROR: %s", msg)
1✔
409
        l.writeStdout("%s %s\n", tsStr, errStr)
1✔
410
}
411

412
// Warn writes a warning message in yellow.
1✔
413
func (l *Logger) Warn(format string, args ...any) {
1✔
414
        msg := fmt.Sprintf(format, args...)
1✔
415
        timestamp := time.Now().Format(timestampFormat)
1✔
416

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

1✔
419
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
420
        warnStr := l.colors.Warn().Sprintf("WARN: %s", msg)
1✔
421
        l.writeStdout("%s %s\n", tsStr, warnStr)
1✔
422
}
423

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

1✔
429
        l.writeFile("[%s] QUESTION: %s\n", timestamp, question)
1✔
430
        l.writeFile("[%s] OPTIONS: %s\n", timestamp, strings.Join(options, ", "))
1✔
431

1✔
432
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
433
        questionStr := l.colors.Info().Sprintf("QUESTION: %s", question)
1✔
434
        optionsStr := l.colors.Info().Sprintf("OPTIONS: %s", strings.Join(options, ", "))
1✔
435
        l.writeStdout("%s %s\n", tsStr, questionStr)
1✔
436
        l.writeStdout("%s %s\n", tsStr, optionsStr)
1✔
437
}
438

439
// LogAnswer logs the user's answer for plan creation mode.
440
// format: ANSWER: <answer>
1✔
441
func (l *Logger) LogAnswer(answer string) {
1✔
442
        timestamp := time.Now().Format(timestampFormat)
1✔
443

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

1✔
446
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
1✔
447
        answerStr := l.colors.Info().Sprintf("ANSWER: %s", answer)
1✔
448
        l.writeStdout("%s %s\n", tsStr, answerStr)
1✔
449
}
450

451
// LogDraftReview logs the user's draft review action and optional feedback.
452
// format: DRAFT REVIEW: <action>
453
// if feedback is non-empty: FEEDBACK: <feedback>
2✔
454
func (l *Logger) LogDraftReview(action, feedback string) {
2✔
455
        timestamp := time.Now().Format(timestampFormat)
2✔
456

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

2✔
459
        tsStr := l.colors.Timestamp().Sprintf("[%s]", timestamp)
2✔
460
        actionStr := l.colors.Info().Sprintf("DRAFT REVIEW: %s", action)
2✔
461
        l.writeStdout("%s %s\n", tsStr, actionStr)
2✔
462

3✔
463
        if feedback != "" {
1✔
464
                l.writeFile("[%s] FEEDBACK: %s\n", timestamp, feedback)
1✔
465
                feedbackStr := l.colors.Info().Sprintf("FEEDBACK: %s", feedback)
1✔
466
                l.writeStdout("%s %s\n", tsStr, feedbackStr)
1✔
467
        }
468
}
469

470
// LogDiffStats writes git diff stats to the progress file (file-only, no stdout).
471
// format: [timestamp] DIFFSTATS: files=F additions=A deletions=D
2✔
472
func (l *Logger) LogDiffStats(files, additions, deletions int) {
3✔
473
        if l.file == nil || files <= 0 {
1✔
474
                return
1✔
475
        }
1✔
476
        timestamp := time.Now().Format(timestampFormat)
1✔
477
        l.writeFile("[%s] DIFFSTATS: files=%d additions=%d deletions=%d\n",
1✔
478
                timestamp, files, additions, deletions)
479
}
480

481
// Elapsed returns formatted elapsed time since start.
482
func (l *Logger) Elapsed() string {
30✔
483
        return humanize.RelTime(l.startTime, time.Now(), "", "")
30✔
484
}
33✔
485

3✔
486
// Close writes footer, releases the file lock, and closes the progress file.
3✔
487
func (l *Logger) Close() error {
27✔
488
        if l.file == nil {
489
                return nil
490
        }
491

25✔
492
        l.writeFile("\n%s\n", strings.Repeat("-", 60))
25✔
UNCOV
493
        l.writeFile("Completed: %s (%s)\n", time.Now().Format("2006-01-02 15:04:05"), l.Elapsed())
×
UNCOV
494

×
495
        // release file lock before closing
496
        _ = unlockFile(l.file)
25✔
497
        unregisterActiveLock(l.file.Name())
25✔
498

25✔
499
        if err := l.file.Close(); err != nil {
25✔
500
                return fmt.Errorf("close progress file: %w", err)
25✔
501
        }
25✔
502
        return nil
25✔
503
}
25✔
UNCOV
504

×
UNCOV
505
func (l *Logger) writeFile(format string, args ...any) {
×
506
        if l.file != nil {
25✔
507
                fmt.Fprintf(l.file, format, args...)
508
        }
509
}
220✔
510

440✔
511
func (l *Logger) writeStdout(format string, args ...any) {
220✔
512
        fmt.Fprintf(l.stdout, format, args...)
220✔
513
}
514

515
// getProgressFilename returns progress file path based on plan and mode.
19✔
516
func progressFilename(planFile, planDescription, mode string) string {
19✔
517
        // plan mode uses sanitized plan description
19✔
518
        if mode == "plan" && planDescription != "" {
519
                sanitized := sanitizePlanName(planDescription)
520
                return fmt.Sprintf("progress-plan-%s.txt", sanitized)
36✔
521
        }
36✔
522

44✔
523
        if planFile != "" {
8✔
524
                stem := strings.TrimSuffix(filepath.Base(planFile), ".md")
8✔
525
                switch mode {
8✔
526
                case "codex-only":
527
                        return fmt.Sprintf("progress-%s-codex.txt", stem)
35✔
528
                case "review":
7✔
529
                        return fmt.Sprintf("progress-%s-review.txt", stem)
7✔
530
                default:
2✔
531
                        return fmt.Sprintf("progress-%s.txt", stem)
2✔
532
                }
2✔
533
        }
2✔
534

3✔
535
        switch mode {
3✔
536
        case "codex-only":
537
                return "progress-codex.txt"
538
        case "review":
539
                return "progress-review.txt"
21✔
540
        case "plan":
2✔
541
                return "progress-plan.txt"
2✔
542
        default:
2✔
543
                return "progress.txt"
2✔
544
        }
2✔
545
}
2✔
546

15✔
547
// sanitizePlanName converts plan description to a safe filename component.
15✔
548
// replaces spaces with dashes, removes special characters, and limits length.
549
func sanitizePlanName(desc string) string {
550
        // lowercase and replace spaces with dashes
551
        result := strings.ToLower(desc)
552
        result = strings.ReplaceAll(result, " ", "-")
553

19✔
554
        // keep only alphanumeric and dashes
19✔
555
        var clean strings.Builder
19✔
556
        for _, r := range result {
19✔
557
                if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
19✔
558
                        clean.WriteRune(r)
19✔
559
                }
19✔
560
        }
331✔
561
        result = clean.String()
613✔
562

301✔
563
        // collapse multiple dashes
301✔
564
        for strings.Contains(result, "--") {
565
                result = strings.ReplaceAll(result, "--", "-")
19✔
566
        }
19✔
567

19✔
568
        // trim leading/trailing dashes
22✔
569
        result = strings.Trim(result, "-")
3✔
570

3✔
571
        // limit length to 50 characters
572
        if len(result) > 50 {
573
                result = result[:50]
19✔
574
                // don't end with a dash
19✔
575
                result = strings.TrimRight(result, "-")
19✔
576
        }
21✔
577

2✔
578
        if result == "" {
2✔
579
                return "unnamed"
2✔
580
        }
2✔
581
        return result
582
}
21✔
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