Skip to content

Commit 6a3821d

Browse files
authored
chore: Improve console error experience (#124)
* chore: Improve console error experience * fix: Use standard error code and handle verbosity
1 parent 80a1747 commit 6a3821d

File tree

6 files changed

+437
-33
lines changed

6 files changed

+437
-33
lines changed

internal/ui/colors.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,23 @@ import "github.com/fatih/color"
55
type ColorFn func(format string, a ...interface{}) string
66

77
type TerminalColors struct {
8-
Normal ColorFn
9-
Red ColorFn
10-
Yellow ColorFn
11-
Cyan ColorFn
12-
Green ColorFn
13-
Bold ColorFn
8+
Normal ColorFn
9+
Red ColorFn
10+
Yellow ColorFn
11+
Cyan ColorFn
12+
Green ColorFn
13+
Bold ColorFn
14+
Dim ColorFn
15+
ErrorCode ColorFn
1416
}
1517

1618
var Colors = TerminalColors{
17-
Normal: color.New().SprintfFunc(),
18-
Red: color.New(color.FgRed, color.Bold).SprintfFunc(),
19-
Yellow: color.New(color.FgYellow).SprintfFunc(),
20-
Cyan: color.New(color.FgCyan).SprintfFunc(),
21-
Green: color.New(color.FgGreen).SprintfFunc(),
22-
Bold: color.New(color.Bold).SprintfFunc(),
19+
Normal: color.New().SprintfFunc(),
20+
Red: color.New(color.FgRed, color.Bold).SprintfFunc(),
21+
Yellow: color.New(color.FgYellow).SprintfFunc(),
22+
Cyan: color.New(color.FgCyan).SprintfFunc(),
23+
Green: color.New(color.FgGreen).SprintfFunc(),
24+
Bold: color.New(color.Bold).SprintfFunc(),
25+
Dim: color.New(color.Faint).SprintfFunc(),
26+
ErrorCode: color.New(color.BgRed, color.FgBlack, color.Bold).SprintfFunc(),
2327
}

internal/ui/error.go

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,57 @@ import (
88
"github.com/safedep/pmg/usefulerror"
99
)
1010

11-
// ErrorExit prints the error message and exits the program with a non-zero status code.
11+
// ErrorExit prints a minimal, clean error message and exits with a non-zero status code.
1212
func ErrorExit(err error) {
1313
log.Errorf("Exiting due to error: %s", err)
1414

15-
usefulErr, ok := usefulerror.AsUsefulError(err)
16-
if !ok {
17-
Fatalf("Error: %s", err)
15+
usefulErr := convertToUsefulError(err)
16+
17+
ClearStatus()
18+
19+
// Use help as hint, but for unknown errors show bug report link
20+
hint := usefulErr.Help()
21+
if usefulErr.Code() == usefulerror.ErrCodeUnknown {
22+
hint = "Report this issue: https://github.com/safedep/pmg/issues/new?labels=bug"
1823
}
1924

20-
additionalHelp := usefulErr.AdditionalHelp()
21-
if additionalHelp == "" {
22-
additionalHelp = fmt.Sprintf("If you believe this is a bug, please report it at: %s",
23-
"https://github.com/safedep/pmg/issues/new?assignees=&labels=bug")
25+
if verbosityLevel == VerbosityLevelVerbose {
26+
printVerboseError(usefulErr.Code(), usefulErr.HumanError(), hint,
27+
usefulErr.AdditionalHelp(), usefulErr.Error())
28+
} else {
29+
printMinimalError(usefulErr.Code(), usefulErr.HumanError(), hint)
2430
}
2531

26-
ClearStatus()
32+
os.Exit(1)
33+
}
2734

28-
fmt.Println(Colors.Red(fmt.Sprintf("Error occurred: %s", usefulErr.HumanError())))
29-
fmt.Println(Colors.Yellow(usefulErr.Help()))
30-
fmt.Println(Colors.Yellow(additionalHelp))
35+
// printMinimalError prints error in minimal two-line format:
36+
// Line 1: Error code (red background) + message (red)
37+
// Line 2: Actionable hint with arrow prefix (dimmed)
38+
func printMinimalError(code, message, hint string) {
39+
// Line 1: Error code + message
40+
fmt.Printf("%s %s\n", Colors.ErrorCode(" %s ", code), Colors.Red(message))
3141

32-
os.Exit(1)
42+
// Line 2: Actionable hint with arrow (only if meaningful)
43+
if hint != "" && hint != "No additional help is available for this error." {
44+
fmt.Printf(" %s %s\n", Colors.Dim("→"), Colors.Dim(hint))
45+
}
46+
}
47+
48+
// printVerboseError prints detailed error for debugging (--verbose mode)
49+
// Includes additional help and original error chain for troubleshooting
50+
func printVerboseError(code, message, hint, additionalHelp, originalError string) {
51+
fmt.Printf("%s %s\n", Colors.ErrorCode(" %s ", code), Colors.Red(message))
52+
53+
if hint != "" && hint != "No additional help is available for this error." {
54+
fmt.Printf(" %s %s\n", Colors.Dim("→"), Colors.Dim(hint))
55+
}
56+
57+
if additionalHelp != "" && additionalHelp != "No additional help is available for this error." {
58+
fmt.Printf(" %s %s\n", Colors.Dim("→"), Colors.Dim(additionalHelp))
59+
}
60+
61+
if originalError != "" && originalError != message {
62+
fmt.Printf(" %s %s\n", Colors.Dim("┄"), Colors.Dim(originalError))
63+
}
3364
}

internal/ui/error_convert.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package ui
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"io/fs"
9+
"net"
10+
"os"
11+
"os/exec"
12+
"strings"
13+
14+
"github.com/safedep/pmg/usefulerror"
15+
)
16+
17+
// errorMatcher defines how to detect and convert a specific error type
18+
type errorMatcher struct {
19+
match func(err error) bool
20+
convert func(err error) usefulerror.UsefulError
21+
}
22+
23+
// errorMatchers is an ordered list of error matchers
24+
// Order matters - more specific matchers should come first
25+
var errorMatchers = []errorMatcher{
26+
// File not found errors
27+
{
28+
match: func(err error) bool {
29+
return errors.Is(err, os.ErrNotExist) || errors.Is(err, fs.ErrNotExist)
30+
},
31+
convert: func(err error) usefulerror.UsefulError {
32+
path := extractPathFromError(err)
33+
humanError := "File or directory not found"
34+
if path != "" {
35+
humanError = fmt.Sprintf("File or directory not found: %s", path)
36+
}
37+
38+
return usefulerror.Useful().
39+
WithCode(usefulerror.ErrCodeNotFound).
40+
WithHumanError(humanError).
41+
WithHelp("Check if the path exists").
42+
WithAdditionalHelp("Use 'ls' to check directory contents").
43+
Wrap(err)
44+
},
45+
},
46+
// Permission denied errors
47+
{
48+
match: func(err error) bool {
49+
return errors.Is(err, os.ErrPermission) || errors.Is(err, fs.ErrPermission)
50+
},
51+
convert: func(err error) usefulerror.UsefulError {
52+
path := extractPathFromError(err)
53+
humanError := "Permission denied"
54+
if path != "" {
55+
humanError = fmt.Sprintf("Permission denied: %s", path)
56+
}
57+
return usefulerror.Useful().
58+
WithCode(usefulerror.ErrCodePermissionDenied).
59+
WithHumanError(humanError).
60+
WithHelp("Check permissions or use sudo").
61+
WithAdditionalHelp("Use 'ls -la' to check permissions").
62+
Wrap(err)
63+
},
64+
},
65+
// Process exit errors
66+
{
67+
match: func(err error) bool {
68+
var exitErr *exec.ExitError
69+
return errors.As(err, &exitErr)
70+
},
71+
convert: func(err error) usefulerror.UsefulError {
72+
var exitErr *exec.ExitError
73+
errors.As(err, &exitErr)
74+
exitCode := exitErr.ExitCode()
75+
return usefulerror.Useful().
76+
WithCode(usefulerror.ErrCodeLifecycle).
77+
WithHumanError(fmt.Sprintf("Command failed with exit code %d", exitCode)).
78+
WithHelp("Check command output above").
79+
WithAdditionalHelp("Run with PMG_DEBUG=true for more details").
80+
Wrap(err)
81+
},
82+
},
83+
// Timeout errors (check before network errors since network timeouts also match)
84+
{
85+
match: func(err error) bool {
86+
return errors.Is(err, context.DeadlineExceeded)
87+
},
88+
convert: func(err error) usefulerror.UsefulError {
89+
return usefulerror.Useful().
90+
WithCode(usefulerror.ErrCodeTimeout).
91+
WithHumanError("Operation timed out").
92+
WithHelp("Try again or check your network").
93+
WithAdditionalHelp("Consider increasing timeout or retry later").
94+
Wrap(err)
95+
},
96+
},
97+
// Canceled errors
98+
{
99+
match: func(err error) bool {
100+
return errors.Is(err, context.Canceled)
101+
},
102+
convert: func(err error) usefulerror.UsefulError {
103+
return usefulerror.Useful().
104+
WithCode(usefulerror.ErrCodeCanceled).
105+
WithHumanError("Operation was canceled").
106+
Wrap(err)
107+
},
108+
},
109+
// Network errors
110+
{
111+
match: func(err error) bool {
112+
var netErr net.Error
113+
if errors.As(err, &netErr) {
114+
return true
115+
}
116+
// Also check for common network-related error messages
117+
errStr := err.Error()
118+
return strings.Contains(errStr, "connection refused") ||
119+
strings.Contains(errStr, "no such host") ||
120+
strings.Contains(errStr, "network is unreachable")
121+
},
122+
convert: func(err error) usefulerror.UsefulError {
123+
var netErr net.Error
124+
if errors.As(err, &netErr) && netErr.Timeout() {
125+
return usefulerror.Useful().
126+
WithCode(usefulerror.ErrCodeTimeout).
127+
WithHumanError("Network request timed out").
128+
WithHelp("Check your internet connection").
129+
WithAdditionalHelp("Consider increasing timeout or retry later").
130+
Wrap(err)
131+
}
132+
return usefulerror.Useful().
133+
WithCode(usefulerror.ErrCodeNetwork).
134+
WithHumanError("Network error occurred").
135+
WithHelp("Check your internet connection").
136+
WithAdditionalHelp("The package registry may be temporarily unavailable").
137+
Wrap(err)
138+
},
139+
},
140+
// Unexpected EOF errors
141+
{
142+
match: func(err error) bool {
143+
return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
144+
},
145+
convert: func(err error) usefulerror.UsefulError {
146+
return usefulerror.Useful().
147+
WithCode(usefulerror.ErrCodeUnexpectedEOF).
148+
WithHumanError("Unexpected end of data").
149+
WithHelp("Retry the download").
150+
WithAdditionalHelp("This may indicate network instability").
151+
Wrap(err)
152+
},
153+
},
154+
}
155+
156+
// convertToUsefulError attempts to convert a regular error to a UsefulError
157+
// by analyzing the error chain for known error types.
158+
// Returns the original error wrapped in a generic UsefulError if no specific match is found.
159+
func convertToUsefulError(err error) usefulerror.UsefulError {
160+
if err == nil {
161+
return nil
162+
}
163+
164+
if ue, ok := usefulerror.AsUsefulError(err); ok {
165+
return ue
166+
}
167+
168+
for _, matcher := range errorMatchers {
169+
if matcher.match(err) {
170+
return matcher.convert(err)
171+
}
172+
}
173+
174+
return usefulerror.Useful().
175+
WithCode(usefulerror.ErrCodeUnknown).
176+
WithHumanError(extractRootCause(err)).
177+
WithHelp("An unexpected error occurred.").
178+
Wrap(err)
179+
}
180+
181+
// extractRootCause traverses the error chain and returns the innermost error message.
182+
// This provides a cleaner, more human-friendly message instead of the full error chain.
183+
func extractRootCause(err error) string {
184+
for {
185+
unwrapped := errors.Unwrap(err)
186+
if unwrapped == nil {
187+
return err.Error()
188+
}
189+
190+
err = unwrapped
191+
}
192+
}
193+
194+
// extractPathFromError attempts to extract a file path from path-related errors
195+
func extractPathFromError(err error) string {
196+
var pathErr *fs.PathError
197+
if errors.As(err, &pathErr) {
198+
return pathErr.Path
199+
}
200+
201+
var linkErr *os.LinkError
202+
if errors.As(err, &linkErr) {
203+
return linkErr.Old
204+
}
205+
206+
return ""
207+
}

0 commit comments

Comments
 (0)