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

fogfish / iq / 19150713757

06 Nov 2025 09:40PM UTC coverage: 40.295%. First build
19150713757

Pull #26

github

fogfish
fix linter issues
Pull Request #26: Compose AI workflow within the shell

1097 of 2630 new or added lines in 34 files covered. (41.71%)

1121 of 2782 relevant lines covered (40.29%)

0.43 hits per line

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

32.33
/internal/progress/progress.go
1
//
2
// Copyright (C) 2025 Dmitry Kolesnikov
3
//
4
// This file may be modified and distributed under the terms
5
// of the MIT license.  See the LICENSE file for details.
6
// https://github.com/fogfish/iq
7
//
8

9
package progress
10

11
import (
12
        "fmt"
13
        "io"
14
        "os"
15
        "strings"
16
        "sync"
17
        "time"
18

19
        "github.com/nickwells/twrap.mod/twrap"
20
        "golang.org/x/term"
21
)
22

23
// Icons used for progress reporting - centralized for easy customization
24
const (
25
        IconWorkflowLoad     = "📋" // Loading workflow file
26
        IconWorkflowCompiled = "✅" // Workflow successfully compiled
27
        IconWorkflowError    = "❌" // Workflow error
28

29
        IconDocumentStart    = "📄"  // Document processing started
30
        IconDocumentComplete = "✅"  // Document processed successfully
31
        IconDocumentError    = "❌"  // Document processing error
32
        IconDocumentSkipped  = "⏭️" // Document skipped
33

34
        IconStepProcessing = "⚙️" // Step is processing (mutable line)
35
        IconStepComplete   = "✅"  // Step completed successfully
36
        IconStepError      = "❌"  // Step failed with error
37

38
        IconRouterEvaluating = "🧭" // Router evaluating conditions
39
        IconRouterMatched    = "↳" // Router matched a route (text, not emoji)
40

41
        IconForeachStart = "🔁" // Foreach loop started
42
        IconForeachItem  = "✅" // Foreach item completed
43

44
        IconRetryAttempt   = "🔄" // Retry attempt
45
        IconRetrySuccess   = "✅" // Retry succeeded
46
        IconRetryExhausted = "❌" // All retries exhausted
47

48
        IconBatchProcessing = "📂"  // Batch processing
49
        IconChunking        = "✂️" // Document chunking
50

51
        IconThinking = "💭" // LLM thinking/reasoning content
52

53
        IconSummary = "📊"  // Pipeline summary
54
        IconWarning = "⚠️" // Warning message
55
)
56

57
// Reporter handles all progress output to stderr with educational messages
58
type Reporter struct {
59
        w           io.Writer
60
        mu          sync.Mutex
61
        quiet       bool
62
        lastLine    string
63
        hasThinking bool // Set to true when thinking content is shown
64
        startTime   time.Time
65
        stats       Stats
66
}
67

68
// Stats tracks processing metrics
69
type Stats struct {
70
        DocsProcessed int
71
        DocsSkipped   int
72
        DocsErrors    int
73
        TokensInput   int
74
        TokensOutput  int
75
        Errors        []error
76
}
77

78
// New creates a new progress reporter
79
func New(quiet bool) *Reporter {
1✔
80
        return &Reporter{
1✔
81
                w:         os.Stderr,
1✔
82
                quiet:     quiet,
1✔
83
                startTime: time.Now(),
1✔
84
        }
1✔
85
}
1✔
86

87
// NewWithWriter creates a reporter with a custom writer (for testing)
88
func NewWithWriter(w io.Writer, quiet bool) *Reporter {
1✔
89
        return &Reporter{
1✔
90
                w:         w,
1✔
91
                quiet:     quiet,
1✔
92
                startTime: time.Now(),
1✔
93
        }
1✔
94
}
1✔
95

96
// Workflow lifecycle events
97

98
// WorkflowLoading indicates workflow file is being loaded
99
func (r *Reporter) WorkflowLoading(path string) {
1✔
100
        r.println(fmt.Sprintf("%s Loading workflow from %s", IconWorkflowLoad, path))
1✔
101
}
1✔
102

