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

umputun / ralphex / 21803280876

08 Feb 2026 06:36PM UTC coverage: 80.925% (+0.2%) from 80.726%
21803280876

Pull #76

github

melonamin
feat(web): add git diff stats display and improve session replay

- Add diff stats (files/additions/deletions) to dashboard header,
  parsed from DIFFSTATS metadata in progress files
- Fix deferred section emission so section timestamps align with
  log timestamps during session replay from start
- Add priority-based event dropping when tailer channel is full,
  preserving section/signal events over regular output
- Fix active task highlighting and execution timer seeding
- Add LogDiffStats to progress logger (file-only, no stdout)
- Add tests for parseLineDeferred, emitPendingSection, sendEvent
Pull Request #76: Web dashboard fixes: diff stats, session replay, watcher improvements

141 of 165 new or added lines in 8 files covered. (85.45%)

2 existing lines in 1 file now uncovered.

5006 of 6186 relevant lines covered (80.92%)

155.83 hits per line

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

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

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

22✔
58
        return c
22✔
59
}
22✔
60

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

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

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

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

98
// ForPhase returns the color for the given execution phase.
99
func (c *Colors) ForPhase(p status.Phase) *color.Color { return c.phases[p] }
15✔
100

101
// Timestamp returns the timestamp color.
102
func (c *Colors) Timestamp() *color.Color { return c.timestamp }
17✔
103

104
// Warn returns the warning color.
105
func (c *Colors) Warn() *color.Color { return c.warn }
4✔
106

107
// Error returns the error color.
108
func (c *Colors) Error() *color.Color { return c.err }
3✔
109

110
// Signal returns the signal color.
111
func (c *Colors) Signal() *color.Color { return c.signal }
2✔
112

113
// Logger writes timestamped output to both file and stdout.
114
type Logger struct {
115
        file      *os.File
116
        stdout    io.Writer
117
        startTime time.Time
118
        phase     status.Phase
119
        colors    *Colors
120
}
121

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

131
// NewLogger creates a logger writing to both a progress file and stdout.
132
// colors must be provided (created via NewColors from config).
133
func NewLogger(cfg Config, colors *Colors) (*Logger, error) {
23✔
134
        // set global color setting
23✔
135
        if cfg.NoColor {
35✔
136
                color.NoColor = true
12✔
137
        }
12✔
138

139
        progressPath := progressFilename(cfg.PlanFile, cfg.PlanDescription, cfg.Mode)
23✔
140

23✔
141
        // ensure progress files are tracked by creating parent dir
23✔
142
        if dir := filepath.Dir(progressPath); dir != "." {
23✔
143
                if err := os.MkdirAll(dir, 0o750); err != nil {
×
144
                        return nil, fmt.Errorf("create progress dir: %w", err)
×
145
                }
×
146
        }
147

148
        f, err := os.Create(progressPath) //nolint:gosec // path derived from plan filename
23✔
149
        if err != nil {
23✔
150
                return nil, fmt.Errorf("create progress file: %w", err)
×
151
        }
×
152

153
        // acquire exclusive lock on progress file to signal active session
154
        // the lock is held for the duration of execution and released on Close()
155
        if err := lockFile(f); err != nil {
23✔
156
                f.Close()
×
157
                return nil, fmt.Errorf("acquire file lock: %w", err)
×
158
        }
×
159
        registerActiveLock(f.Name())
23✔
160

23✔
161
        l := &Logger{
23✔
162
                file:      f,
23✔
163
                stdout:    os.Stdout,
23✔
164
                startTime: time.Now(),
23✔
165
                phase:     status.PhaseTask,
23✔
166
                colors:    colors,
23✔
167
        }
23✔
168

23✔
169
        // write header
23✔
170
        planStr := cfg.PlanFile
23✔
171
        if planStr == "" {
43✔
172
                planStr = "(no plan - review only)"
20✔
173
        }
20✔
174
        l.writeFile("# Ralphex Progress Log\n")
23✔
175
        l.writeFile("Plan: %s\n", planStr)
23✔
176
        l.writeFile("Branch: %s\n", cfg.Branch)
23✔
177
        l.writeFile("Mode: %s\n", cfg.Mode)
23✔
178
        l.writeFile("Started: %s\n", time.Now().Format("2006-01-02 15:04:05"))
23✔
179
        l.writeFile("%s\n\n", strings.Repeat("-", 60))
23✔
180

23✔
181
        return l, nil
23✔
182
}
183

184
// Path returns the progress file path.
185
func (l *Logger) Path() string {
27✔
186
        if l.file == nil {
27✔
187
                return ""
×
188
        }
×
189
        return l.file.Name()
27✔
190
}
191

192
// SetPhase sets the current execution phase for color coding.
193
func (l *Logger) SetPhase(phase status.Phase) {
4✔
194
        l.phase = phase
4✔
195
}
4✔
196

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

305
        phaseColor := l.colors.ForPhase(l.phase)
1✔
306

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

23✔
494
        // release file lock before closing
23✔
495
        _ = unlockFile(l.file)
23✔
496
        unregisterActiveLock(l.file.Name())
23✔
497

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

504
func (l *Logger) writeFile(format string, args ...any) {
203✔
505
        if l.file != nil {
406✔
506
                fmt.Fprintf(l.file, format, args...)
203✔
507
        }
203✔
508
}
509

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

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

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

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

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

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

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

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

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

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