diff --git a/command/command.go b/command/command.go index 5672ef6..2a1a7e0 100644 --- a/command/command.go +++ b/command/command.go @@ -75,6 +75,23 @@ type Command interface { Wait() error } +// FormattedError ... +type FormattedError struct { + formattedErr error + originalCommandErr error +} + +// Error returns the formatted error message. Does not include the original error message (`exit status 1`). +func (c *FormattedError) Error() string { + return c.formattedErr.Error() +} + +// Unwrap is needed for errors.Is and errors.As to work correctly. +// It does not change errorutil.FormattedError's behavior, as it uses Unwrap() error (not []error). +func (c *FormattedError) Unwrap() []error { + return []error{c.originalCommandErr} +} + type command struct { cmd *exec.Cmd errorCollector *errorCollector @@ -169,9 +186,11 @@ func (c command) wrapError(err error) error { var exitErr *exec.ExitError if errors.As(err, &exitErr) { if c.errorCollector != nil && len(c.errorCollector.errorLines) > 0 { - return fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New(strings.Join(c.errorCollector.errorLines, "\n"))) + formattedErr := fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New(strings.Join(c.errorCollector.errorLines, "\n"))) + return &FormattedError{formattedErr: formattedErr, originalCommandErr: err} } - return fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New("check the command's output for details")) + formattedErr := fmt.Errorf("command failed with exit status %d (%s): %w", exitErr.ExitCode(), c.PrintableCommandArgs(), errors.New("check the command's output for details")) + return &FormattedError{formattedErr: formattedErr, originalCommandErr: err} } return fmt.Errorf("executing command failed (%s): %w", c.PrintableCommandArgs(), err) } diff --git a/command/command_test.go b/command/command_test.go index e53b032..1424444 100644 --- a/command/command_test.go +++ b/command/command_test.go @@ -2,12 +2,14 @@ package command import ( "bytes" + "errors" "fmt" "os/exec" "strings" "testing" "github.com/bitrise-io/go-utils/v2/env" + "github.com/bitrise-io/go-utils/v2/errorutil" "github.com/stretchr/testify/assert" ) @@ -65,9 +67,14 @@ Error: fourth error`, gotErrMsg = err.Error() } if gotErrMsg != tt.wantErr { - t.Errorf("command.Run() error = %v, wantErr %v", gotErrMsg, tt.wantErr) + t.Errorf("command.Run() error = \n%v\n, wantErr \n%v\n", gotErrMsg, tt.wantErr) return } + + gotFormattedMsg := errorutil.FormattedError(err) + if gotFormattedMsg != tt.wantErr { + t.Errorf("FormattedError() error = \n%v\n, wantErr \n%v\n", gotFormattedMsg, tt.wantErr) + } }) } } @@ -123,6 +130,18 @@ func TestRunCmdAndReturnExitCode(t *testing.T) { t.Errorf("command.RunAndReturnExitCode() error = %v, wantErr %v", err, tt.wantErr) return } + if tt.wantErr && tt.wantExitCode > 0 { + var exitErr *exec.ExitError + + if ok := errors.As(err, &exitErr); !ok { + t.Errorf("command.RunAndReturnExitCode() did nor return ExitError type: %s", err) + return + } + + if exitErr.ExitCode() != tt.wantExitCode { + t.Errorf("command.RunAndReturnExitCode() exit code = %v, want %v", exitErr.ExitCode(), tt.wantExitCode) + } + } if gotExitCode != tt.wantExitCode { t.Errorf("command.RunAndReturnExitCode() = %v, want %v", gotExitCode, tt.wantExitCode) } @@ -180,6 +199,10 @@ Error: second error`, t.Errorf("command.Run() error = %v, wantErr %v", gotErrMsg, tt.wantErr) return } + gotFormattedMsg := errorutil.FormattedError(err) + if gotFormattedMsg != tt.wantErr { + t.Errorf("FormattedError() error = \n%v\n, wantErr \n%v\n", gotFormattedMsg, tt.wantErr) + } }) } } @@ -236,6 +259,10 @@ Error: fourth error`, t.Errorf("command.Run() error = %v, wantErr %v", gotErrMsg, tt.wantErr) return } + gotFormattedMsg := errorutil.FormattedError(err) + if gotFormattedMsg != tt.wantErr { + t.Errorf("FormattedError() error = \n%v\n, wantErr \n%v\n", gotFormattedMsg, tt.wantErr) + } }) } } diff --git a/errorutil/formatted_error.go b/errorutil/formatted_error.go index 842c74b..e681780 100644 --- a/errorutil/formatted_error.go +++ b/errorutil/formatted_error.go @@ -1,34 +1,73 @@ package errorutil import ( - "errors" "strings" ) -// FormattedError ... -func FormattedError(err error) string { - var formatted string - - i := -1 - for { - i++ +func unwrap(err error) []error { + switch x := err.(type) { + case interface{ Unwrap() []error }: + return x.Unwrap() + case interface{ Unwrap() error }: + return []error{x.Unwrap()} + default: + return nil + } +} - reason := err.Error() +func formattedError(err error, printDebugOnly bool, indent int) string { + debugErr, isDebugErr := err.(DebugError) + if isDebugErr { + err = debugErr.OriginalError() + } - if err = errors.Unwrap(err); err == nil { - formatted = appendError(formatted, reason, i, true) - return formatted + formatted := "" + reason := err.Error() + wrappedErrs := []error{} + if wrappedErrs = unwrap(err); len(wrappedErrs) == 0 { + if isDebugErr == printDebugOnly { + formatted = appendError(formatted, reason, indent, true) } + return formatted + } - reason = strings.TrimSuffix(reason, err.Error()) - reason = strings.TrimRight(reason, " ") + for i := len(wrappedErrs) - 1; i >= 0; i-- { + reason = strings.TrimSuffix(reason, wrappedErrs[i].Error()) + reason = strings.TrimRight(reason, "\n ") reason = strings.TrimSuffix(reason, ":") + } - formatted = appendError(formatted, reason, i, false) + if isDebugErr == printDebugOnly { + formatted += appendError(formatted, reason, indent, false) + } + if !printDebugOnly && isDebugErr { // skip children of debug errors + return formatted + } + if printDebugOnly && isDebugErr { // print children of debug errors + printDebugOnly = false + } + for _, wrappedErr := range wrappedErrs { + formatted += formattedError(wrappedErr, printDebugOnly, indent+1) } + + return formatted +} + +// FormattedError ... +func FormattedError(err error) string { + return formattedError(err, false, 0) +} + +// FormattedErrorInternalDebugInfo ... +func FormattedErrorInternalDebugInfo(err error) string { + return formattedError(err, true, 0) } func appendError(errorMessage, reason string, i int, last bool) string { + if reason == "" { + return "" + } + if i == 0 { errorMessage = indentedReason(reason, i) } else { @@ -46,6 +85,10 @@ func appendError(errorMessage, reason string, i int, last bool) string { func indentedReason(reason string, level int) string { var lines []string split := strings.Split(reason, "\n") + if len(split) == 1 && split[0] == "" { + split = []string{"[empty error string]"} + } + for _, line := range split { line = strings.TrimLeft(line, " ") line = strings.TrimRight(line, "\n") diff --git a/errorutil/formatted_error_test.go b/errorutil/formatted_error_test.go index dfb9c03..5d9becc 100644 --- a/errorutil/formatted_error_test.go +++ b/errorutil/formatted_error_test.go @@ -49,13 +49,39 @@ func TestFormattedError(t *testing.T) { return err }, wantFormattedError: "fourth layer also failed: third layer also failed: second layer also failed: the magic has failed", }, + { + name: "Multiple wrapped errors in the same level", + error: func() error { + err := errors.New("the internal debug info") + err2 := fmt.Errorf("the description") + err = fmt.Errorf("third layer also failed: %w %w", err2, err) + err = fmt.Errorf("fourth layer also failed: %w", err) + return err + }, wantFormattedError: `fourth layer also failed: + third layer also failed: + the description + the internal debug info`, + }, + { + name: "Multiple wrapped errors in the same level, debug info hidden from stack trace", + error: func() error { + err := NewInternalDebugError(errors.New("the internal debug info")) + err2 := fmt.Errorf("the description") + err = fmt.Errorf("third layer also failed: %w %w", err, err2) + err = fmt.Errorf("fourth layer also failed: %w", err) + return err + }, wantFormattedError: `fourth layer also failed: + third layer also failed: + the description`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { formatted := FormattedError(tt.error()) + // require.Equal(t, tt.wantFormattedError, formatted) if formatted != tt.wantFormattedError { - t.Errorf("got formatted error = %s, want %s", formatted, tt.wantFormattedError) + t.Errorf("got formatted error = \n%s\n, want \n%s", formatted, tt.wantFormattedError) } }) } diff --git a/errorutil/interal_debug_error.go b/errorutil/interal_debug_error.go new file mode 100644 index 0000000..69194d3 --- /dev/null +++ b/errorutil/interal_debug_error.go @@ -0,0 +1,47 @@ +package errorutil + +import ( + "errors" +) + +// DebugError type errors will not be included in FormattedError output +// but due to Unwrap() any error that is wrapped by an InternalDebugError can be used with errors.As() +type DebugError interface { + Error() string + Unwrap() error + OriginalError() error +} + +// JoinInternalDebugError ... +func JoinInternalDebugError(err error, debugErr error) error { + return errors.Join(err, NewInternalDebugError(debugErr)) +} + +// InternalDebugError allows to include an error in the error chain but do not print it. +// this allows replacing it with a more readable error message, +// while allowing code to check for the type of the error +type InternalDebugError struct { + originalErr error +} + +// NewInternalDebugError ... +func NewInternalDebugError(originalErr error) error { + return &InternalDebugError{ + originalErr: originalErr, + } +} + +// Error ... +func (e *InternalDebugError) Error() string { + return "" // do not print this error as it is internal debug info +} + +// Unwrap ... +func (e *InternalDebugError) Unwrap() error { + return e.originalErr +} + +// OriginalError ... +func (e *InternalDebugError) OriginalError() error { + return e.originalErr +}