feat(cli): add ProgressBar with Increment, Set, SetMessage, Done

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-21 18:10:01 +00:00
parent 50afecea6d
commit 175ad1e361
2 changed files with 166 additions and 0 deletions

106
pkg/cli/progressbar.go Normal file
View file

@ -0,0 +1,106 @@
package cli
import (
"fmt"
"strings"
"sync"
)
// ProgressHandle controls a progress bar.
type ProgressHandle struct {
mu sync.Mutex
current int
total int
message string
width int
}
// NewProgressBar creates a new progress bar with the given total.
func NewProgressBar(total int) *ProgressHandle {
return &ProgressHandle{
total: total,
width: 30,
}
}
// Current returns the current progress value.
func (p *ProgressHandle) Current() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.current
}
// Total returns the total value.
func (p *ProgressHandle) Total() int {
return p.total
}
// Increment advances the progress by 1.
func (p *ProgressHandle) Increment() {
p.mu.Lock()
defer p.mu.Unlock()
if p.current < p.total {
p.current++
}
p.render()
}
// Set sets the progress to a specific value.
func (p *ProgressHandle) Set(n int) {
p.mu.Lock()
defer p.mu.Unlock()
if n > p.total {
n = p.total
}
if n < 0 {
n = 0
}
p.current = n
p.render()
}
// SetMessage sets the message displayed alongside the bar.
func (p *ProgressHandle) SetMessage(msg string) {
p.mu.Lock()
defer p.mu.Unlock()
p.message = msg
p.render()
}
// Done completes the progress bar and moves to a new line.
func (p *ProgressHandle) Done() {
p.mu.Lock()
defer p.mu.Unlock()
p.current = p.total
p.render()
fmt.Println()
}
// String returns the rendered progress bar without ANSI cursor control.
func (p *ProgressHandle) String() string {
pct := 0
if p.total > 0 {
pct = (p.current * 100) / p.total
}
filled := 0
if p.total > 0 {
filled = (p.width * p.current) / p.total
}
if filled > p.width {
filled = p.width
}
empty := p.width - filled
bar := "[" + strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", empty) + "]"
if p.message != "" {
return fmt.Sprintf("%s %3d%% %s", bar, pct, p.message)
}
return fmt.Sprintf("%s %3d%%", bar, pct)
}
// render outputs the progress bar, overwriting the current line.
func (p *ProgressHandle) render() {
fmt.Printf("\033[2K\r%s", p.String())
}

View file

@ -0,0 +1,60 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProgressBar_Good_Create(t *testing.T) {
pb := NewProgressBar(100)
require.NotNil(t, pb)
assert.Equal(t, 0, pb.Current())
assert.Equal(t, 100, pb.Total())
}
func TestProgressBar_Good_Increment(t *testing.T) {
pb := NewProgressBar(10)
pb.Increment()
assert.Equal(t, 1, pb.Current())
pb.Increment()
assert.Equal(t, 2, pb.Current())
}
func TestProgressBar_Good_SetMessage(t *testing.T) {
pb := NewProgressBar(10)
pb.SetMessage("Processing file.go")
assert.Equal(t, "Processing file.go", pb.message)
}
func TestProgressBar_Good_Set(t *testing.T) {
pb := NewProgressBar(100)
pb.Set(50)
assert.Equal(t, 50, pb.Current())
}
func TestProgressBar_Good_Done(t *testing.T) {
pb := NewProgressBar(5)
for i := 0; i < 5; i++ {
pb.Increment()
}
pb.Done()
// After Done, Current == Total
assert.Equal(t, 5, pb.Current())
}
func TestProgressBar_Bad_ExceedsTotal(t *testing.T) {
pb := NewProgressBar(2)
pb.Increment()
pb.Increment()
pb.Increment() // Should clamp to total
assert.Equal(t, 2, pb.Current())
}
func TestProgressBar_Good_Render(t *testing.T) {
pb := NewProgressBar(10)
pb.Set(5)
rendered := pb.String()
assert.Contains(t, rendered, "50%")
}