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
74impl AppError {
75 /// Convenience for non-pathful I/O.
76 pub fn io_at(path: impl Into<PathBuf>, source: io::Error) -> Self {
77 AppError::Io {
78 path: path.into(),
79 source,
80 }
81 }
82
83 /// True for transient network errors that the widget should hide behind a
84 /// "Loading…" rather than a "⚠".
85 pub fn is_transient(&self) -> bool {
86 matches!(self, AppError::Transport(_))
87 }
88
89 /// Render an error for a local UI or report without exposing an upstream
90 /// authentication response body. Other errors retain their diagnostic text.
91 pub fn user_message(&self) -> String {
92 match self {
93 AppError::Http { status, .. } if matches!(status, 401 | 403) => {
94 format!("HTTP {status}: {AUTH_FAILURE_MESSAGE}")
95 }
96 other => other.to_string(),
97 }
98 }
99}
100
101/// Map a reqwest error into the right variant. Connection-class failures
102/// become `Transport` (transient); the rest become generic `Http`/`Other`.
103impl From<reqwest::Error> for AppError {
104 fn from(err: reqwest::Error) -> Self {
105 if err.is_timeout() || err.is_connect() || err.is_request() {
106 return AppError::Transport(err.to_string());
107 }
108 if let Some(status) = err.status() {
109 return AppError::Http {
110 status: status.as_u16(),
111 body: err.to_string(),
112 };
113 }
114 AppError::Other(err.to_string())
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 /// Asserted at the `Display`, not at a print site, because that is the
123 /// whole point: a path in this variant can come from an account label, a
124 /// vendor response, or an archive member, and there are a dozen places
125 /// that format one.
126 #[test]
127 fn an_io_path_carrying_a_terminal_escape_renders_without_it() {
128 let rendered = AppError::Io {
129 path: PathBuf::from("/tmp/\x1b[2Kspoofed\nRESTORED: 0"),
130 source: io::Error::other("disk full"),
131 }
132 .to_string();
133
134 assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
135 assert!(
136 !rendered.contains('\n'),
137 "an embedded newline forges a line: {rendered:?}"
138 );
139 assert!(rendered.contains("disk full"), "{rendered}");
140 }
141
142 #[test]
143 fn user_message_does_not_expose_authentication_response_bodies() {
144 for status in [401, 403] {
145 let error = AppError::Http {
146 status,
147 body: "PANCEA user@example.test <credential>&token".into(),
148 };
149 let rendered = error.user_message();
150 assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
151 assert!(!rendered.contains("PANCEA"));
152 assert!(!rendered.contains("user@example.test"));
153 assert!(!rendered.contains("&token"));
154 }
155 }
156
157 #[test]
158 fn user_message_preserves_non_authentication_diagnostics() {
159 let error = AppError::Http {
160 status: 500,
161 body: "provider unavailable".into(),
162 };
163 assert!(error.user_message().contains("provider unavailable"));
164 }
165}