chore: Better user friendly error messages (#64)

This commit is contained in:
Abhisek Datta
2025-08-21 18:26:05 +05:30
committed by GitHub
parent cd306884e3
commit 2493fb4dc6
16 changed files with 555 additions and 56 deletions
+4 -5
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/flows"
@@ -21,7 +20,7 @@ func NewBunCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
err := executeBunFlow(cmd.Context(), args) err := executeBunFlow(cmd.Context(), args)
if err != nil { if err != nil {
log.Errorf("Failed to execute bun flow: %s", err) ui.ErrorExit(err)
} }
return nil return nil
@@ -33,12 +32,12 @@ func executeBunFlow(ctx context.Context, args []string) error {
analytics.TrackCommandBun() analytics.TrackCommandBun()
packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultBunPackageManagerConfig()) packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultBunPackageManagerConfig())
if err != nil { 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) config, err := config.FromContext(ctx)
if err != nil { 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) parsedCommand, err := packageManager.ParseCommand(args)
@@ -53,7 +52,7 @@ func executeBunFlow(ctx context.Context, args []string) error {
packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig)
if err != nil { 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) return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
+4 -5
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/flows"
@@ -21,7 +20,7 @@ func NewNpmCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
err := executeNpmFlow(cmd.Context(), args) err := executeNpmFlow(cmd.Context(), args)
if err != nil { if err != nil {
log.Errorf("Failed to execute npm flow: %s", err) ui.ErrorExit(err)
} }
return nil return nil
@@ -33,12 +32,12 @@ func executeNpmFlow(ctx context.Context, args []string) error {
analytics.TrackCommandNpm() analytics.TrackCommandNpm()
packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultNpmPackageManagerConfig()) packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultNpmPackageManagerConfig())
if err != nil { 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) config, err := config.FromContext(ctx)
if err != nil { 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) parsedCommand, err := packageManager.ParseCommand(args)
@@ -53,7 +52,7 @@ func executeNpmFlow(ctx context.Context, args []string) error {
packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig)
if err != nil { 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) return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
+6 -4
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/flows"
@@ -21,7 +20,7 @@ func NewPnpmCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
err := executePnpmFlow(cmd.Context(), args) err := executePnpmFlow(cmd.Context(), args)
if err != nil { if err != nil {
log.Errorf("Failed to execute pnpm flow: %s", err) ui.ErrorExit(err)
} }
return nil return nil
@@ -33,12 +32,12 @@ func executePnpmFlow(ctx context.Context, args []string) error {
analytics.TrackCommandPnpm() analytics.TrackCommandPnpm()
packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultPnpmPackageManagerConfig()) packageManager, err := packagemanager.NewNpmPackageManager(packagemanager.DefaultPnpmPackageManagerConfig())
if err != nil { 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) config, err := config.FromContext(ctx)
if err != nil { 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) parsedCommand, err := packageManager.ParseCommand(args)
@@ -52,6 +51,9 @@ func executePnpmFlow(ctx context.Context, args []string) error {
packageResolverConfig.IncludeDevDependencies = config.IncludeDevDependencies packageResolverConfig.IncludeDevDependencies = config.IncludeDevDependencies
packageResolver, err := packagemanager.NewNpmDependencyResolver(packageResolverConfig) 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) return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
} }
+4 -5
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/flows"
@@ -21,7 +20,7 @@ func NewPipCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
err := executePipFlow(cmd.Context(), args) err := executePipFlow(cmd.Context(), args)
if err != nil { if err != nil {
log.Errorf("Failed to execute pip flow: %s", err) ui.ErrorExit(err)
} }
return nil return nil
@@ -33,12 +32,12 @@ func executePipFlow(ctx context.Context, args []string) error {
analytics.TrackCommandPip() analytics.TrackCommandPip()
packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultPipPackageManagerConfig()) packageManager, err := packagemanager.NewPypiPackageManager(packagemanager.DefaultPipPackageManagerConfig())
if err != nil { 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) config, err := config.FromContext(ctx)
if err != nil { 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) parsedCommand, err := packageManager.ParseCommand(args)
@@ -55,7 +54,7 @@ func executePipFlow(ctx context.Context, args []string) error {
packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig)
if err != nil { 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) return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
+3 -5
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/safedep/dry/log"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/analytics" "github.com/safedep/pmg/internal/analytics"
"github.com/safedep/pmg/internal/flows" "github.com/safedep/pmg/internal/flows"
@@ -21,7 +20,7 @@ func NewUvCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
err := executeUvFlow(cmd.Context(), args) err := executeUvFlow(cmd.Context(), args)
if err != nil { if err != nil {
log.Errorf("Failed to execute uv flow: %s", err) ui.ErrorExit(err)
} }
return nil return nil
@@ -38,7 +37,7 @@ func executeUvFlow(ctx context.Context, args []string) error {
config, err := config.FromContext(ctx) config, err := config.FromContext(ctx)
if err != nil { 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) 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) return fmt.Errorf("failed to parse command: %w", err)
} }
// Parse the args right here
packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig() packageResolverConfig := packagemanager.NewDefaultPypiDependencyResolverConfig()
packageResolverConfig.IncludeTransitiveDependencies = config.Transitive packageResolverConfig.IncludeTransitiveDependencies = config.Transitive
packageResolverConfig.TransitiveDepth = config.TransitiveDepth packageResolverConfig.TransitiveDepth = config.TransitiveDepth
@@ -55,7 +53,7 @@ func executeUvFlow(ctx context.Context, args []string) error {
packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig) packageResolver, err := packagemanager.NewPypiDependencyResolver(packageResolverConfig)
if err != nil { 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) return flows.Common(packageManager, packageResolver, config).Run(ctx, args, parsedCommand)
+4 -2
View File
@@ -32,7 +32,8 @@ func NewInstallCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
aliasManager := alias.New(*config, rcFileManager)
aliasManager := alias.New(config, rcFileManager)
return aliasManager.Install() return aliasManager.Install()
}, },
} }
@@ -48,7 +49,8 @@ func NewRemoveCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
aliasManager := alias.New(*config, rcFileManager)
aliasManager := alias.New(config, rcFileManager)
return aliasManager.Remove() return aliasManager.Remove()
}, },
} }
+13 -9
View File
@@ -32,25 +32,28 @@ type RcFileManager interface {
} }
// DefaultRcFileManager implements RcFileManager for managing the RC file. // DefaultRcFileManager implements RcFileManager for managing the RC file.
type DefaultRcFileManager struct { type defaultRcFileManager struct {
HomeDir string HomeDir string
RcFileName string RcFileName string
} }
var _ RcFileManager = &defaultRcFileManager{}
// NewDefaultRcFileManager creates a new DefaultRcFileManager. // NewDefaultRcFileManager creates a new DefaultRcFileManager.
func NewDefaultRcFileManager(rcFileName string) (*DefaultRcFileManager, error) { func NewDefaultRcFileManager(rcFileName string) (*defaultRcFileManager, error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &DefaultRcFileManager{
return &defaultRcFileManager{
HomeDir: homeDir, HomeDir: homeDir,
RcFileName: rcFileName, RcFileName: rcFileName,
}, nil }, nil
} }
// Create creates the RC file with the given aliases. // 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() rcPath := m.GetRcPath()
f, err := os.Create(rcPath) f, err := os.Create(rcPath)
if err != nil { if err != nil {
@@ -67,7 +70,7 @@ func (m *DefaultRcFileManager) Create(aliases []string) (string, error) {
} }
// Remove deletes the RC file. // Remove deletes the RC file.
func (m *DefaultRcFileManager) Remove() error { func (m *defaultRcFileManager) Remove() error {
rcPath := m.GetRcPath() rcPath := m.GetRcPath()
if err := os.Remove(rcPath); err != nil && !os.IsNotExist(err) { if err := os.Remove(rcPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("could not delete %s: %w", rcPath, 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. // 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) return filepath.Join(m.HomeDir, m.RcFileName)
} }
// DefaultConfig returns the default configuration for alias management. // DefaultConfig returns the default configuration for alias management.
func DefaultConfig() *AliasConfig { func DefaultConfig() AliasConfig {
var shells []Shell var shells []Shell
fishShell, _ := NewFishShell() fishShell, _ := NewFishShell()
@@ -90,7 +93,7 @@ func DefaultConfig() *AliasConfig {
shells = append(shells, fishShell, zshShell, bashShell) shells = append(shells, fishShell, zshShell, bashShell)
return &AliasConfig{ return AliasConfig{
RcFileName: ".pmg.rc", RcFileName: ".pmg.rc",
PackageManagers: []string{"npm", "pip", "pnpm", "bun", "uv"}, PackageManagers: []string{"npm", "pip", "pnpm", "bun", "uv"},
Shells: shells, Shells: shells,
@@ -243,8 +246,9 @@ func (a *AliasManager) addSourceLine(configPath, sourceLine string) error {
if err != nil { if err != nil {
return err return err
} }
defer f.Close() defer f.Close()
_, err = f.WriteString(fmt.Sprintf("\n%s", sourceLine)) _, err = fmt.Fprintf(f, "\n%s", sourceLine)
return err return err
} }
+5 -4
View File
@@ -2,6 +2,7 @@ package flows
import ( import (
"context" "context"
"fmt"
"github.com/safedep/pmg/analyzer" "github.com/safedep/pmg/analyzer"
"github.com/safedep/pmg/config" "github.com/safedep/pmg/config"
@@ -33,14 +34,14 @@ func (f *commonFlow) Run(ctx context.Context, args []string, parsedCmd *packagem
if f.config.Paranoid { if f.config.Paranoid {
malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig()) malysisActiveScanAnalyzer, err := analyzer.NewMalysisActiveScanAnalyzer(analyzer.DefaultMalysisActiveScanAnalyzerConfig())
if err != nil { 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) analyzers = append(analyzers, malysisActiveScanAnalyzer)
} else { } else {
malysisQueryAnalyzer, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{}) malysisQueryAnalyzer, err := analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
if err != nil { 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) 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) proxy, err := guard.NewPackageManagerGuard(guardConfig, f.pm, f.packageResolver, analyzers, interaction)
if err != nil { 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) err = proxy.Run(ctx, args, parsedCmd)
if err != nil { 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 return err
+33
View File
@@ -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)
}
+40 -7
View File
@@ -1,15 +1,48 @@
package packagemanager package packagemanager
import ( import (
"errors" "github.com/safedep/pmg/usefulerror"
) )
var ( var (
ErrPackageNotFound = errors.New("package not found") ErrPackageNotFound = usefulerror.Useful().
ErrFailedToFetchPackage = errors.New("failed to fetch package") WithCode("package_not_found").
ErrFailedToParsePackage = errors.New("failed to parse package") WithHumanError("The requested package could not be found.").
ErrNoPackagesFound = errors.New("no packages found") WithHelp("Please check the package name and try again.")
ErrAuthorNotFound = errors.New("author not found")
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")
) )
+1 -1
View File
@@ -107,7 +107,7 @@ func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error
for _, pkg := range packages { for _, pkg := range packages {
packageName, version, err := npmParsePackageInfo(pkg) packageName, version, err := npmParsePackageInfo(pkg)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse package info: %w", err) return nil, ErrFailedToParsePackage.Wrap(err)
} }
// Clean version if specified // Clean version if specified
+1 -1
View File
@@ -59,7 +59,7 @@ func (r *npmDependencyResolver) ResolveLatestVersion(ctx context.Context,
pkgInfo, err := pd.GetPackage(pkg.Name) pkgInfo, err := pd.GetPackage(pkg.Name)
if err != nil { 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) log.Debugf("Resolved npm/%s to latest version %s", pkg.Name, pkgInfo.LatestVersion)
+4 -4
View File
@@ -141,7 +141,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
for _, pkg := range packages { for _, pkg := range packages {
packageName, version, extras, err := pypiParsePackageInfo(pkg) packageName, version, extras, err := pypiParsePackageInfo(pkg)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse package info: %w", err) return nil, ErrFailedToParsePackage.Wrap(err)
} }
if version != "" { if version != "" {
@@ -150,7 +150,7 @@ func (p *pipCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
} else { } else {
version, err = pypiGetMatchingVersion(packageName, version) version, err = pypiGetMatchingVersion(packageName, version)
if err != nil { 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 { for _, pkg := range packages {
packageName, version, extras, err := pypiParsePackageInfo(pkg) packageName, version, extras, err := pypiParsePackageInfo(pkg)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse package info: %w", err) return nil, ErrFailedToParsePackage.Wrap(err)
} }
if version != "" { if version != "" {
@@ -266,7 +266,7 @@ func (u *uvCommandParser) ParseCommand(args []string) (*ParsedCommand, error) {
} else { } else {
version, err = pypiGetMatchingVersion(packageName, version) version, err = pypiGetMatchingVersion(packageName, version)
if err != nil { if err != nil {
return nil, fmt.Errorf("error resolving version for %s: %s", packageName, err.Error()) return nil, ErrFailedToResolveVersion.Wrap(err)
} }
} }
} }
+4 -4
View File
@@ -158,22 +158,22 @@ func getPypiPackageDependencies(packageName, version string, packageTargets []*P
res, err := http.Get(url) res, err := http.Get(url)
if err != nil { if err != nil {
return nil, ErrFailedToFetchPackage return nil, ErrFailedToFetchPackage.Wrap(err)
} }
if res.StatusCode == 404 { if res.StatusCode == 404 {
return nil, ErrPackageNotFound return nil, ErrPackageNotFound.Wrap(err)
} }
if res.StatusCode != 200 { if res.StatusCode != 200 {
return nil, ErrFailedToFetchPackage return nil, ErrFailedToFetchPackage.Wrap(err)
} }
defer res.Body.Close() defer res.Body.Close()
var pypipkg pypiPackage var pypipkg pypiPackage
err = json.NewDecoder(res.Body).Decode(&pypipkg) err = json.NewDecoder(res.Body).Decode(&pypipkg)
if err != nil { if err != nil {
return nil, ErrFailedToParsePackage return nil, ErrFailedToParsePackage.Wrap(err)
} }
// Find if this package has any specified extras in the install targets // Find if this package has any specified extras in the install targets
+158
View File
@@ -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
}
+271
View File
@@ -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)
}