Skip to content

Commit 3277d53

Browse files
authored
feat(cli): Add user hints to help fix errors (#95)
1 parent 8640164 commit 3277d53

File tree

9 files changed

+125
-17
lines changed

9 files changed

+125
-17
lines changed

internal/app/azldev/app.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,12 @@ func (a *App) Execute(args []string) int {
273273
// usage information, query the tool's version, etc. If the user attempts to run a command that
274274
// requires configuration, then execution will stop just before running the command.
275275
if err = a.initializeProjectConfig(envOptions, earlyTempDirPath); err != nil {
276-
// Present an error, but move on.
276+
// Present an error, but move on. Store the error so we can set a fix suggestion
277+
// on env once it's constructed.
277278
slog.Error("Error loading configuration, execution may fail later;", "err", err)
278279
}
279280

281+
configLoadErr := err
280282
if err = a.reInitLoggingWithLogFile(envOptions); err != nil {
281283
slog.Error("Error initializing file logging.", "err", err)
282284

@@ -305,6 +307,12 @@ func (a *App) Execute(args []string) int {
305307
env.SetVerbose(a.verbose)
306308
env.SetQuiet(a.quiet)
307309
env.SetColorMode(a.colorMode)
310+
311+
// If config loading failed, set a fix suggestion on env so it can be shown
312+
// after any command error that requires config.
313+
if configLoadErr != nil {
314+
env.AddFixSuggestion(fmt.Sprintf("fix the configuration error: %v", configLoadErr))
315+
}
308316
//
309317
// If we managed to find a project + configuration, then we can let anyone who was
310318
// interested have an opportunity to add subcommands (or do whatever they need to
@@ -635,6 +643,8 @@ func (a *App) dispatchToCommand(env *Env, args []string) int {
635643
if err != nil {
636644
slog.Error("Error: " + err.Error())
637645

646+
env.PrintFixSuggestions()
647+
638648
return 1
639649
}
640650

internal/app/azldev/command.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"encoding/json"
88
"errors"
99
"fmt"
10-
"log/slog"
1110
"os"
1211

1312
"github.com/charmbracelet/x/term"
@@ -105,16 +104,15 @@ func runFuncInternal(innerFunc CmdWithExtraArgsFuncType, requireConfig bool) cob
105104
}
106105

107106
if requireConfig && (env.Config() == nil || env.ProjectDir() == "") {
108-
slog.Warn(
109-
"!!! Unable to find and load valid Azure Linux project configuration.\n\n" +
110-
"Please either use the -C option to specify a path to the root directory " +
107+
env.AddFixSuggestion(
108+
"Please either use the -C option to specify a path to the root directory " +
111109
"of your Azure Linux project/repo, or else run this tool from within a directory " +
112-
"tree that contains an 'azldev.toml' file at its root.\n\n" +
113-
"Most commands will not function correctly without a valid configuration.\n\n" +
114-
"------------------------------------------------------------------\n",
110+
"tree that contains an 'azldev.toml' file at its root. " +
111+
"Most commands will not function correctly without a valid configuration.",
115112
)
116113

117-
return errors.New("a valid project and configuration are required to execute this command")
114+
return errors.New(
115+
"a valid project and configuration are required to execute this command")
118116
}
119117

120118
results, err := innerFunc(env, args)

internal/app/azldev/env.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"log/slog"
1112
"os"
1213
"os/exec"
1314
"path/filepath"
1415
"runtime"
16+
"strings"
1517
"time"
1618

1719
"github.com/charmbracelet/gum/confirm"
20+
"github.com/charmbracelet/x/term"
1821
"github.com/mattn/go-isatty"
1922
"github.com/microsoft/azure-linux-dev-tools/internal/global/opctx"
2023
"github.com/microsoft/azure-linux-dev-tools/internal/projectconfig"
@@ -85,6 +88,10 @@ type Env struct {
8588

8689
// Start time: used for consistent timestamping of artifacts.
8790
constructionTime time.Time
91+
92+
// Fix suggestion: a list of human readable hints that will be printed after an error to help the user
93+
// resolve the issue. Printed in FIFO order.
94+
fixSuggestions []string
8895
}
8996

9097
// Constructs a new [Env] using specified options.
@@ -136,6 +143,9 @@ func NewEnv(ctx context.Context, options EnvOptions) *Env {
136143

137144
// Start time.
138145
constructionTime: time.Now(),
146+
147+
// No fix suggestions to start.
148+
fixSuggestions: []string{},
139149
}
140150
}
141151

@@ -279,6 +289,51 @@ func (env *Env) OutputDir() string {
279289
return env.outputDir
280290
}
281291

292+
// AddFixSuggestion records a human-readable hint that will be printed after an
293+
// error to help the user resolve the issue. Suggestions are printed in FIFO order.
294+
func (env *Env) AddFixSuggestion(suggestion string) {
295+
env.fixSuggestions = append(env.fixSuggestions, suggestion)
296+
}
297+
298+
// PrintFixSuggestions prints the current fix suggestions, if any.
299+
func (env *Env) PrintFixSuggestions() {
300+
if len(env.fixSuggestions) == 0 {
301+
return
302+
}
303+
304+
// Use term.GetSize to guess at the width, defaulting to 80 if it fails.
305+
// Subtract 15 to account for the slog head.
306+
const slogHeadWidth = 15
307+
308+
consoleWidth, _, err := term.GetSize(os.Stderr.Fd())
309+
if err != nil {
310+
consoleWidth = 80
311+
}
312+
313+
consoleWidth -= slogHeadWidth
314+
315+
padding := " "
316+
paddingSize := len(padding)
317+
318+
maxMsgLength := 0
319+
for _, suggestion := range env.fixSuggestions {
320+
if len(suggestion) > maxMsgLength {
321+
maxMsgLength = len(suggestion)
322+
}
323+
}
324+
325+
boxWidth := max(0, min(consoleWidth, paddingSize+maxMsgLength+paddingSize))
326+
boxEdgeString := strings.Repeat("=", boxWidth)
327+
328+
slog.Warn(boxEdgeString)
329+
330+
for _, suggestion := range env.fixSuggestions {
331+
slog.Warn(padding + suggestion)
332+
}
333+
334+
slog.Warn(boxEdgeString)
335+
}
336+
282337
// CPUBoundConcurrency returns the recommended concurrency limit for CPU-bound tasks.
283338
// Returns [runtime.NumCPU], minimum 1.
284339
func (env *Env) CPUBoundConcurrency() int {

internal/app/azldev/env_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,40 @@ func TestEnvWithCancel(t *testing.T) {
132132
require.Error(t, child.Err())
133133
assert.NoError(t, original.Err())
134134
}
135+
136+
func TestFixSuggestions(t *testing.T) {
137+
t.Run("no suggestions does not panic", func(t *testing.T) {
138+
testEnv := testutils.NewTestEnv(t)
139+
// Should not panic when no suggestions are present.
140+
assert.NotPanics(t, func() {
141+
testEnv.Env.PrintFixSuggestions()
142+
})
143+
})
144+
145+
t.Run("single suggestion does not panic", func(t *testing.T) {
146+
testEnv := testutils.NewTestEnv(t)
147+
testEnv.Env.AddFixSuggestion("run 'azldev component update -a'")
148+
assert.NotPanics(t, func() {
149+
testEnv.Env.PrintFixSuggestions()
150+
})
151+
})
152+
153+
t.Run("multiple suggestions does not panic", func(t *testing.T) {
154+
testEnv := testutils.NewTestEnv(t)
155+
testEnv.Env.AddFixSuggestion("first suggestion")
156+
testEnv.Env.AddFixSuggestion("second suggestion")
157+
testEnv.Env.AddFixSuggestion("third suggestion")
158+
// Should not panic with multiple suggestions.
159+
assert.NotPanics(t, func() {
160+
testEnv.Env.PrintFixSuggestions()
161+
})
162+
})
163+
164+
t.Run("empty string suggestion does not panic", func(t *testing.T) {
165+
testEnv := testutils.NewTestEnv(t)
166+
testEnv.Env.AddFixSuggestion("")
167+
assert.NotPanics(t, func() {
168+
testEnv.Env.PrintFixSuggestions()
169+
})
170+
})
171+
}

scenario/__snapshots__/TestAzlDevWithInvalidConfig_stderr_1.snap

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ Parse error: /workdir/azldev.toml
33
| ~ expected character =
44

55
##:##:## ERR Error loading configuration, execution may fail later; err="error loading project configuration:\nfailed to load project configuration:\nproject config file \"/workdir/azldev.toml\" contains invalid data:\ntoml: expected character ="
6-
##:##:## WRN !!! Unable to find and load valid Azure Linux project configuration.
7-
8-
Please either use the -C option to specify a path to the root directory of your Azure Linux project/repo, or else run this tool from within a directory tree that contains an 'azldev.toml' file at its root.
9-
10-
Most commands will not function correctly without a valid configuration.
11-
12-
------------------------------------------------------------------
13-
146
##:##:## ERR Error: a valid project and configuration are required to execute this command
7+
##:##:## WRN =================================================================
8+
##:##:## WRN fix the configuration error: error loading project configuration:
9+
failed to load project configuration:
10+
project config file "/workdir/azldev.toml" contains invalid data:
11+
toml: expected character =
12+
##:##:## WRN Please either use the -C option to specify a path to the root directory of your Azure Linux project/repo, or else run this tool from within a directory tree that contains an 'azldev.toml' file at its root. Most commands will not function correctly without a valid configuration.
13+
##:##:## WRN =================================================================
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"ExitCode": 1
3+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
##:##:## INF No Azure Linux project found; some commands will not be available.
2+
##:##:## ERR Error: a valid project and configuration are required to execute this command
3+
##:##:## WRN =================================================================
4+
##:##:## WRN Please either use the -C option to specify a path to the root directory of your Azure Linux project/repo, or else run this tool from within a directory tree that contains an 'azldev.toml' file at its root. Most commands will not function correctly without a valid configuration.
5+
##:##:## WRN =================================================================

scenario/__snapshots__/TestSnapshots_no-config_fix_hint_stdout_1.snap

Whitespace-only changes.

scenario/clismoke_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func TestSnapshots(t *testing.T) {
3131
"--help": cmdtest.NewScenarioTest("--help").Locally(),
3232
"--help with color": cmdtest.NewScenarioTest("--help", "--color=always").Locally(),
3333
"--bogus-flag": cmdtest.NewScenarioTest("--bogus-flag").Locally(),
34+
"no-config fix hint": cmdtest.NewScenarioTest("component", "list", "--all-components").Locally(),
3435
}
3536

3637
for name, test := range tests {

0 commit comments

Comments
 (0)