From 30643e385d9bb62f331a9beca1271febd894f10d Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 13 Apr 2026 19:37:51 +0200 Subject: [PATCH] feat(engine): add Reporter with buffered and plain implementations --- internal/engine/reporter.go | 80 ++++++++++++++++++++++++++++++++ internal/engine/reporter_test.go | 56 ++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 internal/engine/reporter.go create mode 100644 internal/engine/reporter_test.go diff --git a/internal/engine/reporter.go b/internal/engine/reporter.go new file mode 100644 index 0000000..cdf7848 --- /dev/null +++ b/internal/engine/reporter.go @@ -0,0 +1,80 @@ +// Package engine runs modules through the Detect/Plan/Apply/Verify phases. +package engine + +import ( + "fmt" + "io" + "sync" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +// Event is a single item streamed from a module during Apply. +type Event struct { + ModuleID string + Kind EventKind + Current int + Total int + Message string +} + +type EventKind int + +const ( + EventProgress EventKind = iota + EventLog +) + +// BufferedReporter stores every event in memory. Used in tests. +type BufferedReporter struct { + mu sync.Mutex + events []Event +} + +func NewBufferedReporter() *BufferedReporter { return &BufferedReporter{} } + +func (b *BufferedReporter) Progress(id string, current, total int, msg string) { + b.mu.Lock() + defer b.mu.Unlock() + b.events = append(b.events, Event{ModuleID: id, Kind: EventProgress, Current: current, Total: total, Message: msg}) +} + +func (b *BufferedReporter) Log(id, line string) { + b.mu.Lock() + defer b.mu.Unlock() + b.events = append(b.events, Event{ModuleID: id, Kind: EventLog, Message: line}) +} + +func (b *BufferedReporter) Events() []Event { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]Event, len(b.events)) + copy(out, b.events) + return out +} + +// PlainReporter writes human-readable lines to w. Concurrency-safe. +type PlainReporter struct { + mu sync.Mutex + w io.Writer +} + +func NewPlainReporter(w io.Writer) *PlainReporter { return &PlainReporter{w: w} } + +func (p *PlainReporter) Progress(id string, current, total int, msg string) { + p.mu.Lock() + defer p.mu.Unlock() + fmt.Fprintf(p.w, "[%s] (%d/%d) %s\n", id, current, total, msg) +} + +func (p *PlainReporter) Log(id, line string) { + p.mu.Lock() + defer p.mu.Unlock() + fmt.Fprintf(p.w, "[%s] %s\n", id, line) +} + +// Compile-time guarantee both types satisfy module.Reporter. +var ( + _ module.Reporter = (*BufferedReporter)(nil) + _ module.Reporter = (*PlainReporter)(nil) +) diff --git a/internal/engine/reporter_test.go b/internal/engine/reporter_test.go new file mode 100644 index 0000000..5fc9ebb --- /dev/null +++ b/internal/engine/reporter_test.go @@ -0,0 +1,56 @@ +package engine + +import ( + "bytes" + "strings" + "sync" + "testing" +) + +func TestBufferedReporterRecordsEventsInOrder(t *testing.T) { + r := NewBufferedReporter() + r.Progress("m", 1, 3, "step 1") + r.Log("m", "hello") + r.Progress("m", 2, 3, "step 2") + + ev := r.Events() + if len(ev) != 3 { + t.Fatalf("want 3 events, got %d", len(ev)) + } + if ev[0].Kind != EventProgress || ev[0].Current != 1 { + t.Errorf("event[0] = %+v", ev[0]) + } + if ev[1].Kind != EventLog || ev[1].Message != "hello" { + t.Errorf("event[1] = %+v", ev[1]) + } +} + +func TestBufferedReporterIsConcurrencySafe(t *testing.T) { + r := NewBufferedReporter() + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + r.Progress("m", i, 50, "x") + }(i) + } + wg.Wait() + if got := len(r.Events()); got != 50 { + t.Fatalf("want 50 events, got %d", got) + } +} + +func TestPlainReporterFormat(t *testing.T) { + var buf bytes.Buffer + r := NewPlainReporter(&buf) + r.Progress("packages", 2, 10, "installing tio") + r.Log("packages", "pacman: done") + + out := buf.String() + for _, want := range []string{"[packages] (2/10) installing tio", "[packages] pacman: done"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q; got:\n%s", want, out) + } + } +}