Skip to content

Commit e64ef6c

Browse files
author
Test User
committed
refactor(ui,core,cmd): decouple terminal presentation from execution engine
fix: correct milestone denominator drift and step numbering (4 → 8) - Introduce Reporter interface in internal/types - Implement SpinnerReporter (TTY) and PlainReporter (CI/dry-run) - Add factory with character-device detection for automatic UI selection - Remove StepLogger and goroutine-based spinner management from core - Synchronize milestone denominators across cmd and core - Update unit tests to use Reporter abstraction
1 parent 8c200ef commit e64ef6c

File tree

10 files changed

+251
-131
lines changed

10 files changed

+251
-131
lines changed

cmd/apply.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import (
77
"fmt"
88
"path/filepath"
99
"scbake/internal/core"
10+
"scbake/internal/ui"
1011

1112
"github.com/spf13/cobra"
1213
)
1314

15+
const (
16+
runApplyTotalSteps = 5 // Now 5 steps, as git steps are part of template logic
17+
)
18+
1419
var (
1520
langFlag string
1621
withFlag []string
@@ -44,7 +49,10 @@ var applyCmd = &cobra.Command{
4449
Force: force, // force is the global flag.
4550
}
4651

47-
if err := core.RunApply(rc); err != nil {
52+
// Initialize modular UI reporter using the factory
53+
reporter := ui.NewReporter(runApplyTotalSteps, dryRun)
54+
55+
if err := core.RunApply(rc, reporter); err != nil {
4856
return err
4957
}
5058
fmt.Println("✅ Success! 'apply' command finished.")

cmd/new.go

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@ import (
77
"fmt"
88
"os"
99
"scbake/internal/core"
10+
"scbake/internal/ui"
1011
"scbake/internal/util/fileutil"
1112

1213
"github.com/spf13/cobra"
1314
)
1415

15-
// Steps in the new command run
16-
const newCmdTotalSteps = 4
16+
// newCmdTotalSteps represents the total milestones in the 'new' workflow:
17+
// 1. Directory creation
18+
// 2. Template application start
19+
// 3-7. Internal RunApply milestones (Load, Plan, Execute, Manifest, Commit)
20+
// 8. Finalization
21+
const newCmdTotalSteps = 8
1722

1823
var (
1924
newLangFlag string
@@ -27,13 +32,10 @@ var newCmd = &cobra.Command{
2732
Args: cobra.ExactArgs(1),
2833
RunE: func(_ *cobra.Command, args []string) error {
2934
projectName := args[0]
30-
31-
// Flag to track directory creation
3235
dirCreated := false
3336

34-
// runNew takes a pointer to dirCreated to track creation status
3537
if err := runNew(projectName, &dirCreated); err != nil {
36-
// SAFETY CHECK: Only clean up the directory if we created it during this command.
38+
// Cleanup: Only remove the directory if it was created during this session.
3739
if dirCreated {
3840
fmt.Fprintf(os.Stderr, "Cleaning up failed project directory '%s'...\n", projectName)
3941
_ = os.RemoveAll(projectName)
@@ -46,31 +48,31 @@ var newCmd = &cobra.Command{
4648
},
4749
}
4850

49-
// runNew takes a pointer to dirCreated to track creation status.
51+
// runNew coordinates the project directory setup and delegates template application to the core engine.
5052
func runNew(projectName string, dirCreated *bool) error {
51-
logger := core.NewStepLogger(newCmdTotalSteps, dryRun)
53+
reporter := ui.NewReporter(newCmdTotalSteps, dryRun)
5254

5355
// Capture original working directory before any changes.
5456
cwd, err := os.Getwd()
5557
if err != nil {
5658
return fmt.Errorf("failed to get cwd: %w", err)
5759
}
5860

59-
// 1. Check if directory exists
61+
// Verify target directory availability
6062
if _, err := os.Stat(projectName); !os.IsNotExist(err) {
6163
return fmt.Errorf("directory '%s' already exists", projectName)
6264
}
6365

64-
// 2. Create directory
65-
logger.Log("📁", "Creating directory: "+projectName)
66+
// Initialize project directory
67+
reporter.Step("📁", "Creating directory: "+projectName)
6668
if !dryRun {
6769
if err := os.Mkdir(projectName, fileutil.DirPerms); err != nil {
6870
return err
6971
}
70-
*dirCreated = true // Set flag: successfully created directory
72+
*dirCreated = true
7173
}
7274

73-
// 3. CD into directory
75+
// Relocate to target directory for atomic scaffolding
7476
if !dryRun {
7577
if err := os.Chdir(projectName); err != nil {
7678
return err
@@ -82,32 +84,30 @@ func runNew(projectName string, dirCreated *bool) error {
8284
}()
8385
}
8486

85-
// Bootstrap manifest so the engine can find the project root
87+
// Bootstrap manifest for project root discovery
8688
if !dryRun {
8789
if err := os.WriteFile(fileutil.ManifestFileName, []byte(""), fileutil.PrivateFilePerms); err != nil {
8890
return fmt.Errorf("failed to bootstrap manifest: %w", err)
8991
}
9092
}
9193

92-
// 6. Run the 'apply' logic
93-
logger.Log("🚀", "Applying templates...")
94+
// Delegate template and language pack application to the core executor
95+
reporter.Step("🚀", "Applying templates...")
9496
rc := core.RunContext{
9597
LangFlag: newLangFlag,
9698
WithFlag: newWithFlag,
97-
TargetPath: ".", // Now correctly relative to the new directory
98-
DryRun: dryRun, // Use global flag
99-
Force: force, // Use global flag
99+
TargetPath: ".",
100+
DryRun: dryRun,
101+
Force: force,
100102
ManifestPathArg: ".",
101103
}
102104

103-
// RunApply prints its own logs.
104-
if err := core.RunApply(rc); err != nil {
105+
if err := core.RunApply(rc, reporter); err != nil {
105106
return err
106107
}
107108

108-
// Update the total steps for the logger, using the exported method.
109-
logger.SetTotalSteps(newCmdTotalSteps)
110-
logger.Log("✨", "Finalizing project...")
109+
// Finalize output
110+
reporter.Step("✨", "Finalizing project...")
111111
return nil
112112
}
113113

internal/core/executor.go

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,62 +7,27 @@ import (
77
"fmt"
88
"scbake/internal/types"
99
"sort"
10-
"time"
1110
)
1211

13-
// Braille spinner characters
14-
var spinnerChars = []string{"⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"}
15-
16-
// spinnerDelay defines the interval for spinner character updates.
17-
const spinnerDelay = 100 * time.Millisecond
18-
19-
// Execute runs the plan.
20-
func Execute(plan *types.Plan, tc types.TaskContext) error {
21-
// Sort tasks by priority
12+
// Execute runs the plan and reports progress via the provided Reporter.
13+
func Execute(plan *types.Plan, tc types.TaskContext, reporter types.Reporter) error {
2214
sort.SliceStable(plan.Tasks, func(i, j int) bool {
2315
return plan.Tasks[i].Priority() < plan.Tasks[j].Priority()
2416
})
2517

2618
for i, task := range plan.Tasks {
27-
// If we're in a dry run, just print the description
28-
if tc.DryRun {
29-
fmt.Printf(" [DRY RUN] %s\n", task.Description())
30-
continue
31-
}
19+
reporter.TaskStart(task.Description(), i+1, len(plan.Tasks))
3220

33-
// Spinner Logic
34-
done := make(chan struct{})
35-
go func() {
36-
j := 0
37-
for {
38-
select {
39-
case <-done:
40-
return
41-
default:
42-
// Print the spinner, prefix, and description
43-
prefix := fmt.Sprintf("[%d/%d]", i+1, len(plan.Tasks))
44-
line := fmt.Sprintf("\r%s %s %s", prefix, spinnerChars[j%len(spinnerChars)], task.Description())
45-
fmt.Print(line)
46-
j++
47-
time.Sleep(spinnerDelay)
48-
}
49-
}
50-
}()
51-
52-
err := task.Execute(tc) // Run the actual task
21+
var err error
22+
if !tc.DryRun {
23+
err = task.Execute(tc)
24+
}
5325

54-
close(done) // Stop the spinner goroutine
26+
reporter.TaskEnd(err)
5527

56-
// Print the final line
57-
prefix := fmt.Sprintf("[%d/%d]", i+1, len(plan.Tasks))
5828
if err != nil {
59-
// Print a clear error message, overwriting the spinner line
60-
fmt.Printf("\r%s ❌ %s\n", prefix, task.Description())
6129
return fmt.Errorf("task failed (%s): %w", task.Description(), err)
6230
}
63-
64-
// Print a clear success message, overwriting the spinner line
65-
fmt.Printf("\r%s ✅ %s\n", prefix, task.Description())
6631
}
6732

6833
return nil

internal/core/run.go

Lines changed: 17 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -19,44 +19,12 @@ import (
1919
"scbake/pkg/templates"
2020
)
2121

22-
// Define constants for step logging and cyclomatic complexity reduction
23-
const (
24-
runApplyTotalSteps = 5 // Now 5 steps, as git steps are part of template logic
25-
langApplyTotalSteps = 5
26-
)
27-
28-
// StepLogger helps print consistent step messages
29-
type StepLogger struct {
30-
currentStep int
31-
totalSteps int // Keep unexported
32-
DryRun bool
33-
}
34-
35-
// NewStepLogger creates a new StepLogger instance.
36-
func NewStepLogger(totalSteps int, dryRun bool) *StepLogger {
37-
return &StepLogger{totalSteps: totalSteps, DryRun: dryRun}
38-
}
39-
40-
// Log prints the current step message.
41-
func (l *StepLogger) Log(emoji, message string) {
42-
l.currentStep++
43-
if l.DryRun && l.currentStep > 2 {
44-
return
45-
}
46-
fmt.Printf("[%d/%d] %s %s\n", l.currentStep, l.totalSteps, emoji, message)
47-
}
48-
49-
// SetTotalSteps updates the total number of steps for logging.
50-
func (l *StepLogger) SetTotalSteps(newTotal int) {
51-
l.totalSteps = newTotal
52-
}
53-
54-
// RunContext holds all the flags and args for a run.
22+
// RunContext holds the flags and arguments for a single execution run.
5523
type RunContext struct {
5624
LangFlag string
5725
WithFlag []string
58-
TargetPath string // Used for execution (absolute path)
59-
ManifestPathArg string // Used for manifest/template rendering (relative path)
26+
TargetPath string // Absolute path for execution stability.
27+
ManifestPathArg string // Relative path for manifest portability.
6028
DryRun bool
6129
Force bool
6230
}
@@ -67,11 +35,9 @@ type manifestChanges struct {
6735
Templates []types.Template
6836
}
6937

70-
// RunApply is the main logic for the 'apply' command, extracted.
71-
func RunApply(rc RunContext) error {
72-
logger := NewStepLogger(runApplyTotalSteps, rc.DryRun)
73-
74-
logger.Log("📖", "Loading manifest ("+fileutil.ManifestFileName+")...")
38+
// RunApply orchestrates the template application process.
39+
func RunApply(rc RunContext, reporter types.Reporter) error {
40+
reporter.Step("📖", "Loading manifest ("+fileutil.ManifestFileName+")...")
7541

7642
// 1. Root Discovery & Manifest Load
7743
m, rootPath, err := manifest.Load(rc.TargetPath)
@@ -101,7 +67,7 @@ func RunApply(rc RunContext) error {
10167
}()
10268
}
10369

104-
logger.Log("📝", "Building execution plan...")
70+
reporter.Step("📝", "Building execution plan...")
10571

10672
// Deduplicate requested templates to ensure idempotency
10773
rc.WithFlag = deduplicateTemplates(rc.WithFlag)
@@ -127,34 +93,27 @@ func RunApply(rc RunContext) error {
12793
}
12894

12995
if rc.DryRun {
130-
fmt.Println("DRY RUN: No changes will be made.")
131-
fmt.Println("Plan contains the following tasks:")
132-
return Execute(plan, tc)
96+
return Execute(plan, tc, reporter)
13397
}
13498

135-
// 3. Execute and Finalize
136-
// We pass the transaction and paths down.
137-
return executeAndFinalize(logger, plan, tc, m, changes, rootPath, tx)
99+
return executeAndFinalize(reporter, plan, tc, m, changes, rootPath, tx)
138100
}
139101

140-
// executeAndFinalize runs the plan, updates manifest, and commits the transaction.
141102
func executeAndFinalize(
142-
logger *StepLogger,
103+
reporter types.Reporter,
143104
plan *types.Plan,
144105
tc types.TaskContext,
145106
m *types.Manifest,
146107
changes *manifestChanges,
147108
rootPath string,
148109
tx *transaction.Manager,
149110
) error {
150-
logger.Log("🚀", "Executing plan...")
151-
152-
// Run all tasks. They will auto-track changes via tc.Tx.
153-
if err := Execute(plan, tc); err != nil {
154-
return fmt.Errorf("task execution failed: %w", err)
111+
reporter.Step("🚀", "Executing plan...")
112+
if err := Execute(plan, tc, reporter); err != nil {
113+
return err
155114
}
156115

157-
logger.Log("✍️", "Updating manifest...")
116+
reporter.Step("✍️", "Updating manifest...")
158117
updateManifest(m, changes)
159118

160119
// We track the manifest file itself before saving.
@@ -169,7 +128,7 @@ func executeAndFinalize(
169128
return fmt.Errorf("manifest save failed: %w", err)
170129
}
171130

172-
logger.Log("✅", "Committing transaction...")
131+
reporter.Step("✅", "Committing transaction...")
173132
// Point of No Return: We delete the backups.
174133
if err := tx.Commit(); err != nil {
175134
return fmt.Errorf("failed to commit transaction: %w", err)
@@ -221,7 +180,7 @@ func buildPlan(rc RunContext) (*types.Plan, string, *manifestChanges, error) {
221180
plan := &types.Plan{Tasks: []types.Task{}}
222181
changes := &manifestChanges{}
223182
commitMessage := "scbake: Apply templates"
224-
didSomething := false // Flag to ensure at least one action is requested
183+
didSomething := false
225184

226185
if rc.LangFlag != "" {
227186
didSomething = true
@@ -245,7 +204,7 @@ func buildPlan(rc RunContext) (*types.Plan, string, *manifestChanges, error) {
245204

246205
// Only fail if neither a language nor tooling was specified.
247206
if !didSomething {
248-
return nil, "", nil, errors.New("no language or templates specified. Use --lang or --with")
207+
return nil, "", nil, errors.New("no language or templates specified")
249208
}
250209

251210
return plan, commitMessage, changes, nil
@@ -281,7 +240,6 @@ func handleLangFlag(rc RunContext, plan *types.Plan, changes *manifestChanges) (
281240

282241
plan.Tasks = append(plan.Tasks, langTasks...)
283242

284-
// Sanitize the project name for the manifest
285243
projectName, err := util.SanitizeModuleName(rc.ManifestPathArg)
286244
if err != nil {
287245
return "", fmt.Errorf("could not determine project name: %w", err)

internal/core/run_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"scbake/internal/filesystem/transaction"
1212
"scbake/internal/manifest"
1313
"scbake/internal/types"
14+
"scbake/internal/ui"
1415
"scbake/internal/util/fileutil"
1516
"testing"
1617
)
@@ -94,9 +95,11 @@ func TestExecuteAndFinalize_Rollback(t *testing.T) {
9495
}
9596

9697
// Run logic
97-
logger := NewStepLogger(2, false)
98+
// executeAndFinalize calls Step() 3 times (Execute, Update Manifest, Commit).
99+
// We use 3 as the denominator to ensure the UI doesn't drift.
100+
reporter := ui.NewPlainReporter(3, false)
98101
// Execute should fail
99-
err = executeAndFinalize(logger, plan, tc, m, changes, rootPath, tx)
102+
err = executeAndFinalize(reporter, plan, tc, m, changes, rootPath, tx)
100103

101104
// Assert Failure
102105
if err == nil {
@@ -162,8 +165,9 @@ func TestExecuteAndFinalize_Success(t *testing.T) {
162165
Tx: tx,
163166
}
164167

165-
logger := NewStepLogger(1, false)
166-
if err := executeAndFinalize(logger, plan, tc, m, changes, rootPath, tx); err != nil {
168+
// executeAndFinalize calls Step() exactly 3 times.
169+
reporter := ui.NewPlainReporter(3, false)
170+
if err := executeAndFinalize(reporter, plan, tc, m, changes, rootPath, tx); err != nil {
167171
t.Fatalf("Execution failed: %v", err)
168172
}
169173

0 commit comments

Comments
 (0)