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    ///
17    /// **The path is sanitized here rather than at each print site.** A path in
18    /// this variant is not always a literal this program chose — it can carry a
19    /// component from an account label, a vendor response, or an archive
20    /// member — and `Display for Path` escapes nothing. Doing it at the
21    /// `Display` covers every site that formats an `AppError`, including the
22    /// ones written after this comment.
23    #[error(
24        "io error at {}: {source}",
25        crate::display::sanitize_untrusted_path(path)
26    )]
27    Io {
28        path: PathBuf,
29        #[source]
30        source: io::Error,
31    },
32
33    /// Generic I/O without a meaningful path (e.g. stdout writes).
34    #[error(transparent)]
35    IoBare(#[from] io::Error),
36
37    /// A vendor's credentials file is missing, unreadable, or malformed.
38    /// Distinct from `Io` because the widget treats it as "user must re-auth"
39    /// rather than a transient failure.
40    #[error("credentials error: {0}")]
41    Credentials(String),
42
43    /// HTTP request failed at the transport layer (DNS, TLS, timeout, connect).
44    /// Maps to claudebar's "HTTP 000" — show `Loading…`, don't write
45    /// `.last_error`, retry next tick.
46    #[error("network transport error: {0}")]
47    Transport(String),
48
49    /// HTTP request reached the server but returned a non-2xx status.
50    /// Carries the code + best-effort body so the widget can populate
51    /// `.last_error` for the tooltip.
52    #[error("HTTP {status}: {body}")]
53    Http { status: u16, body: String },
54
55    /// API returned 2xx but the body did not match our expected schema.
56    /// Treated like an HTTP error for tooltip purposes, but logged separately
57    /// because it signals undocumented-endpoint drift.
58    #[error("schema mismatch: {0}")]
59    Schema(String),
60
61    /// JSON serialization/deserialization failure (config files, response bodies).
62    #[error("json error: {0}")]
63    Json(#[from] serde_json::Error),
64
65    /// TOML config parse failure.
66    #[error("toml error: {0}")]
67    Toml(#[from] toml::de::Error),
68
69    /// Catch-all for unexpected conditions (cache lock contention, etc.).
70    #[error("{0}")]
71    Other(String),
72
73    /// A fetch failed after the vendor's plan label was already known (Claude
74    /// OAuth `subscriptionType`, etc.). Display and [`Self::user_message`] are
75    /// the inner error so UIs can still name the real cause; the plan is for
76    /// surfaces that can show it without a quota snapshot.
77    #[error("{source}")]
78    WithPlan {
79        plan: String,
80        #[source]
81        source: Box<AppError>,
82    },
83}
84
85impl AppError {
86    /// Convenience for non-pathful I/O.
87    pub fn io_at(path: impl Into<PathBuf>, source: io::Error) -> Self {
88        AppError::Io {
89            path: path.into(),
90            source,
91        }
92    }
93
94    /// Attach a plan label already known from credentials. Empty labels are
95    /// dropped so a card does not render a blank plan row.
96    pub fn with_plan(self, plan: impl Into<String>) -> Self {
97        let plan = plan.into();
98        if plan.is_empty() {
99            self
100        } else {
101            AppError::WithPlan {
102                plan,
103                source: Box::new(self),
104            }
105        }
106    }
107
108    /// Plan label carried by [`Self::WithPlan`], if any.
109    pub fn plan(&self) -> Option<&str> {
110        match self {
111            AppError::WithPlan { plan, .. } => Some(plan.as_str()),
112            _ => None,
113        }
114    }
115
116    /// True for transient network errors that the widget should hide behind a
117    /// "Loading…" rather than a "⚠".
118    pub fn is_transient(&self) -> bool {
119        match self {
120            AppError::Transport(_) => true,
121            AppError::WithPlan { source, .. } => source.is_transient(),
122            _ => false,
123        }
124    }
125
126    /// Render an error for a local UI or report without exposing an upstream
127    /// authentication response body. Other errors retain their diagnostic text.
128    pub fn user_message(&self) -> String {
129        match self {
130            AppError::WithPlan { source, .. } => source.user_message(),
131            AppError::Http { status, .. } if matches!(status, 401 | 403) => {
132                format!("HTTP {status}: {AUTH_FAILURE_MESSAGE}")
133            }
134            other => other.to_string(),
135        }
136    }
137}
138
139/// Map a reqwest error into the right variant. Connection-class failures
140/// become `Transport` (transient); the rest become generic `Http`/`Other`.
141/// `Transport` carries the source chain — reqwest's own Display swallows it,
142/// and without it a TLS failure behind a corporate proxy is indistinguishable
143/// from an outage (the certificate cause, e.g. `UnknownIssuer`, is in the
144/// chain, not the message). The chain carries TLS internals, never
145/// credentials.
146impl From<reqwest::Error> for AppError {
147    fn from(err: reqwest::Error) -> Self {
148        if err.is_timeout() || err.is_connect() || err.is_request() {
149            let mut msg = err.to_string();
150            let mut source = std::error::Error::source(&err);
151            while let Some(cause) = source {
152                msg.push_str(&format!(": {cause}"));
153                source = cause.source();
154            }
155            return AppError::Transport(msg);
156        }
157        if let Some(status) = err.status() {
158            return AppError::Http {
159                status: status.as_u16(),
160                body: err.to_string(),
161            };
162        }
163        AppError::Other(err.to_string())
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    /// Asserted at the `Display`, not at a print site, because that is the
172    /// whole point: a path in this variant can come from an account label, a
173    /// vendor response, or an archive member, and there are a dozen places
174    /// that format one.
175    #[test]
176    fn an_io_path_carrying_a_terminal_escape_renders_without_it() {
177        let rendered = AppError::Io {
178            path: PathBuf::from("/tmp/\x1b[2Kspoofed\nRESTORED: 0"),
179            source: io::Error::other("disk full"),
180        }
181        .to_string();
182
183        assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
184        assert!(
185            !rendered.contains('\n'),
186            "an embedded newline forges a line: {rendered:?}"
187        );
188        assert!(rendered.contains("disk full"), "{rendered}");
189    }
190
191    /// A closed loopback port produces a connect-class reqwest error whose
192    /// Display alone is "error sending request"; the Transport message must
193    /// carry the chain so the OS cause ("Connection refused") survives.
194    #[tokio::test]
195    async fn a_transport_error_carries_its_source_chain() {
196        let client = reqwest::Client::new();
197        let err = client
198            .get("http://127.0.0.1:1/")
199            .send()
200            .await
201            .expect_err("port 1 is closed");
202        let rendered = AppError::from(err).to_string();
203        assert!(
204            rendered.contains("Connection refused") || rendered.contains("refused"),
205            "the OS cause must survive into the message: {rendered:?}"
206        );
207    }
208
209    #[test]
210    fn user_message_does_not_expose_authentication_response_bodies() {
211        for status in [401, 403] {
212            let error = AppError::Http {
213                status,
214                body: "PANCEA user@example.test <credential>&token".into(),
215            };
216            let rendered = error.user_message();
217            assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
218            assert!(!rendered.contains("PANCEA"));
219            assert!(!rendered.contains("user@example.test"));
220            assert!(!rendered.contains("&token"));
221        }
222    }
223
224    #[test]
225    fn user_message_preserves_non_authentication_diagnostics() {
226        let error = AppError::Http {
227            status: 500,
228            body: "provider unavailable".into(),
229        };
230        assert!(error.user_message().contains("provider unavailable"));
231    }
232
233    #[test]
234    fn with_plan_keeps_the_inner_message_and_the_label() {
235        let error = AppError::Http {
236            status: 401,
237            body: "invalid token".into(),
238        }
239        .with_plan("Claude Max 5x");
240        assert_eq!(error.plan(), Some("Claude Max 5x"));
241        let rendered = error.user_message();
242        assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
243        assert!(!rendered.contains("invalid token"));
244        assert!(!error.is_transient());
245        assert!(
246            AppError::Transport("timeout".into())
247                .with_plan("Pro")
248                .is_transient()
249        );
250    }
251}