103
// WorkflowCompiled indicates successful workflow compilation
NEW
104
func (r *Reporter) WorkflowCompiled(name string, jobCount, stepCount int) {
×
NEW
105
        if name == "" {
×
NEW
106
                name = "(unnamed)"
×
NEW
107
        }
×
NEW
108
        r.println(fmt.Sprintf("%s Workflow compiled: \"%s\" (%d jobs, %d steps)", IconWorkflowCompiled, name, jobCount, stepCount))
×
NEW
109
        r.println("")
×
110
}
111

112
// WorkflowError reports a workflow-level error
NEW
113
func (r *Reporter) WorkflowError(err error) {
×
NEW
114
        r.println(fmt.Sprintf("%s Workflow error: %v", IconWorkflowError, err))
×
NEW
115
}
×
116

117
// Document processing events
118

119
// DocumentStart indicates a document is starting to process
120
func (r *Reporter) DocumentStart(path string, sizeKB float64) {
1✔
121
        if sizeKB > 0 {
2✔
122
                r.println(fmt.Sprintf("%s Processing: %s (%.1f KB)", IconDocumentStart, path, sizeKB))
1✔
123
        } else {
1✔
NEW
124
                r.println(fmt.Sprintf("%s Processing: %s", IconDocumentStart, path))
×
NEW
125
        }
×
126
}
127

128
// DocumentComplete indicates successful document completion
129
func (r *Reporter) DocumentComplete(path string, duration time.Duration) {
1✔
130
        r.stats.DocsProcessed++
1✔
131
        r.println(fmt.Sprintf("%s Completed: %s (%.1fs total)", IconDocumentComplete, path, duration.Seconds()))
1✔
132
        r.println("")
1✔
133
}
1✔
134

135
// DocumentError reports a document processing error
NEW
136
func (r *Reporter) DocumentError(path string, err error) {
×
NEW
137
        r.stats.DocsErrors++
×
NEW
138
        r.stats.Errors = append(r.stats.Errors, err)
×
NEW
139
        r.println(fmt.Sprintf("%s Failed: %s - %v", IconDocumentError, path, err))
×
NEW
140
}
×
141

142
// DocumentSkipped indicates a document was skipped
NEW
143
func (r *Reporter) DocumentSkipped(path string, reason string) {
×
NEW
144
        r.stats.DocsSkipped++
×
NEW
145
        r.println(fmt.Sprintf("%s Skipped: %s (%s)", IconDocumentSkipped, path, reason))
×
NEW
146
}
×
147

148
// Step execution events
149

150
// StepStart indicates a workflow step is starting
151
func (r *Reporter) StepStart(jobName, stepName string, stepNum, totalSteps int) {
1✔
152
        r.mu.Lock()
1✔
153
        r.hasThinking = false // Reset thinking flag for new step
1✔
154
        r.mu.Unlock()
1✔
155

1✔
156
        if stepName != "" {
2✔
157
                r.updateLine(fmt.Sprintf("   %s Step %d/%d: %s.%s → Processing...",
1✔
158
                        IconStepProcessing, stepNum, totalSteps, jobName, stepName))
1✔
159
        } else {
1✔
NEW
160
                r.updateLine(fmt.Sprintf("   %s Step %d/%d: %s → Processing...",
×
NEW
161
                        IconStepProcessing, stepNum, totalSteps, jobName))
×
NEW
162
        }
×
163
}
164

165
// StepProgress updates the current step status (mutable)
NEW
166
func (r *Reporter) StepProgress(message string) {
×
NEW
167
        r.updateLine(fmt.Sprintf("   %s %s", IconStepProcessing, message))
×
NEW
168
}
×
169

170
// StepComplete indicates step completion
171
func (r *Reporter) StepComplete(jobName, stepName string, stepNum, totalSteps int, duration time.Duration, tokens int) {
1✔
172
        if tokens > 0 {
2✔
173
                if stepName != "" {
2✔
174
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s.%s completed in %.1fs (%d tokens)",
1✔
175
                                IconStepComplete, stepNum, totalSteps, jobName, stepName, duration.Seconds(), tokens))
1✔
176
                } else {
1✔
NEW
177
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s completed in %.1fs (%d tokens)",
×
NEW
178
                                IconStepComplete, stepNum, totalSteps, jobName, duration.Seconds(), tokens))
