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 {
142 fn from(err: reqwest::Error) -> Self {
143 if err.is_timeout() || err.is_connect() || err.is_request() {
144 return AppError::Transport(err.to_string());
145 }
146 if let Some(status) = err.status() {
147 return AppError::Http {
148 status: status.as_u16(),
149 body: err.to_string(),
150 };
151 }
152 AppError::Other(err.to_string())
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
165 fn an_io_path_carrying_a_terminal_escape_renders_without_it() {
166 let rendered = AppError::Io {
167 path: PathBuf::from("/tmp/\x1b[2Kspoofed\nRESTORED: 0"),
168 source: io::Error::other("disk full"),
169 }
170 .to_string();
171
172 assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
173 assert!(
174 !rendered.contains('\n'),
175 "an embedded newline forges a line: {rendered:?}"
176 );
177 assert!(rendered.contains("disk full"), "{rendered}");
178 }
179
180 #[test]
181 fn user_message_does_not_expose_authentication_response_bodies() {
182 for status in [401, 403] {
183 let error = AppError::Http {
184 status,
185 body: "PANCEA user@example.test <credential>&token".into(),
186 };
187 let rendered = error.user_message();
188 assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
189 assert!(!rendered.contains("PANCEA"));
190 assert!(!rendered.contains("user@example.test"));
191 assert!(!rendered.contains("&token"));
192 }
193 }
194
195 #[test]
196 fn user_message_preserves_non_authentication_diagnostics() {
197 let error = AppError::Http {
198 status: 500,
199 body: "provider unavailable".into(),
200 };
201 assert!(error.user_message().contains("provider unavailable"));
202 }
203
204 #[test]
205 fn with_plan_keeps_the_inner_message_and_the_label() {
206 let error = AppError::Http {
207 status: 401,
208 body: "invalid token".into(),
209 }
210 .with_plan("Claude Max 5x");
211 assert_eq!(error.plan(), Some("Claude Max 5x"));
212 let rendered = error.user_message();
213 assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
214 assert!(!rendered.contains("invalid token"));
215 assert!(!error.is_transient());
216 assert!(
217 AppError::Transport("timeout".into())
218 .with_plan("Pro")
219 .is_transient()
220 );
221 }
222}