byob-go-cli

Progress as a narrow interface on the Factory

byob-progress.1 factory-diinterfacesprogress

Problem: every spinner/bar library (charmbracelet/bubbles, schollz/progressbar, briandowns/spinner) exposes a different shape. Baking one into command code couples every caller to that library and makes the test path awkward.

Idea: a narrow interface constructed per-operation via the Factory (byob-interfaces.1). Not a field — a factory method, because each progress instance is operation-scoped. The interface has four methods: Start(), Update(msg string), Stop(), Fail(err error). Stop and Fail(err) are mutually-exclusive terminal states — exactly one is called per operation, after any number of Updates. Calling either after the operation has already terminated is a no-op. Consumer code is library-agnostic; the interface lives in pkg/cmd/progress/.

Factory shape: f.Progress(ctx context.Context, label string) progress.Progress. The returned instance encapsulates the TTY-vs-logging decision (byob-progress.2) internally so callers don't branch on it. The ctx is captured at construction and watched inside the impl: if it cancels mid-operation the impl calls its own Stop() so the spinner doesn't keep rendering under a dead command. Happy-path Stop/Fail still belongs to the caller (usually defer p.Stop() right after p.Start()).

Tradeoffs: four methods is deliberately thin. No nested sub-progress, no multi-line bars, no simultaneous concurrent progresses. Tools that need those add them as separate interfaces (ProgressGroup), not by stretching this one.

Design

// pkg/cmd/progress/progress.go
package progress

type Progress interface {
    Start()
    Update(msg string)
    Stop()
    Fail(err error)
}

// pkg/cmdutil/factory.go
func (f *Factory) Progress(ctx context.Context, label string) progress.Progress {
    if f.IOStreams.IsStderrTTY() {
        return progress.NewSpinner(ctx, f.IOStreams.ErrOut, label)
    }
    return progress.NewLogging(ctx, f.IOStreams.ErrOut, label)
}

// Usage:
p := f.Progress(ctx, "fetching items")
p.Start()
defer p.Stop()
for i, id := range ids {
    p.Update(fmt.Sprintf("item %d/%d", i+1, len(ids)))
    // ...
}

For known-total progress bars, a sibling method: f.ProgressBar(ctx context.Context, label string, total int) progress.Progress. Same interface, different constructor, different library underneath.