×
NEW
179
                }
×
NEW
180
        } else {
×
NEW
181
                if stepName != "" {
×
NEW
182
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s.%s completed in %.1fs",
×
NEW
183
                                IconStepComplete, stepNum, totalSteps, jobName, stepName, duration.Seconds()))
×
NEW
184
                } else {
×
NEW
185
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s completed in %.1fs",
×
NEW
186
                                IconStepComplete, stepNum, totalSteps, jobName, duration.Seconds()))
×
NEW
187
                }
×
188
        }
189
        r.stats.TokensInput += tokens
1✔
190
}
191

192
// StepError reports a step error
NEW
193
func (r *Reporter) StepError(jobName, stepName string, stepNum, totalSteps int, err error) {
×
NEW
194
        if stepName != "" {
×
NEW
195
                r.println(fmt.Sprintf("   %s Step %d/%d: %s.%s failed - %v",
×
NEW
196
                        IconStepError, stepNum, totalSteps, jobName, stepName, err))
×
NEW
197
        } else {
×
NEW
198
                r.println(fmt.Sprintf("   %s Step %d/%d: %s failed - %v",
×
NEW
199
                        IconStepError, stepNum, totalSteps, jobName, err))
×
NEW
200
        }
×
201
}
202

203
// Router and control flow events
204

205
// RouterEvaluating indicates router is evaluating conditions
NEW
206
func (r *Reporter) RouterEvaluating() {
×
NEW
207
        r.updateLine(fmt.Sprintf("   %s Router evaluating conditions...", IconRouterEvaluating))
×
NEW
208
}
×
209

210
// RouterMatched indicates router matched a route
NEW
211
func (r *Reporter) RouterMatched(routeName string, targetJob string) {
×
NEW
212
        r.println(fmt.Sprintf("   %s Routing decision:", IconRouterEvaluating))
×
NEW
213
        r.println(fmt.Sprintf("      %s Matched route: %s → %s", IconRouterMatched, routeName, targetJob))
×
NEW
214
        r.println("")
×
NEW
215
}
×
216

217
// RouterDefault indicates router is using default route
NEW
218
func (r *Reporter) RouterDefault(targetJob string) {
×
NEW
219
        r.println(fmt.Sprintf("   %s Routing decision:", IconRouterEvaluating))
×
NEW
220
        r.println(fmt.Sprintf("      %s Using default route → %s", IconRouterMatched, targetJob))
×
NEW
221
        r.println("")
×
NEW
222
}
×
223

224
// RouterNoMatch indicates no route was matched
NEW
225
func (r *Reporter) RouterNoMatch() {
×
NEW
226
        r.println(fmt.Sprintf("   %s Router: No matching route found", IconWarning))
×
NEW
227
}
×
228

229
// Foreach iteration events
230

231
// ForeachStart indicates foreach loop is starting
NEW
232
func (r *Reporter) ForeachStart(itemCount int) {
×
NEW
233
        r.println(fmt.Sprintf("   %s Foreach: Processing %d items", IconForeachStart, itemCount))
×
NEW
234
}
×
235

236
// ForeachItem reports progress on a foreach item
NEW
237
func (r *Reporter) ForeachItem(index, total int, status string) {
×
NEW
238
        r.println(fmt.Sprintf("      [%d/%d] %s", index, total, status))
×
NEW
239
}
×
240

241
// ForeachComplete indicates foreach loop completed
NEW
242
func (r *Reporter) ForeachComplete(successCount, totalCount int, duration time.Duration) {
×
NEW
243
        r.println(fmt.Sprintf("   %s Foreach completed: %d/%d items succeeded (%.1fs)",
×
NEW
244
                IconForeachItem, successCount, totalCount, duration.Seconds()))
×
NEW
245
        r.println("")
×
NEW
246
}
×
247

248
// Retry and recovery events
249

