package ui import ( "context" "errors" "fmt" "io" "io/fs" "net" "os" "os/exec" "strings" "github.com/safedep/dry/usefulerror" "github.com/safedep/pmg/errcodes" "github.com/safedep/pmg/internal/shim" ) // errorMatcher defines how to detect and convert a specific error type type errorMatcher struct { match func(err error) bool convert func(err error) usefulerror.UsefulError } // errorMatchers is an ordered list of error matchers // Order matters - more specific matchers should come first var errorMatchers = []errorMatcher{ // Package manager not installed (PMG shim hit, real binary absent from PATH). { match: func(err error) bool { var notFound *shim.BinaryNotFoundError return errors.As(err, ¬Found) }, convert: func(err error) usefulerror.UsefulError { var notFound *shim.BinaryNotFoundError errors.As(err, ¬Found) return usefulerror.NewUsefulError(). WithCode(errcodes.PackageManagerNotFound). WithHumanError(fmt.Sprintf("%s is not installed", notFound.Name)). WithHelp(fmt.Sprintf("Install %s and ensure it is on your PATH, then retry.", notFound.Name)). Wrap(notFound) }, }, // File not found errors { match: func(err error) bool { return errors.Is(err, os.ErrNotExist) || errors.Is(err, fs.ErrNotExist) }, convert: func(err error) usefulerror.UsefulError { path := extractPathFromError(err) humanError := "File or directory not found" if path != "" { humanError = fmt.Sprintf("File or directory not found: %s", path) } return usefulerror.NewUsefulError(). WithCode(errcodes.NotFound). WithHumanError(humanError). WithHelp("Check if the path exists"). WithAdditionalHelp("Use 'ls' to check directory contents"). Wrap(err) }, }, // Permission denied errors { match: func(err error) bool { return errors.Is(err, os.ErrPermission) || errors.Is(err, fs.ErrPermission) }, convert: func(err error) usefulerror.UsefulError { path := extractPathFromError(err) humanError := "Permission denied" if path != "" { humanError = fmt.Sprintf("Permission denied: %s", path) } return usefulerror.NewUsefulError(). WithCode(errcodes.PermissionDenied). WithHumanError(humanError). WithHelp("Check permissions or use sudo"). WithAdditionalHelp("Use 'ls -la' to check permissions"). Wrap(err) }, }, // Process exit errors { match: func(err error) bool { var exitErr *exec.ExitError return errors.As(err, &exitErr) }, convert: func(err error) usefulerror.UsefulError { var exitErr *exec.ExitError errors.As(err, &exitErr) exitCode := exitErr.ExitCode() return usefulerror.NewUsefulError(). WithCode(errcodes.Lifecycle). WithHumanError(fmt.Sprintf("Command failed with exit code %d", exitCode)). WithHelp("Check command output above"). Wrap(err) }, }, // Timeout errors (check before network errors since network timeouts also match) { match: func(err error) bool { return errors.Is(err, context.DeadlineExceeded) }, convert: func(err error) usefulerror.UsefulError { return usefulerror.NewUsefulError(). WithCode(errcodes.Timeout). WithHumanError("Operation timed out"). WithHelp("Try again or check your network"). WithAdditionalHelp("Consider increasing timeout or retry later"). Wrap(err) }, }, // Canceled errors { match: func(err error) bool { return errors.Is(err, context.Canceled) }, convert: func(err error) usefulerror.UsefulError { return usefulerror.NewUsefulError(). WithCode(errcodes.Canceled). WithHumanError("Operation was canceled"). Wrap(err) }, }, // Network errors { match: func(err error) bool { var netErr net.Error if errors.As(err, &netErr) { return true } // Also check for common network-related error messages errStr := err.Error() return strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "no such host") || strings.Contains(errStr, "network is unreachable") }, convert: func(err error) usefulerror.UsefulError { var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return usefulerror.NewUsefulError(). WithCode(errcodes.Timeout). WithHumanError("Network request timed out"). WithHelp("Check your internet connection"). WithAdditionalHelp("Consider increasing timeout or retry later"). Wrap(err) } return usefulerror.NewUsefulError(). WithCode(errcodes.Network). WithHumanError("Network error occurred"). WithHelp("Check your internet connection"). WithAdditionalHelp("The package registry may be temporarily unavailable"). Wrap(err) }, }, // Unexpected EOF errors { match: func(err error) bool { return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) }, convert: func(err error) usefulerror.UsefulError { return usefulerror.NewUsefulError(). WithCode(errcodes.UnexpectedEOF). WithHumanError("Unexpected end of data"). WithHelp("Retry the download"). WithAdditionalHelp("This may indicate network instability"). Wrap(err) }, }, } func init() { usefulerror.RegisterErrorConverter("pmg-ui-converter", func(err error) (usefulerror.UsefulError, bool) { for _, matcher := range errorMatchers { if matcher.match(err) { return matcher.convert(err), true } } return nil, false }) } // convertToUsefulError attempts to convert a regular error to a UsefulError // by analyzing the error chain for known error types. // Returns the original error wrapped in a generic UsefulError if no specific match is found. func convertToUsefulError(err error) usefulerror.UsefulError { if err == nil { return nil } if ue, ok := usefulerror.AsUsefulError(err); ok { return ue } return usefulerror.NewUsefulError(). WithCode(errcodes.Unknown). WithHumanError(extractRootCause(err)). WithHelp("An unexpected error occurred."). Wrap(err) } // extractRootCause traverses the error chain and returns the innermost error message. // This provides a cleaner, more human-friendly message instead of the full error chain. func extractRootCause(err error) string { for { unwrapped := errors.Unwrap(err) if unwrapped == nil { return err.Error() } err = unwrapped } } // extractPathFromError attempts to extract a file path from path-related errors func extractPathFromError(err error) string { var pathErr *fs.PathError if errors.As(err, &pathErr) { return pathErr.Path } var linkErr *os.LinkError if errors.As(err, &linkErr) { return linkErr.Old } return "" }