package cmd import ( "encoding/json" "errors" "strings" "forgejo.zerova.net/public/fj/internal/api" ) // Error codes for structured error output. const ( ErrAuthRequired = "auth_required" ErrNotFound = "not_found" ErrAPIError = "api_error" ErrInvalidInput = "invalid_input" ErrGitDetectionFailed = "git_detection_failed" ErrNetworkError = "network_error" ) // CLIError is a structured error type for machine-readable output. type CLIError struct { Code string `json:"code"` Message string `json:"message"` Detail string `json:"detail,omitempty"` Status int `json:"status,omitempty"` // Hint is a separate field so JSON consumers get clean structure and // the human renderer can append "Hint: ..." without polluting Message. Hint string `json:"hint,omitempty"` } func (e *CLIError) Error() string { if e.Hint != "" { return e.Message + "\nHint: " + e.Hint } return e.Message } // NewCLIError creates a new CLIError with the given code and message. func NewCLIError(code, message string) *CLIError { return &CLIError{Code: code, Message: message} } // NewAPIError creates a CLIError from an HTTP status and message. func NewAPIError(status int, message string) *CLIError { return &CLIError{Code: ErrAPIError, Message: message, Status: status} } // ContextualError wraps common errors with helpful hints. // // Auth/404 hints come exclusively from a typed *api.APIError now — we used // to substring-match "401"/"403" against the rendered error string, which // would trigger an "auth login" hint for any error mentioning issue #403. // If the API client doesn't surface APIError, no hint is added; that's a // signal to fix the client wrapper, not to layer regex on top. func ContextualError(err error) error { if err == nil { return nil } // If the error chain already holds a CLIError, leave it — it owns its // Code/Hint already. var cErr *CLIError if errors.As(err, &cErr) { return err } var apiErr *api.APIError if errors.As(err, &apiErr) { c := &CLIError{ Code: ErrAPIError, Message: err.Error(), Status: apiErr.StatusCode, Detail: apiErr.Body, } switch apiErr.StatusCode { case 401, 403: c.Code = ErrAuthRequired c.Hint = "Try authenticating with: fj auth login" case 404: c.Code = ErrNotFound c.Hint = "Resource not found. Check the repository and number are correct." } return c } // Plain network errors come back as fmt.Errorf strings from net/http. msg := err.Error() switch { case strings.Contains(msg, "no such host"), strings.Contains(msg, "connection refused"), strings.Contains(msg, "i/o timeout"): return &CLIError{ Code: ErrNetworkError, Message: msg, Hint: "Check your internet connection and that the host is correct.", } } return err } // WriteJSONError writes a structured JSON error to stderr. // It is exported for use from main.go. func WriteJSONError(err error) { cliErr := &CLIError{ Code: ErrAPIError, Message: err.Error(), } // Try to extract structured info from the error chain. Prefer CLIError // (which carries Hint cleanly) over APIError so a wrapped CLIError // keeps its structured fields. var apiErr *api.APIError var cErr *CLIError switch { case errors.As(err, &cErr): cliErr = cErr case errors.As(err, &apiErr): cliErr.Status = apiErr.StatusCode cliErr.Detail = apiErr.Body switch { case apiErr.StatusCode == 401 || apiErr.StatusCode == 403: cliErr.Code = ErrAuthRequired case apiErr.StatusCode == 404: cliErr.Code = ErrNotFound } } enc := json.NewEncoder(ios.ErrOut) enc.SetIndent("", " ") _ = enc.Encode(cliErr) } // Compile-time check that CLIError satisfies the standard error interface. var _ error = (*CLIError)(nil)