1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use crate::code::ErrorCode;
6
7#[derive(Debug)]
23pub struct AppError {
24 code: ErrorCode,
26 message: String,
28 retryable: bool,
30 http_status: http::StatusCode,
32 details: HashMap<String, Value>,
34 cause: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
36}
37
38impl std::fmt::Display for AppError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 write!(f, "{}: {}", self.code, self.message)
41 }
42}
43
44impl std::error::Error for AppError {
45 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46 self.cause.as_deref().map(|e| e as _)
47 }
48}
49
50impl AppError {
51 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
55 let retryable = code.is_retryable();
56 let http_status = code.http_status();
57 Self {
58 code,
59 message: message.into(),
60 retryable,
61 http_status,
62 details: HashMap::new(),
63 cause: None,
64 }
65 }
66
67 #[must_use]
71 pub fn with_cause(mut self, cause: impl std::error::Error + Send + Sync + 'static) -> Self {
72 self.cause = Some(Box::new(cause));
73 self
74 }
75
76 #[must_use]
78 pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
79 self.details.insert(key.into(), value.into());
80 self
81 }
82
83 #[must_use]
85 pub fn with_details(mut self, details: HashMap<String, Value>) -> Self {
86 self.details.extend(details);
87 self
88 }
89
90 #[must_use]
92 pub fn retryable(mut self, r: bool) -> Self {
93 self.retryable = r;
94 self
95 }
96
97 pub fn service_unavailable(service: impl Into<String>) -> Self {
101 Self::new(
102 ErrorCode::ServiceUnavailable,
103 format!("service unavailable: {}", service.into()),
104 )
105 }
106
107 pub fn connection_failed(service: impl Into<String>) -> Self {
109 Self::new(
110 ErrorCode::ConnectionFailed,
111 format!("connection failed: {}", service.into()),
112 )
113 }
114
115 pub fn timeout(operation: impl Into<String>) -> Self {
117 Self::new(
118 ErrorCode::Timeout,
119 format!("operation timed out: {}", operation.into()),
120 )
121 }
122
123 pub fn rate_limited() -> Self {
125 Self::new(ErrorCode::RateLimited, "rate limit exceeded")
126 }
127
128 pub fn not_found(resource: impl Into<String>, id: Option<&str>) -> Self {
130 let msg = match id {
131 Some(id) => format!("{} '{}' not found", resource.into(), id),
132 None => format!("{} not found", resource.into()),
133 };
134 Self::new(ErrorCode::NotFound, msg)
135 }
136
137 pub fn already_exists(resource: impl Into<String>) -> Self {
139 Self::new(
140 ErrorCode::AlreadyExists,
141 format!("{} already exists", resource.into()),
142 )
143 }
144
145 pub fn conflict(reason: impl Into<String>) -> Self {
147 Self::new(ErrorCode::Conflict, reason)
148 }
149
150 pub fn invalid_input(field: impl Into<String>, reason: impl Into<String>) -> Self {
152 Self::new(
153 ErrorCode::InvalidInput,
154 format!("invalid {}: {}", field.into(), reason.into()),
155 )
156 }
157
158 pub fn missing_field(field: impl Into<String>) -> Self {
160 Self::new(
161 ErrorCode::MissingField,
162 format!("missing required field: {}", field.into()),
163 )
164 }
165
166 pub fn invalid_format(field: impl Into<String>, expected: impl Into<String>) -> Self {
168 Self::new(
169 ErrorCode::InvalidFormat,
170 format!(
171 "invalid format for {}: expected {}",
172 field.into(),
173 expected.into()
174 ),
175 )
176 }
177
178 pub fn unauthorized(reason: impl Into<String>) -> Self {
180 Self::new(ErrorCode::Unauthorized, reason)
181 }
182
183 pub fn forbidden(reason: impl Into<String>) -> Self {
185 Self::new(ErrorCode::Forbidden, reason)
186 }
187
188 pub fn token_expired() -> Self {
190 Self::new(ErrorCode::TokenExpired, "authentication token has expired")
191 }
192
193 pub fn invalid_token() -> Self {
195 Self::new(ErrorCode::InvalidToken, "authentication token is invalid")
196 }
197
198 pub fn internal(cause: impl std::error::Error + Send + Sync + 'static) -> Self {
203 Self::new(ErrorCode::Internal, "internal server error").with_cause(cause)
204 }
205
206 pub fn database_error(cause: impl std::error::Error + Send + Sync + 'static) -> Self {
211 Self::new(ErrorCode::DatabaseError, "database error").with_cause(cause)
212 }
213
214 pub fn external_service(
219 service: impl Into<String>,
220 cause: impl std::error::Error + Send + Sync + 'static,
221 ) -> Self {
222 let svc = service.into();
223 let msg = format!("external service error ({svc})");
224 Self::new(ErrorCode::ExternalService, msg)
225 .with_cause(cause)
226 .with_detail("service", svc)
227 }
228
229 pub fn cancelled(operation: impl Into<String>) -> Self {
231 let op = operation.into();
232 Self::new(
233 ErrorCode::Cancelled,
234 format!("operation '{}' was cancelled", op),
235 )
236 .with_detail("operation", op)
237 }
238
239 #[must_use]
253 pub fn context(mut self, msg: impl Into<String>) -> Self {
254 let new_msg = format!("{}: {}", msg.into(), self.message);
255 self.message = new_msg;
256 self
257 }
258
259 pub fn is_retryable(&self) -> bool {
263 self.retryable
264 }
265
266 pub fn is_not_found(&self) -> bool {
268 self.code == ErrorCode::NotFound
269 }
270
271 pub fn is_unauthorized(&self) -> bool {
273 matches!(
274 self.code,
275 ErrorCode::Unauthorized | ErrorCode::TokenExpired | ErrorCode::InvalidToken
276 )
277 }
278
279 pub const fn code(&self) -> ErrorCode {
281 self.code
282 }
283
284 pub fn message(&self) -> &str {
286 &self.message
287 }
288
289 pub const fn http_status(&self) -> http::StatusCode {
291 self.http_status
292 }
293
294 pub fn details(&self) -> &HashMap<String, Value> {
296 &self.details
297 }
298
299 pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
301 self.cause.as_deref()
302 }
303}
304
305impl serde::Serialize for AppError {
306 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
307 use serde::ser::SerializeStruct;
308 let mut s = ser.serialize_struct("AppError", 4)?;
309 s.serialize_field("code", &self.code)?;
310 s.serialize_field("message", &self.message)?;
311 s.serialize_field("retryable", &self.retryable)?;
312 if !self.details.is_empty() {
313 s.serialize_field("details", &self.details)?;
314 }
315 s.end()
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
326 fn new_sets_code_and_message() {
327 let err = AppError::new(ErrorCode::NotFound, "item not found");
328 assert_eq!(err.code, ErrorCode::NotFound);
329 assert_eq!(err.message, "item not found");
330 }
331
332 #[test]
333 fn new_sets_retryable_true_for_retryable_code() {
334 let err = AppError::new(ErrorCode::ConnectionFailed, "conn error");
335 assert!(err.retryable);
336 }
337
338 #[test]
339 fn new_sets_retryable_false_for_non_retryable_code() {
340 let err = AppError::new(ErrorCode::Unauthorized, "no access");
341 assert!(!err.retryable);
342 }
343
344 #[test]
345 fn new_sets_http_status_from_code() {
346 let err = AppError::new(ErrorCode::NotFound, "missing");
347 assert_eq!(err.http_status, http::StatusCode::NOT_FOUND);
348 }
349
350 #[test]
351 fn new_starts_with_empty_details() {
352 let err = AppError::new(ErrorCode::Internal, "oops");
353 assert!(err.details.is_empty());
354 }
355
356 #[test]
357 fn new_starts_with_no_cause() {
358 let err = AppError::new(ErrorCode::Internal, "oops");
359 assert!(err.cause.is_none());
360 }
361
362 #[test]
365 fn with_detail_stores_single_kv() {
366 let err = AppError::new(ErrorCode::InvalidInput, "bad field").with_detail("field", "email");
367 assert_eq!(
368 err.details.get("field").and_then(|v| v.as_str()),
369 Some("email")
370 );
371 }
372
373 #[test]
374 fn with_detail_stores_multiple_kv() {
375 let err = AppError::new(ErrorCode::InvalidInput, "bad")
376 .with_detail("field", "email")
377 .with_detail("reason", "invalid format");
378 assert_eq!(err.details.len(), 2);
379 assert!(err.details.contains_key("field"));
380 assert!(err.details.contains_key("reason"));
381 }
382
383 #[test]
386 fn with_cause_stores_cause() {
387 use std::io;
388 let io_err = io::Error::new(io::ErrorKind::TimedOut, "timed out");
389 let err = AppError::new(ErrorCode::Timeout, "timed out").with_cause(io_err);
390 assert!(err.cause.is_some());
391 }
392
393 #[test]
394 fn with_cause_source_returns_cause() {
395 use std::error::Error;
396 use std::io;
397 let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
398 let err = AppError::new(ErrorCode::ConnectionFailed, "conn failed").with_cause(io_err);
399 assert!(err.source().is_some());
400 }
401
402 #[test]
405 fn not_found_without_id() {
406 let err = AppError::not_found("User", None);
407 assert_eq!(err.code, ErrorCode::NotFound);
408 assert!(err.message.contains("User"));
409 assert!(!err.retryable);
410 }
411
412 #[test]
413 fn not_found_with_id() {
414 let err = AppError::not_found("User", Some("42"));
415 assert_eq!(err.code, ErrorCode::NotFound);
416 assert!(err.message.contains("42"));
417 }
418
419 #[test]
420 fn unauthorized_sets_code_and_not_retryable() {
421 let err = AppError::unauthorized("token missing");
422 assert_eq!(err.code, ErrorCode::Unauthorized);
423 assert!(!err.retryable);
424 }
425
426 #[test]
427 fn invalid_input_sets_code() {
428 let err = AppError::invalid_input("email", "must contain @");
429 assert_eq!(err.code, ErrorCode::InvalidInput);
430 assert!(err.message.contains("email"));
431 assert!(err.message.contains("must contain @"));
432 }
433
434 #[test]
435 fn timeout_is_retryable() {
436 let err = AppError::timeout("db query");
437 assert_eq!(err.code, ErrorCode::Timeout);
438 assert!(err.retryable);
439 assert!(err.message.contains("db query"));
440 }
441
442 #[test]
443 fn rate_limited_is_retryable() {
444 let err = AppError::rate_limited();
445 assert_eq!(err.code, ErrorCode::RateLimited);
446 assert!(err.retryable);
447 }
448
449 #[test]
452 fn display_includes_message() {
453 let err = AppError::new(ErrorCode::NotFound, "item not found");
454 let display = format!("{err}");
455 assert!(display.contains("item not found"), "display was: {display}");
456 }
457
458 #[test]
459 fn display_includes_code() {
460 let err = AppError::new(ErrorCode::NotFound, "item not found");
461 let display = format!("{err}");
462 assert!(display.contains("NOT_FOUND"), "display was: {display}");
463 }
464
465 #[test]
468 fn is_retryable_reflects_retryable_field() {
469 let err = AppError::new(ErrorCode::ServiceUnavailable, "down");
470 assert!(err.is_retryable());
471 }
472
473 #[test]
474 fn is_not_found_true_for_not_found_code() {
475 let err = AppError::not_found("Resource", None);
476 assert!(err.is_not_found());
477 }
478
479 #[test]
480 fn is_not_found_false_for_other_code() {
481 let err = AppError::new(ErrorCode::Internal, "err");
482 assert!(!err.is_not_found());
483 }
484
485 #[test]
486 fn is_unauthorized_true_for_unauthorized_code() {
487 let err = AppError::unauthorized("denied");
488 assert!(err.is_unauthorized());
489 }
490
491 #[test]
492 fn is_unauthorized_true_for_token_expired() {
493 let err = AppError::token_expired();
494 assert!(err.is_unauthorized());
495 }
496}