From 2d9dc76c2bcdb98d3abf2802853c210349622918 Mon Sep 17 00:00:00 2001 From: Jaeyoun Nam Date: Sun, 22 Mar 2026 19:55:06 +0900 Subject: [PATCH] feat(gitimpact): complete step 4 observer Bubble Tea bridge --- ...ubble-tea-msg-bridge-for-git-impact-rea.md | 46 +++++ go.mod | 26 +++ go.sum | 48 +++++ internal/gitimpact/engine.go | 16 ++ internal/gitimpact/observer.go | 11 ++ internal/gitimpact/tui_bridge.go | 78 +++++++++ internal/gitimpact/tui_bridge_test.go | 164 ++++++++++++++++++ internal/gitimpact/tui_model.go | 138 +++++++++++++++ 8 files changed, 527 insertions(+) create mode 100644 docs/exec-plans/completed/step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-for-git-impact-rea.md create mode 100644 go.sum create mode 100644 internal/gitimpact/engine.go create mode 100644 internal/gitimpact/observer.go create mode 100644 internal/gitimpact/tui_bridge.go create mode 100644 internal/gitimpact/tui_bridge_test.go create mode 100644 internal/gitimpact/tui_model.go diff --git a/docs/exec-plans/completed/step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-for-git-impact-rea.md b/docs/exec-plans/completed/step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-for-git-impact-rea.md new file mode 100644 index 0000000..be409c1 --- /dev/null +++ b/docs/exec-plans/completed/step-4-of-10-implement-the-wtl-observer-bubble-tea-msg-bridge-for-git-impact-rea.md @@ -0,0 +1,46 @@ +# Step 4 of 10: Implement the WTL Observer -> Bubble Tea Msg bridge for git-impact + +## Goal +Implement the Observer-to-TUI bridge for git-impact so WTL lifecycle events are emitted as Bubble Tea messages, consumed by a minimal analysis progress model, and validated by tests. + +## Background +- `SPEC.md` section 3 and 3.1 define a phase-driven WTL run and an explicit Observer -> Bubble Tea `Msg` mapping. +- Required message mapping for this step: `TurnStarted`, `PhaseAdvanced`, `WaitEntered`, `WaitResolved`, `RunCompleted`, `RunExhausted`. +- The current worktree has `internal/wtl` engine code, while `internal/gitimpact` is not yet present and will need to be introduced as part of this step's implementation. +- Repository merge rules require tests for new behavior (`NON_NEGOTIABLE_RULES.md` Rule 1). + +## Milestones +| ID | Milestone | Status | Exit criteria | +| --- | --- | --- | --- | +| M1 | Add Bubble Tea dependencies to module | completed | `go.mod` includes `bubbletea`, `bubbles`, and `lipgloss`; dependency graph resolves via `go get`. | +| M2 | Define Observer->Msg bridge types and adapter | completed | `internal/gitimpact/tui_bridge.go` defines all required `tea.Msg` structs and a `TUIObserver` implementing the git-impact `Observer` interface with `program.Send(...)` dispatch. | +| M3 | Add minimal analysis progress model | completed | `internal/gitimpact/tui_model.go` defines `AnalysisModel`, `PhaseStatus`, `Init`, `Update`, and `View` handling all bridge message types with simple text rendering. | +| M4 | Add bridge message dispatch tests | completed | `internal/gitimpact/tui_bridge_test.go` verifies each Observer callback emits the expected Bubble Tea message payload. | +| M5 | Verify build and test health | completed | `go build ./...` and `go test ./...` pass after Step 4 changes. | + +## Current progress +- Overall status: complete (`M1` through `M5` complete). +- Added Bubble Tea ecosystem dependencies via `go get`: `github.com/charmbracelet/bubbletea v1.3.10`, `github.com/charmbracelet/bubbles v1.0.0`, and `github.com/charmbracelet/lipgloss v1.1.0`. +- Added `internal/gitimpact/tui_bridge.go` with six Bubble Tea message structs and `TUIObserver` forwarding each Observer callback with `program.Send(...)`. +- Introduced minimal `internal/gitimpact/engine.go` and `internal/gitimpact/observer.go` scaffolding required for bridge compilation because prior-step files were not present in this worktree. +- Added `internal/gitimpact/tui_model.go` implementing `AnalysisModel` (`Init`, `Update`, `View`) and `PhaseStatus`, with event-driven state transitions for all Observer bridge message types. +- Added `internal/gitimpact/tui_bridge_test.go` with a Bubble Tea program-backed observer dispatch test validating type and payload for all six bridge messages. +- Re-ran dependency resolution and full validation for final milestone: `go get` (Bubble Tea deps), `go build ./...`, and `go test ./...` all succeeded. + +## Key decisions +- Keep this step focused on the Observer-to-TUI bridge and minimal model state transitions; defer richer TUI visuals/interactions to Step 9 as scoped. +- Use one Bubble Tea `Msg` type per Observer event exactly as specified to keep event handling explicit and testable. +- Treat the bridge as an adapter layer in `internal/gitimpact` to avoid coupling WTL core internals directly to Bubble Tea update logic. +- Follow loop rule "exactly one milestone per iteration" by advancing milestones incrementally across iterations. +- Define only minimal `Phase`, `AnalysisResult`, and `Observer` types now to unblock bridge wiring; richer engine/result modeling remains for later milestones. +- Keep model state simple and explicit for now: each phase is one of `waiting`, `running`, `done`; richer visuals/components are deferred to Step 9. +- Use an in-process Bubble Tea `Program` test harness (with renderer/input disabled) so tests verify real `program.Send(...)` integration instead of mocked dispatch. + +## Remaining issues +- None for Step 4 scope. + +## Links +- Product spec: `SPEC.md` (sections 3, 3.1) +- Plan policy: `docs/PLANS.md` +- Merge gates: `NON_NEGOTIABLE_RULES.md` +- System boundaries: `ARCHITECTURE.md` diff --git a/go.mod b/go.mod index 7a3e3d8..a04e0c3 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,29 @@ module impactable go 1.26 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ae9cc6e --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/gitimpact/engine.go b/internal/gitimpact/engine.go new file mode 100644 index 0000000..b8defa0 --- /dev/null +++ b/internal/gitimpact/engine.go @@ -0,0 +1,16 @@ +package gitimpact + +// Phase represents a lifecycle stage in the git-impact analysis run. +type Phase string + +const ( + PhaseSourceCheck Phase = "source_check" + PhaseCollect Phase = "collect" + PhaseLink Phase = "link" + PhaseScore Phase = "score" + PhaseReport Phase = "report" +) + +// AnalysisResult is the terminal analysis payload produced by a completed run. +// Fields will be expanded in later milestones as report data is introduced. +type AnalysisResult struct{} diff --git a/internal/gitimpact/observer.go b/internal/gitimpact/observer.go new file mode 100644 index 0000000..0c3407a --- /dev/null +++ b/internal/gitimpact/observer.go @@ -0,0 +1,11 @@ +package gitimpact + +// Observer receives lifecycle callbacks from the git-impact engine. +type Observer interface { + TurnStarted(phase Phase, iteration int) + PhaseAdvanced(from, to Phase) + WaitEntered(message string) + WaitResolved(response string) + RunCompleted(result *AnalysisResult) + RunExhausted(err error) +} diff --git a/internal/gitimpact/tui_bridge.go b/internal/gitimpact/tui_bridge.go new file mode 100644 index 0000000..e38d74e --- /dev/null +++ b/internal/gitimpact/tui_bridge.go @@ -0,0 +1,78 @@ +package gitimpact + +import tea "github.com/charmbracelet/bubbletea" + +// TurnStartedMsg notifies the TUI that a turn has started. +type TurnStartedMsg struct { + Phase Phase + Iteration int +} + +// PhaseAdvancedMsg notifies the TUI that the run moved to another phase. +type PhaseAdvancedMsg struct { + From Phase + To Phase +} + +// WaitEnteredMsg notifies the TUI that execution is paused for user input. +type WaitEnteredMsg struct { + Message string +} + +// WaitResolvedMsg notifies the TUI that a paused wait state was resolved. +type WaitResolvedMsg struct { + Response string +} + +// RunCompletedMsg notifies the TUI that the run finished successfully. +type RunCompletedMsg struct { + Result *AnalysisResult +} + +// RunExhaustedMsg notifies the TUI that the run ended due to exhaustion/error. +type RunExhaustedMsg struct { + Err error +} + +// TUIObserver bridges engine observer callbacks into Bubble Tea messages. +type TUIObserver struct { + program *tea.Program +} + +var _ Observer = (*TUIObserver)(nil) + +// NewTUIObserver constructs an Observer that forwards callbacks to Bubble Tea. +func NewTUIObserver(program *tea.Program) *TUIObserver { + return &TUIObserver{program: program} +} + +func (o *TUIObserver) TurnStarted(phase Phase, iteration int) { + o.send(TurnStartedMsg{Phase: phase, Iteration: iteration}) +} + +func (o *TUIObserver) PhaseAdvanced(from, to Phase) { + o.send(PhaseAdvancedMsg{From: from, To: to}) +} + +func (o *TUIObserver) WaitEntered(message string) { + o.send(WaitEnteredMsg{Message: message}) +} + +func (o *TUIObserver) WaitResolved(response string) { + o.send(WaitResolvedMsg{Response: response}) +} + +func (o *TUIObserver) RunCompleted(result *AnalysisResult) { + o.send(RunCompletedMsg{Result: result}) +} + +func (o *TUIObserver) RunExhausted(err error) { + o.send(RunExhaustedMsg{Err: err}) +} + +func (o *TUIObserver) send(msg tea.Msg) { + if o == nil || o.program == nil { + return + } + o.program.Send(msg) +} diff --git a/internal/gitimpact/tui_bridge_test.go b/internal/gitimpact/tui_bridge_test.go new file mode 100644 index 0000000..0df4059 --- /dev/null +++ b/internal/gitimpact/tui_bridge_test.go @@ -0,0 +1,164 @@ +package gitimpact + +import ( + "io" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +type bridgeCaptureModel struct { + ready chan struct{} + expected int + msgs []tea.Msg +} + +func (m *bridgeCaptureModel) Init() tea.Cmd { + close(m.ready) + return nil +} + +func (m *bridgeCaptureModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !isBridgeMsg(msg) { + return m, nil + } + + m.msgs = append(m.msgs, msg) + if len(m.msgs) >= m.expected { + return m, tea.Quit + } + return m, nil +} + +func (m *bridgeCaptureModel) View() string { + return "" +} + +func isBridgeMsg(msg tea.Msg) bool { + switch msg.(type) { + case TurnStartedMsg, PhaseAdvancedMsg, WaitEnteredMsg, WaitResolvedMsg, RunCompletedMsg, RunExhaustedMsg: + return true + default: + return false + } +} + +func TestTUIObserverSendsBubbleTeaMessages(t *testing.T) { + model := &bridgeCaptureModel{ + ready: make(chan struct{}), + expected: 6, + } + + program := tea.NewProgram( + model, + tea.WithInput(nil), + tea.WithOutput(io.Discard), + tea.WithoutRenderer(), + tea.WithoutSignalHandler(), + ) + + type runResult struct { + model tea.Model + err error + } + done := make(chan runResult, 1) + go func() { + finalModel, err := program.Run() + done <- runResult{model: finalModel, err: err} + }() + + select { + case <-model.ready: + case <-time.After(2 * time.Second): + t.Fatal("bubble tea program did not initialize in time") + } + + observer := NewTUIObserver(program) + result := &AnalysisResult{} + exhaustedErr := &testErr{msg: "max iterations reached"} + + observer.TurnStarted(PhaseCollect, 2) + observer.PhaseAdvanced(PhaseCollect, PhaseLink) + observer.WaitEntered("Need deployment mapping confirmation") + observer.WaitResolved("Use release tags") + observer.RunCompleted(result) + observer.RunExhausted(exhaustedErr) + + var run runResult + select { + case run = <-done: + case <-time.After(2 * time.Second): + program.Kill() + t.Fatal("bubble tea program did not stop in time") + } + + if run.err != nil { + t.Fatalf("program run failed: %v", run.err) + } + + finalModel, ok := run.model.(*bridgeCaptureModel) + if !ok { + t.Fatalf("expected *bridgeCaptureModel, got %T", run.model) + } + + if len(finalModel.msgs) != 6 { + t.Fatalf("expected 6 bridge messages, got %d", len(finalModel.msgs)) + } + + turnStarted, ok := finalModel.msgs[0].(TurnStartedMsg) + if !ok { + t.Fatalf("msg 0 type = %T, want TurnStartedMsg", finalModel.msgs[0]) + } + if turnStarted.Phase != PhaseCollect || turnStarted.Iteration != 2 { + t.Fatalf("unexpected TurnStartedMsg payload: %+v", turnStarted) + } + + phaseAdvanced, ok := finalModel.msgs[1].(PhaseAdvancedMsg) + if !ok { + t.Fatalf("msg 1 type = %T, want PhaseAdvancedMsg", finalModel.msgs[1]) + } + if phaseAdvanced.From != PhaseCollect || phaseAdvanced.To != PhaseLink { + t.Fatalf("unexpected PhaseAdvancedMsg payload: %+v", phaseAdvanced) + } + + waitEntered, ok := finalModel.msgs[2].(WaitEnteredMsg) + if !ok { + t.Fatalf("msg 2 type = %T, want WaitEnteredMsg", finalModel.msgs[2]) + } + if waitEntered.Message != "Need deployment mapping confirmation" { + t.Fatalf("unexpected WaitEnteredMsg payload: %+v", waitEntered) + } + + waitResolved, ok := finalModel.msgs[3].(WaitResolvedMsg) + if !ok { + t.Fatalf("msg 3 type = %T, want WaitResolvedMsg", finalModel.msgs[3]) + } + if waitResolved.Response != "Use release tags" { + t.Fatalf("unexpected WaitResolvedMsg payload: %+v", waitResolved) + } + + runCompleted, ok := finalModel.msgs[4].(RunCompletedMsg) + if !ok { + t.Fatalf("msg 4 type = %T, want RunCompletedMsg", finalModel.msgs[4]) + } + if runCompleted.Result != result { + t.Fatalf("unexpected RunCompletedMsg result pointer: got %p want %p", runCompleted.Result, result) + } + + runExhausted, ok := finalModel.msgs[5].(RunExhaustedMsg) + if !ok { + t.Fatalf("msg 5 type = %T, want RunExhaustedMsg", finalModel.msgs[5]) + } + if runExhausted.Err != exhaustedErr { + t.Fatalf("unexpected RunExhaustedMsg err pointer: got %v want %v", runExhausted.Err, exhaustedErr) + } +} + +type testErr struct { + msg string +} + +func (e *testErr) Error() string { + return e.msg +} diff --git a/internal/gitimpact/tui_model.go b/internal/gitimpact/tui_model.go new file mode 100644 index 0000000..328264a --- /dev/null +++ b/internal/gitimpact/tui_model.go @@ -0,0 +1,138 @@ +package gitimpact + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + phaseStatusWaiting = "waiting" + phaseStatusRunning = "running" + phaseStatusDone = "done" +) + +// PhaseStatus tracks progress status for each analysis phase. +type PhaseStatus struct { + Name Phase + Status string +} + +// AnalysisModel is a minimal Bubble Tea model for analysis progress. +type AnalysisModel struct { + currentPhase Phase + iteration int + phases []PhaseStatus + waitMessage string + isWaiting bool +} + +var _ tea.Model = AnalysisModel{} + +// NewAnalysisModel builds the default progress state for all phases. +func NewAnalysisModel() AnalysisModel { + return AnalysisModel{ + phases: []PhaseStatus{ + {Name: PhaseSourceCheck, Status: phaseStatusWaiting}, + {Name: PhaseCollect, Status: phaseStatusWaiting}, + {Name: PhaseLink, Status: phaseStatusWaiting}, + {Name: PhaseScore, Status: phaseStatusWaiting}, + {Name: PhaseReport, Status: phaseStatusWaiting}, + }, + } +} + +// Init implements tea.Model. +func (m AnalysisModel) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. +func (m AnalysisModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch typed := msg.(type) { + case TurnStartedMsg: + m.currentPhase = typed.Phase + m.iteration = typed.Iteration + m.isWaiting = false + m.waitMessage = "" + m.setRunning(typed.Phase) + case PhaseAdvancedMsg: + m.currentPhase = typed.To + m.setStatus(typed.From, phaseStatusDone) + m.setRunning(typed.To) + case WaitEnteredMsg: + m.isWaiting = true + m.waitMessage = typed.Message + case WaitResolvedMsg: + m.isWaiting = false + m.waitMessage = "" + case RunCompletedMsg: + m.isWaiting = false + m.waitMessage = "" + m.setAllDone() + case RunExhaustedMsg: + m.isWaiting = false + } + return m, nil +} + +// View implements tea.Model. +func (m AnalysisModel) View() string { + var b strings.Builder + + b.WriteString("Git Impact Analyzer\n") + b.WriteString(fmt.Sprintf("Iteration: %d\n", m.iteration)) + if m.currentPhase != "" { + b.WriteString(fmt.Sprintf("Current phase: %s\n", m.currentPhase)) + } + b.WriteString("\nPhase Progress:\n") + for _, phase := range m.phases { + b.WriteString(fmt.Sprintf("%s %s (%s)\n", phaseMarker(phase.Status), phase.Name, phase.Status)) + } + + if m.isWaiting { + b.WriteString("\nWaiting for input:\n") + b.WriteString(m.waitMessage) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} + +func (m *AnalysisModel) setStatus(target Phase, status string) { + for i := range m.phases { + if m.phases[i].Name == target { + m.phases[i].Status = status + return + } + } +} + +func (m *AnalysisModel) setRunning(target Phase) { + for i := range m.phases { + switch { + case m.phases[i].Name == target: + m.phases[i].Status = phaseStatusRunning + case m.phases[i].Status != phaseStatusDone: + m.phases[i].Status = phaseStatusWaiting + } + } +} + +func (m *AnalysisModel) setAllDone() { + for i := range m.phases { + m.phases[i].Status = phaseStatusDone + } +} + +func phaseMarker(status string) string { + switch status { + case phaseStatusDone: + return "[x]" + case phaseStatusRunning: + return "[>]" + default: + return "[ ]" + } +}