Skip to main content

ai_usagebar/
error.rs

1//! Shared error type. Vendors and renderers convert their failures into
2//! `AppError` so the widget shell can decide whether to retry, fall back to
3//! cache, show ⚠, or show "Loading…".
4
5use std::io;
6use std::path::PathBuf;
7
8pub type Result<T> = std::result::Result<T, AppError>;
9
10pub const AUTH_FAILURE_MESSAGE: &str =
11    "authentication rejected — credentials may be missing, expired, or invalid";
12
13#[derive(Debug, thiserror::Error)]
14pub enum AppError {
15    /// Local I/O failed (cache write, credentials read, theme file, etc.).
16    #[error("io error at {path}: {source}")]
17    Io {
18        path: PathBuf,
19        #[source]
20        source: io::Error,
21    },
22
23    /// Generic I/O without a meaningful path (e.g. stdout writes).
24    #[error(transparent)]
25    IoBare(#[from] io::Error),
26
27    /// A vendor's credentials file is missing, unreadable, or malformed.
28    /// Distinct from `Io` because the widget treats it as "user must re-auth"
29    /// rather than a transient failure.
30    #[error("credentials error: {0}")]
31    Credentials(String),
32
33    /// HTTP request failed at the transport layer (DNS, TLS, timeout, connect).
34    /// Maps to claudebar's "HTTP 000" — show `Loading…`, don't write
35    /// `.last_error`, retry next tick.
36    #[error("network transport error: {0}")]
37    Transport(String),
38
39    /// HTTP request reached the server but returned a non-2xx status.
40    /// Carries the code + best-effort body so the widget can populate
41    /// `.last_error` for the tooltip.
42    #[error("HTTP {status}: {body}")]
43    Http { status: u16, body: String },
44
45    /// API returned 2xx but the body did not match our expected schema.
46    /// Treated like an HTTP error for tooltip purposes, but logged separately
47    /// because it signals undocumented-endpoint drift.
48    #[error("schema mismatch: {0}")]
49    Schema(String),
50
51    /// JSON serialization/deserialization failure (config files, response bodies).
52    #[error("json error: {0}")]
53    Json(#[from] serde_json::Error),
54
55    /// TOML config parse failure.
56    #[error("toml error: {0}")]
57    Toml(#[from] toml::de::Error),
58
59    /// Catch-all for unexpected conditions (cache lock contention, etc.).
60    #[error("{0}")]
61    Other(String),
62}
63
64impl AppError {
65    /// Convenience for non-pathful I/O.
66    pub fn io_at(path: impl Into<PathBuf>, source: io::Error) -> Self {
67        AppError::Io {
68            path: path.into(),
69            source,
70        }
71    }
72
73    /// True for transient network errors that the widget should hide behind a
74    /// "Loading…" rather than a "⚠".
75    pub fn is_transient(&self) -> bool {
76        matches!(self, AppError::Transport(_))
77    }
78
79    /// Render an error for a local UI or report without exposing an upstream
80    /// authentication response body. Other errors retain their diagnostic text.
81    pub fn user_message(&self) -> String {
82        match self {
83            AppError::Http { status, .. } if matches!(status, 401 | 403) => {
84                format!("HTTP {status}: {AUTH_FAILURE_MESSAGE}")
85            }
86            other => other.to_string(),
87        }
88    }
89}
90
91/// Map a reqwest error into the right variant. Connection-class failures
92/// become `Transport` (transient); the rest become generic `Http`/`Other`.
93impl From<reqwest::Error> for AppError {
94    fn from(err: reqwest::Error) -> Self {
95        if err.is_timeout() || err.is_connect() || err.is_request() {
96            return AppError::Transport(err.to_string());
97        }
98        if let Some(status) = err.status() {
99            return AppError::Http {
100                status: status.as_u16(),
101                body: err.to_string(),
102            };
103        }
104        AppError::Other(err.to_string())
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn user_message_does_not_expose_authentication_response_bodies() {
114        for status in [401, 403] {
115            let error = AppError::Http {
116                status,
117                body: "PANCEA user@example.test <credential>&token".into(),
118            };
119            let rendered = error.user_message();
120            assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
121            assert!(!rendered.contains("PANCEA"));
122            assert!(!rendered.contains("user@example.test"));
123            assert!(!rendered.contains("&token"));
124        }
125    }
126
127    #[test]
128    fn user_message_preserves_non_authentication_diagnostics() {
129        let error = AppError::Http {
130            status: 500,
131            body: "provider unavailable".into(),
132        };
133        assert!(error.user_message().contains("provider unavailable"));
134    }
135}