diff --git a/internal/app/azldev/env.go b/internal/app/azldev/env.go index 493a2a62..b1d79acb 100644 --- a/internal/app/azldev/env.go +++ b/internal/app/azldev/env.go @@ -14,6 +14,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "time" "github.com/charmbracelet/gum/confirm" @@ -92,13 +93,34 @@ type Env struct { // Fix suggestion: a list of human readable hints that will be printed after an error to help the user // resolve the issue. Printed in FIFO order. - fixSuggestions []string + fixSuggestions *fixSuggestionState // lockStore provides cached access to per-component lock files. // Nil when no project directory is configured. lockStore *lockfile.Store } +type fixSuggestionState struct { + mu sync.Mutex + suggestions []string +} + +// Add appends a fix suggestion in FIFO order. +func (suggestions *fixSuggestionState) Add(suggestion string) { + suggestions.mu.Lock() + defer suggestions.mu.Unlock() + + suggestions.suggestions = append(suggestions.suggestions, suggestion) +} + +// All returns a copy of all collected fix suggestions. +func (suggestions *fixSuggestionState) All() []string { + suggestions.mu.Lock() + defer suggestions.mu.Unlock() + + return append([]string(nil), suggestions.suggestions...) +} + // Constructs a new [Env] using specified options. func NewEnv(ctx context.Context, options EnvOptions) *Env { var workDir, logDir, outputDir string @@ -150,7 +172,7 @@ func NewEnv(ctx context.Context, options EnvOptions) *Env { constructionTime: time.Now(), // No fix suggestions to start. - fixSuggestions: []string{}, + fixSuggestions: &fixSuggestionState{}, // Lock store: created when we have a project directory. lockStore: newLockStore(options.ProjectDir, options.Config, options.Interfaces.FileSystemFactory), @@ -300,12 +322,14 @@ func (env *Env) OutputDir() string { // AddFixSuggestion records a human-readable hint that will be printed after an // error to help the user resolve the issue. Suggestions are printed in FIFO order. func (env *Env) AddFixSuggestion(suggestion string) { - env.fixSuggestions = append(env.fixSuggestions, suggestion) + env.fixSuggestions.Add(suggestion) } // PrintFixSuggestions prints the current fix suggestions, if any. func (env *Env) PrintFixSuggestions() { - if len(env.fixSuggestions) == 0 { + suggestions := env.fixSuggestions.All() + + if len(suggestions) == 0 { return } @@ -324,7 +348,7 @@ func (env *Env) PrintFixSuggestions() { paddingSize := len(padding) maxMsgLength := 0 - for _, suggestion := range env.fixSuggestions { + for _, suggestion := range suggestions { if len(suggestion) > maxMsgLength { maxMsgLength = len(suggestion) } @@ -335,7 +359,7 @@ func (env *Env) PrintFixSuggestions() { slog.Warn(boxEdgeString) - for _, suggestion := range env.fixSuggestions { + for _, suggestion := range suggestions { slog.Warn(padding + suggestion) } diff --git a/internal/app/azldev/env_test.go b/internal/app/azldev/env_test.go index 456624ef..007e5174 100644 --- a/internal/app/azldev/env_test.go +++ b/internal/app/azldev/env_test.go @@ -4,8 +4,12 @@ package azldev_test import ( + "bytes" "context" + "fmt" + "log/slog" "os" + "sync" "testing" "time" @@ -16,6 +20,19 @@ import ( "github.com/stretchr/testify/require" ) +func setupTestLogger(logBuffer *bytes.Buffer) func() { + logger := slog.New(slog.NewTextHandler(logBuffer, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) + + previousDefaultLogger := slog.Default() + slog.SetDefault(logger) + + return func() { + slog.SetDefault(previousDefaultLogger) + } +} + func TestNewEnv(t *testing.T) { const ( testProjectRoot = "/non/existent/dir" @@ -168,4 +185,69 @@ func TestFixSuggestions(t *testing.T) { testEnv.Env.PrintFixSuggestions() }) }) + + t.Run("suggestions added from child envs are visible on the parent env", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const suggestionCount = 32 + + var waitGroup sync.WaitGroup + for suggestionIndex := range suggestionCount { + waitGroup.Add(1) + + go func(index int) { + defer waitGroup.Done() + + childEnv, cancel := testEnv.Env.WithCancel() + defer cancel() + + childEnv.AddFixSuggestion(fmt.Sprintf("child suggestion %d", index)) + }(suggestionIndex) + } + + waitGroup.Wait() + + var logBuffer bytes.Buffer + + restoreLogger := setupTestLogger(&logBuffer) + defer restoreLogger() + + testEnv.Env.PrintFixSuggestions() + + output := logBuffer.String() + for suggestionIndex := range suggestionCount { + assert.Contains(t, output, fmt.Sprintf("child suggestion %d", suggestionIndex)) + } + }) + + t.Run("concurrent suggestions on the same env are all preserved", func(t *testing.T) { + testEnv := testutils.NewTestEnv(t) + + const suggestionCount = 128 + + var waitGroup sync.WaitGroup + for suggestionIndex := range suggestionCount { + waitGroup.Add(1) + + go func(index int) { + defer waitGroup.Done() + + testEnv.Env.AddFixSuggestion(fmt.Sprintf("shared suggestion %d", index)) + }(suggestionIndex) + } + + waitGroup.Wait() + + var logBuffer bytes.Buffer + + restoreLogger := setupTestLogger(&logBuffer) + defer restoreLogger() + + testEnv.Env.PrintFixSuggestions() + + output := logBuffer.String() + for suggestionIndex := range suggestionCount { + assert.Contains(t, output, fmt.Sprintf("shared suggestion %d", suggestionIndex)) + } + }) }