250
// RetryAttempt indicates a retry is being attempted
NEW
251
func (r *Reporter) RetryAttempt(attempt, maxAttempts int, delay time.Duration) {
×
NEW
252
        if attempt == 1 {
×
NEW
253
                r.println(fmt.Sprintf("   %s Step failed (attempt %d/%d)", IconWarning, attempt, maxAttempts))
×
NEW
254
                if delay > 0 {
×
NEW
255
                        r.println(fmt.Sprintf("      %s Retrying in %.0fs...", IconRouterMatched, delay.Seconds()))
×
NEW
256
                }
×
NEW
257
        } else {
×
NEW
258
                r.updateLine(fmt.Sprintf("   %s Retry attempt %d/%d...", IconRetryAttempt, attempt, maxAttempts))
×
NEW
259
        }
×
260
}
261

262
// RetrySuccess indicates retry succeeded
NEW
263
func (r *Reporter) RetrySuccess(attempt int) {
×
NEW
264
        r.println(fmt.Sprintf("   %s Retry succeeded on attempt %d", IconRetrySuccess, attempt))
×
NEW
265
}
×
266

267
// RetryExhausted indicates all retry attempts failed
NEW
268
func (r *Reporter) RetryExhausted(maxAttempts int) {
×
NEW
269
        r.println(fmt.Sprintf("   %s All %d retry attempts exhausted", IconRetryExhausted, maxAttempts))
×
NEW
270
}
×
271

272
// LLM thinking events
273

274
// ThinkingContent displays LLM thinking/reasoning content
275
// This is used when --llm-think is enabled to show intermediate reasoning
NEW
276
func (r *Reporter) ThinkingContent(text string) {
×
NEW
277
        r.mu.Lock()
×
NEW
278
        defer r.mu.Unlock()
×
NEW
279

×
NEW
280
        if r.quiet {
×
NEW
281
                return
×
NEW
282
        }
×
283

284
        // Mark that we've shown thinking content
NEW
285
        r.hasThinking = true
×
NEW
286

×
NEW
287
        // Store the current processing line to restore it after thinking
×
NEW
288
        processingLine := r.lastLine
×
NEW
289

×
NEW
290
        // If there's a mutable line (e.g., "   ⚙️  Step 1/2: main → Processing..."),
×
NEW
291
        // finalize it before showing thinking header
×
NEW
292
        if r.lastLine != "" {
×
NEW
293
                fmt.Fprintln(r.w) // Move to new line, keep the processing line visible
×
NEW
294
                r.lastLine = ""
×
NEW
295
        }
×
296

297
        // Print thinking header (e.g., "   💭 Step 1/2: main → Thinking")
NEW
298
        if processingLine != "" {
×
NEW
299
                // Extract step info from processing line and create thinking header
×
NEW
300
                thinkingHeader := strings.Replace(processingLine, "Processing", "Thinking", 1)
×
NEW
301
                thinkingHeader = strings.Replace(thinkingHeader, IconStepProcessing, IconThinking, 1)
×
NEW
302
                fmt.Fprintln(r.w, thinkingHeader)
×
NEW
303
        }
×
304

305
        // Get terminal width for wrapping (default to 80 if not a terminal)
NEW
306
        width := 80
×
NEW
307
        if fd, ok := r.w.(*os.File); ok {
×
NEW
308
                if w, _, err := term.GetSize(int(fd.Fd())); err == nil && w > 0 {
×
NEW
309
                        width = w
×
NEW
310
                }
×
311
        }
312

313
        // Print thinking content with proper indentation and wrapping
314
        // Format: "   💭 <first line text>"
315
        //         "      <continuation lines>"
316

317
        // Use a buffer to capture wrapped output
NEW
318
        var buf strings.Builder
×
NEW
319
        tw, err := twrap.NewTWConf(
×
NEW
320
                twrap.SetWriter(&buf),
×
NEW
321
                twrap.SetTargetLineLen(width-6), // Reserve space for indent (6 chars: 3 spaces + "💭 ")
×
NEW
322
        )
×
NEW
323
        if err != nil {
×
NEW
324
                // Fallback: print without wrapping if configuration fails
×
NEW
325
                fmt.Fprintf(r.w, "   %s %s\n", IconThinking, text)
×
NEW
326
                return
×
NEW
327
        }
×
328

329
        // Wrap text without indent first
NEW
330
        tw.Wrap(text, 0)
×
NEW
331

×
NEW
332
        // Now add indentation to each line (thinking content is indented)
×
NEW
333
        lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
×
NEW
334
        for _, line := range lines {
×
NEW
335
                fmt.Fprintf(r.w, "      %s\n", line) // All lines indented (no emoji on content lines)
×
NEW
336
        }
×
337

338
        // Restore the processing line as mutable after thinking
NEW
339
        if processingLine != "" {
×
NEW
340
                r.lastLine = processingLine
×
NEW
341
                fmt.Fprint(r.w, processingLine) // Print without newline (mutable)
×
NEW
342
        } else {
×
NEW
343
                fmt.Fprintln(r.w) // Just add blank line if no processing line to restore
×
NEW
344
        }
×
345
}
346

