diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 000000000..aea38a441 --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,225 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package errors + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss/tree" + "github.com/goharbor/harbor-cli/pkg/views" +) + +var ( + as = errors.As +) + +type Frame struct { + Message string + Hints []string +} + +type Error struct { + frames []Frame + cause error +} + +func New(message string, hints ...string) *Error { + return &Error{ + frames: []Frame{{Message: message, Hints: hints}}, + } +} + +func Newf(format string, args ...any) *Error { + return &Error{ + frames: []Frame{{Message: fmt.Sprintf(format, args...)}}, + } +} + +func NewWithCause(cause error, message string, hints ...string) *Error { + return &Error{ + cause: cause, + frames: []Frame{{Message: message, Hints: hints}}, + } +} + +func (e *Error) WithMessage(message string, hints ...string) *Error { + e.frames = append(e.frames, Frame{Message: message, Hints: hints}) + return e +} + +func (e *Error) Error() string { + if len(e.frames) == 0 { + return "" + } + + var parts []string + + rootFrame := e.frames[0] + parts = append(parts, views.ErrCauseStyle.Render(rootFrame.Message)) + + if e.cause != nil { + if code := parseHarborErrorCode(e.cause); code != "" { + parts = append(parts, + views.ErrTitleStyle.Render("Code: ")+views.ErrCauseStyle.Render(code), + ) + } + } + + if e.cause != nil { + causeText := e.cause.Error() + if he := isHarborError(e.cause); he != nil { + causeText = he.Message() + } + + cause := views.ErrTitleStyle.Render("Cause: ") + causeText = views.ErrCauseStyle.Render(causeText) + causeTree := tree.Root(cause + causeText). + Enumerator(tree.RoundedEnumerator). + EnumeratorStyle(views.ErrEnumeratorStyle). + ItemStyle(views.ErrHintStyle) + + if he := isHarborError(e.cause); he != nil { + for _, h := range he.Hints() { + causeTree.Child(h) + } + } + parts = append(parts, causeTree.String()) + } + + if len(rootFrame.Hints) > 0 { + hintsTree := tree.New(). + Root("Hints:"). + RootStyle(views.ErrTitleStyle). + Enumerator(tree.RoundedEnumerator). + EnumeratorStyle(views.ErrEnumeratorStyle). + ItemStyle(views.ErrHintStyle) + + for _, h := range rootFrame.Hints { + hintsTree.Child(h) + } + parts = append(parts, hintsTree.String()) + } + + if len(e.frames) > 1 { + msgTree := tree.New(). + Root("Messages:"). + RootStyle(views.ErrTitleStyle). + Enumerator(tree.RoundedEnumerator). + EnumeratorStyle(views.ErrEnumeratorStyle). + ItemStyle(views.ErrTitleStyle) + + for _, f := range e.frames[1:] { + msgWithHints := tree.Root(f.Message). + RootStyle(views.ErrTitleStyle). + Enumerator(tree.RoundedEnumerator). + EnumeratorStyle(views.ErrEnumeratorStyle). + ItemStyle(views.ErrHintStyle) + for _, h := range f.Hints { + msgWithHints.Child(h) + } + msgTree.Child(msgWithHints) + } + parts = append(parts, msgTree.String()) + } + + return strings.Join(parts, "\n") +} + +func (e *Error) Message() string { + if len(e.frames) == 0 { + return "" + } + return e.frames[0].Message +} + +func (e *Error) Errors() []string { + msgs := make([]string, len(e.frames)) + for i, f := range e.frames { + msgs[i] = f.Message + } + return msgs +} + +func (e *Error) Hints() []string { + var all []string + for _, f := range e.frames { + all = append(all, f.Hints...) + } + return all +} + +func (e *Error) Frames() []Frame { + if len(e.frames) == 0 { + return nil + } + + frames := make([]Frame, len(e.frames)) + for i, f := range e.frames { + frames[i].Message = f.Message + if len(f.Hints) > 0 { + frames[i].Hints = append([]string(nil), f.Hints...) + } + } + + return frames +} + +func (e *Error) Cause() error { return e.cause } + +func (e *Error) Status() string { + if e.cause == nil { + return "" + } + return parseHarborErrorCode(e.cause) +} + +func (e *Error) Unwrap() error { return e.cause } +func AsError(err error) *Error { + var e *Error + if errors.As(err, &e) { + return e + } + return &Error{ + frames: []Frame{{Message: parseHarborErrorMsg(err)}}, + cause: err, + } +} + +func IsError(err error) bool { + var e *Error + return as(err, &e) +} + +func Cause(err error) error { + if e := isHarborError(err); e != nil { + return e.Cause() + } + return nil +} + +func Hints(err error) []string { + if e := isHarborError(err); e != nil { + return e.Hints() + } + return nil +} + +func Status(err error) string { + if e := isHarborError(err); e != nil { + return e.Status() + } + return "" +} diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go new file mode 100644 index 000000000..403558d4d --- /dev/null +++ b/pkg/errors/errors_test.go @@ -0,0 +1,289 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package errors_test + +import ( + "errors" + "fmt" + "testing" + + harborerr "github.com/goharbor/harbor-cli/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew_MessageOnly(t *testing.T) { + err := harborerr.New("something went wrong") + require.NotNil(t, err) + + output := err.Error() + assert.Contains(t, output, "something went wrong") + assert.Equal(t, "something went wrong", err.Message()) + assert.Equal(t, []string{"something went wrong"}, err.Errors()) + assert.Empty(t, err.Hints()) + assert.Nil(t, err.Cause()) +} + +func TestNew_MessageWithHints(t *testing.T) { + err := harborerr.New("auth failed", "check your credentials", "ensure token is not expired") + require.NotNil(t, err) + + output := err.Error() + assert.Contains(t, output, "auth failed") + assert.Contains(t, output, "check your credentials") + assert.Contains(t, output, "ensure token is not expired") + + assert.Equal(t, "auth failed", err.Message()) + assert.Equal(t, []string{"check your credentials", "ensure token is not expired"}, err.Hints()) + assert.Nil(t, err.Cause()) +} + +func TestNewf_FormatsMessage(t *testing.T) { + err := harborerr.Newf("resource %q not found", "project-x") + require.NotNil(t, err) + + output := err.Error() + assert.Contains(t, output, `resource "project-x" not found`) + assert.Equal(t, `resource "project-x" not found`, err.Message()) + assert.Empty(t, err.Hints()) +} + +func TestNewWithCause_Basic(t *testing.T) { + cause := errors.New("connection refused") + err := harborerr.NewWithCause(cause, "could not reach registry") + + output := err.Error() + assert.Contains(t, output, "could not reach registry") + assert.Contains(t, output, "Cause:") + assert.Contains(t, output, "connection refused") + assert.NotContains(t, output, "Messages:") + + assert.Equal(t, cause, err.Cause()) + assert.Equal(t, "could not reach registry", err.Message()) +} + +func TestNewWithCause_WithHints(t *testing.T) { + cause := errors.New("401 Unauthorized") + err := harborerr.NewWithCause(cause, "authentication failed", + "ensure your credentials are correct", + "run `harbor login` to re-authenticate", + ) + + output := err.Error() + assert.Contains(t, output, "authentication failed") + assert.Contains(t, output, "ensure your credentials are correct") + assert.Contains(t, output, "run `harbor login` to re-authenticate") + assert.Contains(t, output, "Cause:") + assert.Contains(t, output, "401 Unauthorized") + assert.NotContains(t, output, "Messages:") + + assert.Equal(t, cause, err.Cause()) + assert.Equal(t, []string{ + "ensure your credentials are correct", + "run `harbor login` to re-authenticate", + }, err.Hints()) +} + +func TestNewWithCause_CauseIsHarborError_ShowsCauseHintsInHeader(t *testing.T) { + inner := harborerr.New("db connection lost", "check that the database is running") + outer := harborerr.NewWithCause(inner, "repository unavailable") + + output := outer.Error() + assert.Contains(t, output, "repository unavailable") + assert.Contains(t, output, "check that the database is running") + assert.NotContains(t, output, "Messages:") +} + +func TestWithMessage_AppendsFrame(t *testing.T) { + err := harborerr.NewWithCause( + errors.New("dial tcp 127.0.0.1:5432: connect: connection refused"), + "repository unavailable", "check that the database is running", + ).WithMessage("failed to delete artifact", + "retry after resolving the underlying issue", + "use --force to skip confirmation prompts", + ) + + output := err.Error() + assert.Contains(t, output, "repository unavailable") + assert.Contains(t, output, "check that the database is running") + assert.Contains(t, output, "Cause:") + assert.Contains(t, output, "connection refused") + assert.Contains(t, output, "Messages:") + assert.Contains(t, output, "failed to delete artifact") + assert.Contains(t, output, "retry after resolving the underlying issue") + assert.Contains(t, output, "use --force to skip confirmation prompts") + + assert.Equal(t, "repository unavailable", err.Message()) + assert.Len(t, err.Frames(), 2) +} + +func TestWithMessage_MultipleFrames(t *testing.T) { + err := harborerr.New("step 1"). + WithMessage("step 2", "hint A"). + WithMessage("step 3", "hint B", "hint C") + + assert.Len(t, err.Frames(), 3) + assert.Equal(t, "step 1", err.Frames()[0].Message) + assert.Equal(t, "step 2", err.Frames()[1].Message) + assert.Equal(t, []string{"hint A"}, err.Frames()[1].Hints) + assert.Equal(t, "step 3", err.Frames()[2].Message) + assert.Equal(t, []string{"hint B", "hint C"}, err.Frames()[2].Hints) +} + +func TestWithMessage_NoCause_NoRootHeader(t *testing.T) { + err := harborerr.New("first").WithMessage("second", "a hint") + + output := err.Error() + assert.NotContains(t, output, "Root:") + assert.NotContains(t, output, "Code:") + assert.Contains(t, output, "first") + assert.Contains(t, output, "second") + assert.Contains(t, output, "a hint") +} + +func TestError_EmptyFrames(t *testing.T) { + err := &harborerr.Error{} + assert.Equal(t, "", err.Error()) +} + +func TestError_NoCause_SingleFrameNoHints(t *testing.T) { + err := harborerr.New("operation not supported") + output := err.Error() + + assert.NotContains(t, output, "Root:") + assert.NotContains(t, output, "Code:") + assert.Contains(t, output, "operation not supported") +} + +func TestError_NoCause_SingleFrameWithHints(t *testing.T) { + err := harborerr.New("plugin system not implemented", + "this command is a placeholder for future plugin management", + ) + + output := err.Error() + assert.NotContains(t, output, "Root:") + assert.Contains(t, output, "plugin system not implemented") + assert.Contains(t, output, "this command is a placeholder for future plugin management") +} + +func TestMessage_ReturnsFirstFrame(t *testing.T) { + err := harborerr.New("first").WithMessage("second") + assert.Equal(t, "first", err.Message()) +} + +func TestErrors_AllMessages(t *testing.T) { + err := harborerr.New("a").WithMessage("b").WithMessage("c") + assert.Equal(t, []string{"a", "b", "c"}, err.Errors()) +} + +func TestHints_AcrossAllFrames(t *testing.T) { + err := harborerr.New("m1", "h1", "h2").WithMessage("m2", "h3") + assert.Equal(t, []string{"h1", "h2", "h3"}, err.Hints()) +} + +func TestFrames_ReturnsAll(t *testing.T) { + err := harborerr.New("root", "hint-a") + frames := err.Frames() + require.Len(t, frames, 1) + assert.Equal(t, "root", frames[0].Message) + assert.Equal(t, []string{"hint-a"}, frames[0].Hints) +} + +func TestCause_ReturnsUnderlyingError(t *testing.T) { + sentinel := errors.New("sentinel") + err := harborerr.NewWithCause(sentinel, "wrapper") + assert.Equal(t, sentinel, err.Cause()) +} + +func TestCause_NilWhenNoCause(t *testing.T) { + err := harborerr.New("standalone") + assert.Nil(t, err.Cause()) +} + +func TestStatus_NoCause_ReturnsEmpty(t *testing.T) { + assert.Equal(t, "", harborerr.Status(harborerr.New("no cause"))) +} + +func TestStatus_PlainError_ReturnsEmpty(t *testing.T) { + assert.Equal(t, "", harborerr.Status(errors.New("plain"))) +} + +func TestUnwrap_ReturnsTheCause(t *testing.T) { + sentinel := errors.New("sentinel") + err := harborerr.NewWithCause(sentinel, "wrapper") + + assert.Equal(t, sentinel, err.Unwrap()) + assert.True(t, errors.Is(err, sentinel)) +} + +func TestErrorsAs_FindsHarborError(t *testing.T) { + inner := harborerr.New("inner") + outer := harborerr.NewWithCause(inner, "outer") + + var target *harborerr.Error + assert.True(t, errors.As(outer, &target)) + assert.Contains(t, target.Error(), "inner") + assert.Contains(t, target.Error(), "outer") +} + +func TestIsError_True_DirectHarborError(t *testing.T) { + assert.True(t, harborerr.IsError(harborerr.New("err"))) +} + +func TestIsError_True_WrappedWithFmtErrorf(t *testing.T) { + wrapped := fmt.Errorf("outer: %w", harborerr.New("inner")) + assert.True(t, harborerr.IsError(wrapped)) +} + +func TestIsError_False_PlainError(t *testing.T) { + assert.False(t, harborerr.IsError(errors.New("plain"))) +} + +func TestAsError_FromHarborError_ReturnsSame(t *testing.T) { + original := harborerr.New("original") + wrapped := fmt.Errorf("wrapped: %w", original) + + result := harborerr.AsError(wrapped) + require.NotNil(t, result) + assert.Contains(t, result.Error(), "original") +} + +func TestAsError_FromPlainError_WrapsIntoSingleFrame(t *testing.T) { + plain := errors.New("plain error") + result := harborerr.AsError(plain) + require.NotNil(t, result) + + assert.Contains(t, result.Error(), "plain error") + assert.Equal(t, []string{"plain error"}, result.Errors()) + assert.Equal(t, plain, result.Cause()) +} + +func TestCause_PackageLevel_HarborError(t *testing.T) { + sentinel := errors.New("root") + err := harborerr.NewWithCause(sentinel, "top") + assert.Equal(t, sentinel, harborerr.Cause(err)) +} + +func TestCause_PackageLevel_PlainError(t *testing.T) { + assert.Nil(t, harborerr.Cause(errors.New("plain"))) +} + +func TestHints_PackageLevel_HarborError(t *testing.T) { + err := harborerr.New("error", "check config") + assert.Equal(t, []string{"check config"}, harborerr.Hints(err)) +} + +func TestHints_PackageLevel_PlainError(t *testing.T) { + assert.Nil(t, harborerr.Hints(errors.New("plain"))) +} diff --git a/pkg/errors/utils.go b/pkg/errors/utils.go new file mode 100644 index 000000000..b24b7a6ea --- /dev/null +++ b/pkg/errors/utils.go @@ -0,0 +1,88 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package errors + +import ( + "encoding/json" + "fmt" + "reflect" + "regexp" + "strings" +) + +type harborErrorPayload struct { + Errors []struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"errors"` +} + +func isHarborError(err error) *Error { + var e *Error + if as(err, &e) { + return e + } + return nil +} + +func parseHarborErrorMsg(err error) string { + if err == nil { + return "" + } + + val := reflect.ValueOf(err) + if val.Kind() == reflect.Pointer { + if val.IsNil() { + return err.Error() + } + val = val.Elem() + } + if val.Kind() != reflect.Struct { + return err.Error() + } + field := val.FieldByName("Payload") + if field.IsValid() { + payload := field.Interface() + jsonBytes, jsonErr := json.Marshal(payload) + if jsonErr == nil { + var harborErr harborErrorPayload + if unmarshalErr := json.Unmarshal(jsonBytes, &harborErr); unmarshalErr == nil { + if len(harborErr.Errors) > 0 { + return harborErr.Errors[0].Message + } + } + } + } + return fmt.Sprintf("%v", err.Error()) +} + +func parseHarborErrorCode(err error) string { + errStr := err.Error() + + parts := strings.Split(errStr, "]") + if len(parts) >= 2 { + codePart := strings.TrimSpace(parts[1]) + if strings.HasPrefix(codePart, "[") && len(codePart) == 4 { + code := codePart[1:4] + return code + } + } + + re := regexp.MustCompile(`\(status\s+(\d{3})\)`) + if matches := re.FindStringSubmatch(errStr); len(matches) > 1 { + return matches[1] + } + + return "" +} diff --git a/pkg/views/styles.go b/pkg/views/styles.go index 240ef23bc..4ce188594 100644 --- a/pkg/views/styles.go +++ b/pkg/views/styles.go @@ -37,6 +37,19 @@ var ( var BaseStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()).Padding(0, 1) +var ( + ErrCauseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("203")). + Bold(true) + ErrTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")) + ErrHintStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("44")) + ErrEnumeratorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("242")). + MarginRight(1) +) + func RedText(strs ...string) string { var msg strings.Builder for _, str := range strs {