1use 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 #[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 #[error(transparent)]
35 IoBare(#[from] io::Error),
36
37 #[error("credentials error: {0}")]
41 Credentials(String),
42
43 #[error("network transport error: {0}")]
47 Transport(String),
48
49 #[error("HTTP {status}: {body}")]
53 Http { status: u16, body: String },
54
55 #[error("schema mismatch: {0}")]
59 Schema(String),
60
61 #[error("json error: {0}")]
63 Json(#[from] serde_json::Error),
64
65 #[error("toml error: {0}")]
67 Toml(#[from] toml::de::Error),
68
69 #[error("{0}")]
71 Other(String),
72
73 #[error("{source}")]
78 WithPlan {
79 plan: String,
80 #[source]
81 source: Box<AppError>,
82 },
83}
84
85impl AppError {
86 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 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 pub fn plan(&self) -> Option<&str> {
110 match self {
111 AppError::WithPlan { plan, .. } => Some(plan.as_str()),
112 _ => None,
113 }
114 }
115
116 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 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
139impl 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 #[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 #[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}