347
// Batch processing events
348

349
// BatchStart indicates batch processing is starting
NEW
350
func (r *Reporter) BatchStart(inputPath, outputPath string, mutable bool) {
×
NEW
351
        if mutable {
×
NEW
352
                r.println(fmt.Sprintf("%s Batch processing: %s → %s (mutable mode)", IconBatchProcessing, inputPath, outputPath))
×
NEW
353
        } else {
×
NEW
354
                r.println(fmt.Sprintf("%s Batch processing: %s → %s", IconBatchProcessing, inputPath, outputPath))
×
NEW
355
        }
×
356
}
357

358
// BatchProgress reports batch processing progress
NEW
359
func (r *Reporter) BatchProgress(processed, total int) {
×
NEW
360
        if total > 0 {
×
NEW
361
                percent := int(float64(processed) / float64(total) * 100)
×
NEW
362
                bars := percent / 5 // 20 bars total
×
NEW
363
                progress := strings.Repeat("█", bars) + strings.Repeat("░", 20-bars)
×
NEW
364
                r.updateLine(fmt.Sprintf("Processing: %s %d/%d files (%d%%)",
×
NEW
365
                        progress, processed, total, percent))
×
NEW
366
        } else {
×
NEW
367
                r.updateLine(fmt.Sprintf("Processing: %d files...", processed))
×
NEW
368
        }
×
369
}
370

371
// BatchComplete finalizes batch progress
NEW
372
func (r *Reporter) BatchComplete() {
×
NEW
373
        if r.lastLine != "" {
×
NEW
374
                r.println("") // Move to new line
×
NEW
375
        }
×
376
}
377

378
// Chunking events
379

380
// ChunkingStart indicates document is being split
NEW
381
func (r *Reporter) ChunkingStart(strategy string, chunkCount int) {
×
NEW
382
        r.println(fmt.Sprintf("   %s Splitting into %d chunks (strategy: %s)", IconChunking, chunkCount, strategy))
×
NEW
383
}
×
384

385
// Summary and statistics
386

387
// Summary prints final execution summary
NEW
388
func (r *Reporter) Summary() {
×
NEW
389
        duration := time.Since(r.startTime)
×
NEW
390

×
NEW
391
        r.println("")
×
NEW
392
        r.println(fmt.Sprintf("%s Pipeline Summary:", IconSummary))
×
NEW
393

×
NEW
394
        if r.stats.DocsProcessed > 0 {
×
NEW
395
                r.println(fmt.Sprintf("   %s Processed: %d documents", IconDocumentComplete, r.stats.DocsProcessed))
×
NEW
396
        }
×
397

NEW
398
        if r.stats.DocsSkipped > 0 {
×
NEW
399
                r.println(fmt.Sprintf("   %s Skipped: %d documents", IconDocumentSkipped, r.stats.DocsSkipped))
×
NEW
400
        }
×
401

NEW
402
        if r.stats.DocsErrors > 0 {
×
NEW
403
                r.println(fmt.Sprintf("   %s Errors: %d documents", IconWarning, r.stats.DocsErrors))
×
NEW
404
        }
×
405

NEW
406
        if r.stats.TokensInput > 0 || r.stats.TokensOutput > 0 {
×
NEW
407
                total := r.stats.TokensInput + r.stats.TokensOutput
×
NEW
408
                r.println(fmt.Sprintf("   📈 Tokens: %s (input: %s | output: %s)",
×
NEW
409
                        formatNumber(total),
×
NEW
410
                        formatNumber(r.stats.TokensInput),
×
NEW
411
                        formatNumber(r.stats.TokensOutput)))
×
NEW
412
        }
×
413

NEW
414
        r.println(fmt.Sprintf("   ⏱️  Duration: %s", formatDuration(duration)))
×
NEW
415

×
NEW
416
        // Add visual separator before output
×
NEW
417
        r.println("")
×
NEW
418
        r.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
×
NEW
419
        r.println("")
×
420
}
421

