diff --git a/logging/error.go b/logging/error.go new file mode 100644 index 00000000..03aabb7a --- /dev/null +++ b/logging/error.go @@ -0,0 +1,59 @@ +// Copyright 2026, TeamDev. All rights reserved. +// +// Redistribution and use in source and/or binary forms, with or without +// modification, must retain the above copyright notice and the following +// disclaimer. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package logging + +import ( + "fmt" + "strings" +) + +// FormatError formats a single error inline and joined errors as a bullet list. +func FormatError(message string, err error) string { + errs := flattenedErrors(err) + if len(errs) <= 1 { + return fmt.Sprintf("%s: %v", message, err) + } + + var builder strings.Builder + builder.WriteString(message) + builder.WriteString(":") + for _, nestedErr := range errs { + builder.WriteString("\n - ") + builder.WriteString(nestedErr.Error()) + } + + return builder.String() +} + +// flattenedErrors returns the leaf errors from a joined error. +func flattenedErrors(err error) []error { + joined, ok := err.(interface { + Unwrap() []error + }) + if !ok { + return []error{err} + } + + var result []error + for _, nestedErr := range joined.Unwrap() { + result = append(result, flattenedErrors(nestedErr)...) + } + + return result +} diff --git a/logging/error_test.go b/logging/error_test.go new file mode 100644 index 00000000..b375e9f8 --- /dev/null +++ b/logging/error_test.go @@ -0,0 +1,78 @@ +// Copyright 2026, TeamDev. All rights reserved. +// +// Redistribution and use in source and/or binary forms, with or without +// modification, must retain the above copyright notice and the following +// disclaimer. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package logging_test + +import ( + "errors" + "fmt" + + "embed-code/embed-code-go/logging" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Error formatting", func() { + + It("should format a single error inline", func() { + err := errors.New("first failure") + + Expect(logging.FormatError("operation failed", err)).To( + Equal("operation failed: first failure"), + ) + }) + + It("should format a nested error inline", func() { + err := fmt.Errorf("outer context: %w", errors.New("first failure")) + + Expect(logging.FormatError("operation failed", err)).To( + Equal("operation failed: outer context: first failure"), + ) + }) + + It("should format joined errors as a bullet list", func() { + err := errors.Join( + errors.New("first failure"), + errors.New("second failure"), + ) + + Expect(logging.FormatError("operation failed", err)).To( + Equal("operation failed:\n" + + " - first failure\n" + + " - second failure"), + ) + }) + + It("should flatten nested joined errors into a bullet list", func() { + err := errors.Join( + errors.New("first failure"), + errors.Join( + errors.New("second failure"), + errors.New("third failure"), + ), + ) + + Expect(logging.FormatError("operation failed", err)).To( + Equal("operation failed:\n" + + " - first failure\n" + + " - second failure\n" + + " - third failure"), + ) + }) +}) diff --git a/logging/logger.go b/logging/logger.go index d9e73383..67d0f3fe 100644 --- a/logging/logger.go +++ b/logging/logger.go @@ -177,19 +177,5 @@ func formatPanicMessage(recovered any) string { return fmt.Sprintf("panic: %v", recovered) } - joined, isJoined := err.(interface { - Unwrap() []error - }) - if !isJoined || len(joined.Unwrap()) <= 1 { - return fmt.Sprintf("panic: %v", err) - } - - var builder strings.Builder - builder.WriteString("panic:") - for _, wrappedErr := range joined.Unwrap() { - builder.WriteString("\n- ") - builder.WriteString(wrappedErr.Error()) - } - - return builder.String() + return FormatError("panic", err) } diff --git a/logging/logger_test.go b/logging/logger_test.go index e275fce8..51427a12 100644 --- a/logging/logger_test.go +++ b/logging/logger_test.go @@ -18,44 +18,42 @@ package logging //nolint:testpackage // Tests OS-specific normalization in an unexported helper. -import "testing" - -// TestFileURLFromAbsolutePath verifies OS-specific path shapes are valid file URLs. -func TestFileURLFromAbsolutePath(t *testing.T) { - tests := []struct { - name string - path string - want string - }{ - { - name: "unix path", - path: "/Users/me/project/file.go", - want: "file:///Users/me/project/file.go", - }, - { - name: "windows drive path", - path: `C:\Users\me\project\file.go`, - want: "file:///C:/Users/me/project/file.go", - }, - { - name: "windows drive path with spaces", - path: `C:\Users\me\my project\file.go`, - want: "file:///C:/Users/me/my%20project/file.go", - }, - { - name: "windows unc path", - path: `\\server\share\project\file.go`, - want: "file://server/share/project/file.go", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got := fileURLFromAbsolutePath(test.path) - if got != test.want { - t.Fatalf("fileURLFromAbsolutePath(%q) = %q, want %q", - test.path, got, test.want) - } - }) - } +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// TestLogging runs the logging package specs. +func TestLogging(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Logging Suite") } + +var _ = Describe("File URL formatting", func() { + + It("should format a Unix path", func() { + Expect(fileURLFromAbsolutePath("/Users/me/project/file.go")).To( + Equal("file:///Users/me/project/file.go"), + ) + }) + + It("should format a Windows drive path", func() { + Expect(fileURLFromAbsolutePath(`C:\Users\me\project\file.go`)).To( + Equal("file:///C:/Users/me/project/file.go"), + ) + }) + + It("should escape spaces in a Windows drive path", func() { + Expect(fileURLFromAbsolutePath(`C:\Users\me\my project\file.go`)).To( + Equal("file:///C:/Users/me/my%20project/file.go"), + ) + }) + + It("should format a Windows UNC path", func() { + Expect(fileURLFromAbsolutePath(`\\server\share\project\file.go`)).To( + Equal("file://server/share/project/file.go"), + ) + }) +}) diff --git a/main.go b/main.go index fec817ce..4cc775f6 100644 --- a/main.go +++ b/main.go @@ -136,49 +136,9 @@ func configureLogging(config cli.Config) { slog.SetDefault(logger) } -// logError writes a user-facing error through the configured logger. -func logError(message string, err error) { - slog.Error(formatError(message, err)) -} - -// formatError formats single errors inline and joined errors as a bullet list. -func formatError(message string, err error) string { - errs := flattenedErrors(err) - if len(errs) <= 1 { - return fmt.Sprintf("%s: %v", message, err) - } - - var builder strings.Builder - builder.WriteString(message) - builder.WriteString(":") - for _, nestedErr := range errs { - builder.WriteString("\n- ") - builder.WriteString(nestedErr.Error()) - } - - return builder.String() -} - -// flattenedErrors returns the leaf errors from a joined error joined. -func flattenedErrors(err error) []error { - joined, ok := err.(interface { - Unwrap() []error - }) - if !ok { - return []error{err} - } - - var result []error - for _, nestedErr := range joined.Unwrap() { - result = append(result, flattenedErrors(nestedErr)...) - } - - return result -} - // exitWithError writes a user-facing error and exits with a failing status. func exitWithError(message string, err error) { - logError(message, err) + slog.Error(logging.FormatError(message, err)) os.Exit(1) }