From 2493fb4dc695b9d259f4834d4d73489cb882d60c Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Thu, 21 Aug 2025 18:26:05 +0530 Subject: [PATCH] chore: Better user friendly error messages (#64) --- cmd/npm/bun.go | 9 +- cmd/npm/npm.go | 9 +- cmd/npm/pnpm.go | 10 +- cmd/pypi/pip.go | 9 +- cmd/pypi/uv.go | 8 +- cmd/setup/setup.go | 6 +- internal/alias/alias.go | 22 +-- internal/flows/common.go | 9 +- internal/ui/error.go | 33 ++++ packagemanager/errors.go | 47 +++++- packagemanager/npm.go | 2 +- packagemanager/npm_resolver.go | 2 +- packagemanager/pypi.go | 8 +- packagemanager/pypi_resolver.go | 8 +- usefulerror/error.go | 158 +++++++++++++++++++ usefulerror/error_test.go | 271 ++++++++++++++++++++++++++++++++ 16 files changed, 555 insertions(+), 56 deletions(-) create mode 100644 internal/ui/error.go create mode 100644 usefulerror/error.go create mode 100644 usefulerror/error_test.go diff --git a/cmd/npm/bun.go b/cmd/npm/bun.go index 451fcfd..194f69a 100644 --- a/cmd/npm/bun.go +++ b/cmd/npm/bun.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" @@ -21,7 +20,7 @@ func NewBunCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { err := executeBunFlow(cmd.Context(), args) if err != nil { - log.Errorf("Failed to execute bun flow: %s", err) + ui.ErrorExit(err) } return nil @@ -33,12 +32,12 @@ func executeBunFlow(ctx context.Context, args []string) error { analytics.TrackCommandBun() packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultBunPackageManagerConfig()) if err != nil { - ui.Fatalf("Failed to create bun package manager proxy: %s", err) + return fmt.Errorf("failed to create bun package manager proxy: %w", err) } config, err := config.FromContext(ctx) if err != nil { - ui.Fatalf("Failed to get config: %s", err) + return fmt.Errorf("failed to get config: %w", err) } parsedCommand, err := packageManager.ParseCommand(args) @@ -53,7 +52,7 @@ func executeBunFlow(ctx context.Context, args []string) error { packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) if err != nil { - ui.Fatalf("Failed to create dependency resolver: %s", err) + return fmt.Errorf("failed to create dependency resolver: %w", err) } return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand) diff --git a/cmd/npm/npm.go b/cmd/npm/npm.go index 2a2ef56..b6f408e 100644 --- a/cmd/npm/npm.go +++ b/cmd/npm/npm.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" @@ -21,7 +20,7 @@ func NewNpmCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { err := executeNpmFlow(cmd.Context(), args) if err != nil { - log.Errorf("Failed to execute npm flow: %s", err) + ui.ErrorExit(err) } return nil @@ -33,12 +32,12 @@ func executeNpmFlow(ctx context.Context, args []string) error { analytics.TrackCommandNpm() packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultNpmPackageManagerConfig()) if err != nil { - ui.Fatalf("Failed to create npm package manager proxy: %s", err) + return fmt.Errorf("failed to create npm package manager proxy: %w", err) } config, err := config.FromContext(ctx) if err != nil { - ui.Fatalf("Failed to get config: %s", err) + return fmt.Errorf("failed to get config: %w", err) } parsedCommand, err := packageManager.ParseCommand(args) @@ -53,7 +52,7 @@ func executeNpmFlow(ctx context.Context, args []string) error { packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) if err != nil { - ui.Fatalf("Failed to create dependency resolver: %s", err) + return fmt.Errorf("failed to create dependency resolver: %w", err) } return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand) diff --git a/cmd/npm/pnpm.go b/cmd/npm/pnpm.go index 3e138f3..fb86534 100644 --- a/cmd/npm/pnpm.go +++ b/cmd/npm/pnpm.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" @@ -21,7 +20,7 @@ func NewPnpmCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { err := executePnpmFlow(cmd.Context(), args) if err != nil { - log.Errorf("Failed to execute pnpm flow: %s", err) + ui.ErrorExit(err) } return nil @@ -33,12 +32,12 @@ func executePnpmFlow(ctx context.Context, args []string) error { analytics.TrackCommandPnpm() packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultPnpmPackageManagerConfig()) if err != nil { - ui.Fatalf("Failed to create pnpm package manager proxy: %s", err) + return fmt.Errorf("failed to create pnpm package manager proxy: %w", err) } config, err := config.FromContext(ctx) if err != nil { - ui.Fatalf("Failed to get config: %s", err) + return fmt.Errorf("failed to get config: %w", err) } parsedCommand, err := packageManager.ParseCommand(args) @@ -52,6 +51,9 @@ func executePnpmFlow(ctx context.Context, args []string) error { packageResolverConfig.IncludeDevDependencies = config.IncludeDevDependencies packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) + if err != nil { + return fmt.Errorf("failed to create dependency resolver: %w", err) + } return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand) } diff --git a/cmd/pypi/pip.go b/cmd/pypi/pip.go index 4492256..b85ffe2 100644 --- a/cmd/pypi/pip.go +++ b/cmd/pypi/pip.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" @@ -21,7 +20,7 @@ func NewPipCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { err := executePipFlow(cmd.Context(), args) if err != nil { - log.Errorf("Failed to execute pip flow: %s", err) + ui.ErrorExit(err) } return nil @@ -33,12 +32,12 @@ func executePipFlow(ctx context.Context, args []string) error { analytics.TrackCommandPip() packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultPipPackageManagerConfig()) if err != nil { - return fmt.Errorf("failed to create pip package manager: %w", err) + return fmt.Errorf("failed to create pip package manager proxy: %w", err) } config, err := config.FromContext(ctx) if err != nil { - ui.Fatalf("Failed to get config: %s", err) + return fmt.Errorf("failed to get config: %w", err) } parsedCommand, err := packageManager.ParseCommand(args) @@ -55,7 +54,7 @@ func executePipFlow(ctx context.Context, args []string) error { packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) if err != nil { - ui.Fatalf("Failed to create dependency resolver: %s", err) + return fmt.Errorf("failed to create dependency resolver: %w", err) } return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand) diff --git a/cmd/pypi/uv.go b/cmd/pypi/uv.go index f5cb125..ba242d4 100644 --- a/cmd/pypi/uv.go +++ b/cmd/pypi/uv.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - "github.com/safedep/dry/log" "github.com/safedep/pmg/config" "github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/flows" @@ -21,7 +20,7 @@ func NewUvCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { err := executeUvFlow(cmd.Context(), args) if err != nil { - log.Errorf("Failed to execute uv flow: %s", err) + ui.ErrorExit(err) } return nil @@ -38,7 +37,7 @@ func executeUvFlow(ctx context.Context, args []string) error { config, err := config.FromContext(ctx) if err != nil { - ui.Fatalf("Failed to get config: %s", err) + return fmt.Errorf("failed to get config: %w", err) } parsedCommand, err := packageManager.ParseCommand(args) @@ -46,7 +45,6 @@ func executeUvFlow(ctx context.Context, args []string) error { return fmt.Errorf("failed to parse command: %w", err) } - // Parse the args right here packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() packageResolverConfig.IncludeTransitiveDependencies = config.Transitive packageResolverConfig.TransitiveDepth = config.TransitiveDepth @@ -55,7 +53,7 @@ func executeUvFlow(ctx context.Context, args []string) error { packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) if err != nil { - ui.Fatalf("Failed to create dependency resolver: %s", err) + return fmt.Errorf("failed to create dependency resolver: %w", err) } return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index b0d4dfd..146d093 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -32,7 +32,8 @@ func NewInstallCommand() *cobra.Command { if err != nil { return err } - aliasManager := alias.New(*config, rcFileManager) + + aliasManager := alias.New(config, rcFileManager) return aliasManager.Install() }, } @@ -48,7 +49,8 @@ func NewRemoveCommand() *cobra.Command { if err != nil { return err } - aliasManager := alias.New(*config, rcFileManager) + + aliasManager := alias.New(config, rcFileManager) return aliasManager.Remove() }, } diff --git a/internal/alias/alias.go b/internal/alias/alias.go index ed75acd..dac764e 100644 --- a/internal/alias/alias.go +++ b/internal/alias/alias.go @@ -32,25 +32,28 @@ type RcFileManager interface { } // DefaultRcFileManager implements RcFileManager for managing the RC file. -type DefaultRcFileManager struct { +type defaultRcFileManager struct { HomeDir string RcFileName string } +var _ RcFileManager = &defaultRcFileManager{} + // NewDefaultRcFileManager creates a new DefaultRcFileManager. -func NewDefaultRcFileManager(rcFileName string) (*DefaultRcFileManager, error) { +func NewDefaultRcFileManager(rcFileName string) (*defaultRcFileManager, error) { homeDir, err := os.UserHomeDir() if err != nil { return nil, err } - return &DefaultRcFileManager{ + + return &defaultRcFileManager{ HomeDir: homeDir, RcFileName: rcFileName, }, nil } // Create creates the RC file with the given aliases. -func (m *DefaultRcFileManager) Create(aliases []string) (string, error) { +func (m *defaultRcFileManager) Create(aliases []string) (string, error) { rcPath := m.GetRcPath() f, err := os.Create(rcPath) if err != nil { @@ -67,7 +70,7 @@ func (m *DefaultRcFileManager) Create(aliases []string) (string, error) { } // Remove deletes the RC file. -func (m *DefaultRcFileManager) Remove() error { +func (m *defaultRcFileManager) Remove() error { rcPath := m.GetRcPath() if err := os.Remove(rcPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("could not delete %s: %w", rcPath, err) @@ -76,12 +79,12 @@ func (m *DefaultRcFileManager) Remove() error { } // GetRcPath returns the full path to the RC file. -func (m *DefaultRcFileManager) GetRcPath() string { +func (m *defaultRcFileManager) GetRcPath() string { return filepath.Join(m.HomeDir, m.RcFileName) } // DefaultConfig returns the default configuration for alias management. -func DefaultConfig() *AliasConfig { +func DefaultConfig() AliasConfig { var shells []Shell fishShell, _ := NewFishShell() @@ -90,7 +93,7 @@ func DefaultConfig() *AliasConfig { shells = append(shells, fishShell, zshShell, bashShell) - return &AliasConfig{ + return AliasConfig{ RcFileName: ".pmg.rc", PackageManagers: []string{"npm", "pip", "pnpm", "bun", "uv"}, Shells: shells, @@ -243,8 +246,9 @@ func (a *AliasManager) addSourceLine(configPath, sourceLine string) error { if err != nil { return err } + defer f.Close() - _, err = f.WriteString(fmt.Sprintf("\n%s", sourceLine)) + _, err = fmt.Fprintf(f, "\n%s", sourceLine) return err } diff --git a/internal/flows/common.go b/internal/flows/common.go index f7af8d3..dfcfe07 100644 --- a/internal/flows/common.go +++ b/internal/flows/common.go @@ -2,6 +2,7 @@ package flows import ( "context" + "fmt" "github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/config" @@ -33,14 +34,14 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem if f.config.Paranoid { malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig()) if err != nil { - ui.Fatalf("Failed to create malware analyzer: %s", err) + return fmt.Errorf("failed to create malware analyzer: %s", err) } analyzers = append(analyzers, malysisActiveScanAnalyzer) } else { malysisQueryAnalyzer, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) if err != nil { - ui.Fatalf("Failed to create malware analyzer: %s", err) + return fmt.Errorf("failed to create malware analyzer: %s", err) } analyzers = append(analyzers, malysisQueryAnalyzer) @@ -60,12 +61,12 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem proxy, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction) if err != nil { - ui.Fatalf("Failed to create package manager guard: %s", err) + return fmt.Errorf("failed to create package manager guard: %s", err) } err = proxy.Run(ctx, args, parsedCmd) if err != nil { - ui.Fatalf("pmg: failed to execute command: %s", err) + return fmt.Errorf("failed to run package manager guard: %w", err) } return err diff --git a/internal/ui/error.go b/internal/ui/error.go new file mode 100644 index 0000000..0bbddbf --- /dev/null +++ b/internal/ui/error.go @@ -0,0 +1,33 @@ +package ui + +import ( + "fmt" + "os" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/usefulerror" +) + +// ErrorExit prints the error message and exits the program with a non-zero status code. +func ErrorExit(err error) { + log.Errorf("Exiting due to error: %s", err) + + usefulErr, ok := usefulerror.AsUsefulError(err) + if !ok { + Fatalf("Error: %s", err) + } + + additionalHelp := usefulErr.AdditionalHelp() + if additionalHelp == "" { + additionalHelp = fmt.Sprintf("If you believe this is a bug, please report it at: %s", + "https://github.com/safedep/pmg/issues/new?assignees=&labels=bug") + } + + ClearStatus() + + fmt.Println(Colors.Red(fmt.Sprintf("Error occurred: %s", usefulErr.HumanError()))) + fmt.Println(Colors.Yellow(usefulErr.Help())) + fmt.Println(Colors.Yellow(additionalHelp)) + + os.Exit(1) +} diff --git a/packagemanager/errors.go b/packagemanager/errors.go index beb9127..d7f5f32 100644 --- a/packagemanager/errors.go +++ b/packagemanager/errors.go @@ -1,15 +1,48 @@ package packagemanager import ( - "errors" + "github.com/safedep/pmg/usefulerror" ) var ( - ErrPackageNotFound = errors.New("package not found") - ErrFailedToFetchPackage = errors.New("failed to fetch package") - ErrFailedToParsePackage = errors.New("failed to parse package") - ErrNoPackagesFound = errors.New("no packages found") - ErrAuthorNotFound = errors.New("author not found") + ErrPackageNotFound = usefulerror.Useful(). + WithCode("package_not_found"). + WithHumanError("The requested package could not be found."). + WithHelp("Please check the package name and try again.") - ErrGitHubRateLimitExceeded = errors.New("github api rate limit exceeded") + ErrFailedToFetchPackage = usefulerror.Useful(). + WithCode("fetch_failed"). + WithHumanError("Failed to retrieve the requested package."). + WithHelp("Check your network connection and try again. If the problem persists, the package repository may be temporarily unavailable."). + Msg("failed to fetch package") + + ErrFailedToResolveVersion = usefulerror.Useful(). + WithCode("resolve_version_failed"). + WithHumanError("Failed to resolve the requested package version."). + WithHelp("Check your network connection and try again. If the problem persists, the package repository may be temporarily unavailable."). + Msg("failed to resolve package version") + + ErrFailedToResolveDependencies = usefulerror.Useful(). + WithCode("resolve_failed"). + WithHumanError("Failed to resolve dependencies."). + WithHelp("Check your network connection and try again. If the problem persists, the package repository may be temporarily unavailable."). + Msg("failed to resolve dependencies") + + ErrFailedToParsePackage = usefulerror.Useful(). + WithCode("parse_failed"). + WithHumanError("The package data could not be processed."). + WithHelp("The package may be corrupted or in an unsupported format."). + Msg("failed to parse package") + + ErrAuthorNotFound = usefulerror.Useful(). + WithCode("author_not_found"). + WithHumanError("The package author information could not be found."). + WithHelp("This may be due to incomplete package metadata or network issues."). + Msg("author not found") + + ErrGitHubRateLimitExceeded = usefulerror.Useful(). + WithCode("github_rate_limit"). + WithHumanError("GitHub API rate limit has been exceeded."). + WithHelp("Wait for the rate limit to reset or configure authentication to increase your rate limit."). + Msg("github api rate limit exceeded") ) diff --git a/packagemanager/npm.go b/packagemanager/npm.go index 91dd3c4..135c23b 100644 --- a/packagemanager/npm.go +++ b/packagemanager/npm.go @@ -107,7 +107,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error for _, pkg := range packages { packageName, version, err := npmParsePackageInfo(pkg) if err != nil { - return nil, fmt.Errorf("failed to parse package info: %w", err) + return nil, ErrFailedToParsePackage.Wrap(err) } // Clean version if specified diff --git a/packagemanager/npm_resolver.go b/packagemanager/npm_resolver.go index 9cc6c7c..351b280 100644 --- a/packagemanager/npm_resolver.go +++ b/packagemanager/npm_resolver.go @@ -59,7 +59,7 @@ func (r *npmDependencyResolver) ResolveLatestVersion(ctx context.Context, pkgInfo, err := pd.GetPackage(pkg.Name) if err != nil { - return nil, fmt.Errorf("failed to get package: %w", err) + return nil, ErrFailedToFetchPackage.Wrap(err) } log.Debugf("Resolved npm/%s to latest version %s", pkg.Name, pkgInfo.LatestVersion) diff --git a/packagemanager/pypi.go b/packagemanager/pypi.go index 9cbd599..c5b0219 100644 --- a/packagemanager/pypi.go +++ b/packagemanager/pypi.go @@ -141,7 +141,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { for _, pkg := range packages { packageName, version, extras, err := pypiParsePackageInfo(pkg) if err != nil { - return nil, fmt.Errorf("failed to parse package info: %w", err) + return nil, ErrFailedToParsePackage.Wrap(err) } if version != "" { @@ -150,7 +150,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { } else { version, err = pypiGetMatchingVersion(packageName, version) if err != nil { - return nil, fmt.Errorf("error resolving version for %s: %s", packageName, err.Error()) + return nil, ErrFailedToResolveVersion.Wrap(err) } } } @@ -257,7 +257,7 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { for _, pkg := range packages { packageName, version, extras, err := pypiParsePackageInfo(pkg) if err != nil { - return nil, fmt.Errorf("failed to parse package info: %w", err) + return nil, ErrFailedToParsePackage.Wrap(err) } if version != "" { @@ -266,7 +266,7 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) { } else { version, err = pypiGetMatchingVersion(packageName, version) if err != nil { - return nil, fmt.Errorf("error resolving version for %s: %s", packageName, err.Error()) + return nil, ErrFailedToResolveVersion.Wrap(err) } } } diff --git a/packagemanager/pypi_resolver.go b/packagemanager/pypi_resolver.go index 5f1e55d..a1bc850 100644 --- a/packagemanager/pypi_resolver.go +++ b/packagemanager/pypi_resolver.go @@ -158,22 +158,22 @@ func getPypiPackageDependencies(packageName, version string, packageTargets []*P res, err := http.Get(url) if err != nil { - return nil, ErrFailedToFetchPackage + return nil, ErrFailedToFetchPackage.Wrap(err) } if res.StatusCode == 404 { - return nil, ErrPackageNotFound + return nil, ErrPackageNotFound.Wrap(err) } if res.StatusCode != 200 { - return nil, ErrFailedToFetchPackage + return nil, ErrFailedToFetchPackage.Wrap(err) } defer res.Body.Close() var pypipkg pypiPackage err = json.NewDecoder(res.Body).Decode(&pypipkg) if err != nil { - return nil, ErrFailedToParsePackage + return nil, ErrFailedToParsePackage.Wrap(err) } // Find if this package has any specified extras in the install targets diff --git a/usefulerror/error.go b/usefulerror/error.go new file mode 100644 index 0000000..88c797f --- /dev/null +++ b/usefulerror/error.go @@ -0,0 +1,158 @@ +package usefulerror + +import ( + "errors" + "strings" +) + +// UsefulError is an interface that can be implemented for custom error types +// that are actually useful for the user. Think of this as a way out of showing +// weird internal errors to the user, which actually don't help them +type UsefulError interface { + // Error returns a string that is useful for the user. + // Maintains compatibility with the standard error interface. + Error() string + + // HumanError returns a string that is more human-readable. + HumanError() string + + // Help returns a string that provides help or guidance specific to the + // business logic of the error. + Help() string + + // AdditionalHelp returns a string that provides additional help or guidance + // This is useful for providing specific tooling related instructions such + // as common line flags to use to fix the error. + AdditionalHelp() string + + // Code returns a string that can be used to identify the error types + // Meant for programmatic use, such as logging or categorization + Code() string +} + +type usefulErrorBuilder struct { + originalError error + humanError string + help string + additionalHelp string + code string + msg string +} + +var _ UsefulError = (*usefulErrorBuilder)(nil) + +func Useful() *usefulErrorBuilder { + return &usefulErrorBuilder{} +} + +func (b *usefulErrorBuilder) Wrap(originalError error) *usefulErrorBuilder { + b.originalError = originalError + return b +} + +// WithHumanError sets a string that is more human-readable. +func (b *usefulErrorBuilder) WithHumanError(humanError string) *usefulErrorBuilder { + b.humanError = humanError + return b +} + +// WithHelp sets a string that provides additional help or guidance. +func (b *usefulErrorBuilder) WithHelp(help string) *usefulErrorBuilder { + b.help = help + return b +} + +// WithCode sets a code that can be used to identify the error types. +func (b *usefulErrorBuilder) WithCode(code string) *usefulErrorBuilder { + b.code = code + return b +} + +// WithMsg sets a message that is useful for the user, but not necessarily human-readable. +func (b *usefulErrorBuilder) Msg(msg string) *usefulErrorBuilder { + b.msg = msg + return b +} + +// WithAdditionalHelp sets a string that provides additional help or guidance. +func (b *usefulErrorBuilder) WithAdditionalHelp(additionalHelp string) *usefulErrorBuilder { + b.additionalHelp = additionalHelp + return b +} + +// Error implements the standard error interface. It returns the original error's message if present; +// otherwise, it returns a constructed message based on the code and msg fields, or "unknown error" if none are set. +func (b *usefulErrorBuilder) Error() string { + if b.originalError != nil { + return b.originalError.Error() + } + + if b.msg == "" { + return "unknown error" + } + + msgParts := []string{} + if b.code != "" { + msgParts = append(msgParts, b.code) + } + + if b.msg != "" { + msgParts = append(msgParts, b.msg) + } + + return strings.Join(msgParts, ": ") +} + +// HumanError returns a string that is more human-readable. +func (b *usefulErrorBuilder) HumanError() string { + if b.humanError == "" { + return "An error occurred, but no human-readable message is available." + } + + return b.humanError +} + +// Help returns a string that provides additional help or guidance. +func (b *usefulErrorBuilder) Help() string { + if b.help == "" { + return "No additional help is available for this error." + } + + return b.help +} + +// Code returns a string that can be used to identify the error types. +func (b *usefulErrorBuilder) Code() string { + if b.code == "" { + return "unknown" + } + + return b.code +} + +// AdditionalHelp returns a string that provides additional help or guidance. +func (b *usefulErrorBuilder) AdditionalHelp() string { + if b.additionalHelp == "" { + return "No additional help is available for this error." + } + + return b.additionalHelp +} + +// AsUsefulError attempts to convert a given error into a UsefulError. +func AsUsefulError(err error) (UsefulError, bool) { + if err == nil { + return nil, false + } + + var usefulErr *usefulErrorBuilder + if errors.As(err, &usefulErr) { + return usefulErr, true + } + + if usefulErr, ok := err.(UsefulError); ok { + return usefulErr, true + } + + return nil, false +} diff --git a/usefulerror/error_test.go b/usefulerror/error_test.go new file mode 100644 index 0000000..8d7df73 --- /dev/null +++ b/usefulerror/error_test.go @@ -0,0 +1,271 @@ +package usefulerror + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUsefulErrorBuilder_Error(t *testing.T) { + tests := []struct { + name string + builder func() *usefulErrorBuilder + expected string + }{ + { + name: "with original error", + builder: func() *usefulErrorBuilder { + return Useful().Wrap(errors.New("original error")) + }, + expected: "original error", + }, + { + name: "with msg only", + builder: func() *usefulErrorBuilder { + return Useful().Msg("test message") + }, + expected: "test message", + }, + { + name: "with code and msg", + builder: func() *usefulErrorBuilder { + return Useful().WithCode("TEST001").Msg("test message") + }, + expected: "TEST001: test message", + }, + { + name: "with code only", + builder: func() *usefulErrorBuilder { + return Useful().WithCode("TEST001") + }, + expected: "unknown error", + }, + { + name: "empty builder", + builder: func() *usefulErrorBuilder { + return Useful() + }, + expected: "unknown error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.builder() + assert.Equal(t, tt.expected, err.Error()) + }) + } +} + +func TestUsefulErrorBuilder_HumanError(t *testing.T) { + tests := []struct { + name string + builder func() *usefulErrorBuilder + expected string + }{ + { + name: "with human error set", + builder: func() *usefulErrorBuilder { + return Useful().WithHumanError("Something went wrong") + }, + expected: "Something went wrong", + }, + { + name: "empty human error", + builder: func() *usefulErrorBuilder { + return Useful() + }, + expected: "An error occurred, but no human-readable message is available.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.builder() + assert.Equal(t, tt.expected, err.HumanError()) + }) + } +} + +func TestUsefulErrorBuilder_Help(t *testing.T) { + tests := []struct { + name string + builder func() *usefulErrorBuilder + expected string + }{ + { + name: "with help set", + builder: func() *usefulErrorBuilder { + return Useful().WithHelp("Try running with --verbose flag") + }, + expected: "Try running with --verbose flag", + }, + { + name: "empty help", + builder: func() *usefulErrorBuilder { + return Useful() + }, + expected: "No additional help is available for this error.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.builder() + assert.Equal(t, tt.expected, err.Help()) + }) + } +} + +func TestUsefulErrorBuilder_AdditionalHelp(t *testing.T) { + tests := []struct { + name string + builder func() *usefulErrorBuilder + expected string + }{ + { + name: "with additional help set", + builder: func() *usefulErrorBuilder { + return Useful().WithAdditionalHelp("Use --force to override") + }, + expected: "Use --force to override", + }, + { + name: "empty additional help", + builder: func() *usefulErrorBuilder { + return Useful() + }, + expected: "No additional help is available for this error.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.builder() + assert.Equal(t, tt.expected, err.AdditionalHelp()) + }) + } +} + +func TestUsefulErrorBuilder_Code(t *testing.T) { + tests := []struct { + name string + builder func() *usefulErrorBuilder + expected string + }{ + { + name: "with code set", + builder: func() *usefulErrorBuilder { + return Useful().WithCode("ERR001") + }, + expected: "ERR001", + }, + { + name: "empty code", + builder: func() *usefulErrorBuilder { + return Useful() + }, + expected: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.builder() + assert.Equal(t, tt.expected, err.Code()) + }) + } +} + +func TestUsefulErrorBuilder_ChainedMethods(t *testing.T) { + err := Useful(). + WithCode("TEST001"). + Msg("test message"). + WithHumanError("User friendly error"). + WithHelp("Try this fix"). + WithAdditionalHelp("Or try this") + + assert.Equal(t, "TEST001: test message", err.Error()) + assert.Equal(t, "User friendly error", err.HumanError()) + assert.Equal(t, "Try this fix", err.Help()) + assert.Equal(t, "Or try this", err.AdditionalHelp()) + assert.Equal(t, "TEST001", err.Code()) +} + +func TestAsUsefulError(t *testing.T) { + tests := []struct { + name string + input error + expectOk bool + expectError bool + }{ + { + name: "nil error", + input: nil, + expectOk: false, + expectError: false, + }, + { + name: "useful error builder", + input: Useful().Msg("test"), + expectOk: true, + expectError: false, + }, + { + name: "regular error", + input: errors.New("regular error"), + expectOk: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := AsUsefulError(tt.input) + assert.Equal(t, tt.expectOk, ok) + if tt.expectOk { + assert.NotNil(t, result) + assert.Implements(t, (*UsefulError)(nil), result) + } else { + assert.Nil(t, result) + } + }) + } +} + +func TestUsefulErrorBuilder_ImplementsUsefulError(t *testing.T) { + var _ UsefulError = (*usefulErrorBuilder)(nil) + + builder := Useful() + assert.Implements(t, (*UsefulError)(nil), builder) +} + +func TestUsefulErrorBuilder_Wrap(t *testing.T) { + originalErr := errors.New("original error") + wrappedErr := Useful().Wrap(originalErr) + + assert.Equal(t, "original error", wrappedErr.Error()) + assert.Equal(t, "An error occurred, but no human-readable message is available.", wrappedErr.HumanError()) +} + +func TestUsefulErrorBuilder_ComplexScenario(t *testing.T) { + originalErr := errors.New("file not found") + + err := Useful(). + Wrap(originalErr). + WithCode("FILE001"). + WithHumanError("The configuration file could not be found"). + WithHelp("Make sure the config file exists in the current directory"). + WithAdditionalHelp("Use --config flag to specify a different location") + + assert.Equal(t, "file not found", err.Error()) + assert.Equal(t, "The configuration file could not be found", err.HumanError()) + assert.Equal(t, "Make sure the config file exists in the current directory", err.Help()) + assert.Equal(t, "Use --config flag to specify a different location", err.AdditionalHelp()) + assert.Equal(t, "FILE001", err.Code()) + + usefulErr, ok := AsUsefulError(err) + assert.True(t, ok) + assert.Equal(t, err, usefulErr) +}