422
// SummaryQuick prints a compact one-line summary
NEW
423
func (r *Reporter) SummaryQuick(docCount int, duration time.Duration) {
×
NEW
424
        tokens := r.stats.TokensInput + r.stats.TokensOutput
×
NEW
425
        if tokens > 0 {
×
NEW
426
                r.println(fmt.Sprintf("\n📊 Summary: %d document(s), %s tokens, %s",
×
NEW
427
                        docCount, formatNumber(tokens), formatDuration(duration)))
×
NEW
428
        } else {
×
NEW
429
                r.println(fmt.Sprintf("\n📊 Summary: %d document(s), %s",
×
NEW
430
                        docCount, formatDuration(duration)))
×
NEW
431
        }
×
432
}
433

434
// Helper methods
435

436
// UpdateTokens updates token statistics
437
func (r *Reporter) UpdateTokens(inputTokens, outputTokens int) {
1✔
438
        r.mu.Lock()
1✔
439
        defer r.mu.Unlock()
1✔
440
        r.stats.TokensInput += inputTokens
1✔
441
        r.stats.TokensOutput += outputTokens
1✔
442
}
1✔
443

444
// GetStats returns a copy of current statistics
445
func (r *Reporter) GetStats() Stats {
1✔
446
        r.mu.Lock()
1✔
447
        defer r.mu.Unlock()
1✔
448
        return r.stats
1✔
449
}
1✔
450

451
// updateLine updates the current line (mutable output)
452
func (r *Reporter) updateLine(text string) {
1✔
453
        if r.quiet {
2✔
454
                return
1✔
455
        }
1✔
456
        r.mu.Lock()
1✔
457
        defer r.mu.Unlock()
1✔
458

1✔
459
        // Clear previous line
1✔
460
        if r.lastLine != "" {
1✔
NEW
461
                fmt.Fprintf(r.w, "\r\033[K")
×
NEW
462
        }
×
463

464
        fmt.Fprint(r.w, text)
1✔
465
        r.lastLine = text
1✔
466
}
467

468
// println writes a new line (finalize any mutable line first)
469
func (r *Reporter) println(text string) {
1✔
470
        if r.quiet {
2✔
471
                return
1✔
472
        }
1✔
473
        r.mu.Lock()
1✔
474
        defer r.mu.Unlock()
1✔
475

1✔
476
        if r.lastLine != "" {
2✔
477
                if r.hasThinking {
1✔
NEW
478
                        // In thinking mode: keep the previous line, just move to next line
×
NEW
479
                        fmt.Fprintln(r.w)
×
480
                } else {
1✔
481
                        // Without thinking: clear the mutable line and replace it
1✔
482
                        fmt.Fprint(r.w, "\r\033[K")
1✔
483
                }
1✔
484
                r.lastLine = ""
1✔
485
        }
486
        fmt.Fprintln(r.w, text)
1✔
487
}
488

489
// Formatting helpers
490

491
func formatNumber(n int) string {
1✔
492
        if n >= 1000000 {
2✔
493
                return fmt.Sprintf("%.1fM", float64(n)/1000000)
1✔
494
        }
1✔
495
        if n >= 1000 {
2✔
496
                return fmt.Sprintf("%.1fK", float64(n)/1000)
1✔
497
        }
1✔
498
        return fmt.Sprintf("%d", n)
1✔
499
}
500

501
func formatDuration(d time.Duration) string {
1✔
502
        if d < time.Second {
2✔
503
                return fmt.Sprintf("%.0fms", d.Seconds()*1000)
1✔
504
        }
1✔
505
        if d < time.Minute {
2✔
506
                return fmt.Sprintf("%.1fs", d.Seconds())
1✔
507
        }
1✔
508
        minutes := int(d.Minutes())
1✔
509
        seconds := int(d.Seconds()) % 60
1✔
510
        return fmt.Sprintf("%dm %ds", minutes, seconds)
1✔
511
}
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