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 #[must_use]
284 pub fn hint(mut self, hint: impl Into<String>) -> Self {
285 let hint = hint.into();
286 let hint = hint.trim();
287 if hint.is_empty() {
288 return self;
289 }
290 if !self.message.is_empty() {
291 self.message.push(' ');
292 }
293 self.message.push_str(hint);
294 self
295 }
296
297 pub fn is_retryable(&self) -> bool {
301 self.retryable
302 }
303
304 pub fn is_not_found(&self) -> bool {
306 self.code == ErrorCode::NotFound
307 }
308
309 pub fn is_unauthorized(&self) -> bool {
311 matches!(
312 self.code,
313 ErrorCode::Unauthorized | ErrorCode::TokenExpired | ErrorCode::InvalidToken
314 )
315 }
316
317 pub const fn code(&self) -> ErrorCode {
319 self.code
320 }
321
322 pub fn message(&self) -> &str {
324 &self.message
325 }
326
327 pub const fn http_status(&self) -> http::StatusCode {
329 self.http_status
330 }
331
332 pub fn details(&self) -> &HashMap<String, Value> {
334 &self.details
335 }
336
337 pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
339 self.cause.as_deref()
340 }
341}
342
343impl serde::Serialize for AppError {
344 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
345 use serde::ser::SerializeStruct;
346 let mut s = ser.serialize_struct("AppError", 4)?;
347 s.serialize_field("code", &self.code)?;
348 s.serialize_field("message", &self.message)?;
349 s.serialize_field("retryable", &self.retryable)?;
350 if !self.details.is_empty() {
351 s.serialize_field("details", &self.details)?;
352 }
353 s.end()
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
364 fn new_sets_code_and_message() {
365 let err = AppError::new(ErrorCode::NotFound, "item not found");
366 assert_eq!(err.code, ErrorCode::NotFound);
367 assert_eq!(err.message, "item not found");
368 }
369
370 #[test]
371 fn new_sets_retryable_true_for_retryable_code() {
372 let err = AppError::new(ErrorCode::ConnectionFailed, "conn error");
373 assert!(err.retryable);
374 }
375
376 #[test]
377 fn new_sets_retryable_false_for_non_retryable_code() {
378 let err = AppError::new(ErrorCode::Unauthorized, "no access");
379 assert!(!err.retryable);
380 }
381
382 #[test]
383 fn new_sets_http_status_from_code() {
384 let err = AppError::new(ErrorCode::NotFound, "missing");
385 assert_eq!(err.http_status, http::StatusCode::NOT_FOUND);
386 }
387
388 #[test]
389 fn new_starts_with_empty_details() {
390 let err = AppError::new(ErrorCode::Internal, "oops");
391 assert!(err.details.is_empty());
392 }
393
394 #[test]
395 fn new_starts_with_no_cause() {
396 let err = AppError::new(ErrorCode::Internal, "oops");
397 assert!(err.cause.is_none());
398 }
399
400 #[test]
403 fn with_detail_stores_single_kv() {
404 let err = AppError::new(ErrorCode::InvalidInput, "bad field").with_detail("field", "email");
405 assert_eq!(
406 err.details.get("field").and_then(|v| v.as_str()),
407 Some("email")
408 );
409 }
410
411 #[test]
412 fn with_detail_stores_multiple_kv() {
413 let err = AppError::new(ErrorCode::InvalidInput, "bad")
414 .with_detail("field", "email")
415 .with_detail("reason", "invalid format");
416 assert_eq!(err.details.len(), 2);
417 assert!(err.details.contains_key("field"));
418 assert!(err.details.contains_key("reason"));
419 }
420
421 #[test]
424 fn with_cause_stores_cause() {
425 use std::io;
426 let io_err = io::Error::new(io::ErrorKind::TimedOut, "timed out");
427 let err = AppError::new(ErrorCode::Timeout, "timed out").with_cause(io_err);
428 assert!(err.cause.is_some());
429 }
430
431 #[test]
432 fn with_cause_source_returns_cause() {
433 use std::error::Error;
434 use std::io;
435 let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
436 let err = AppError::new(ErrorCode::ConnectionFailed, "conn failed").with_cause(io_err);
437 assert!(err.source().is_some());
438 }
439
440 #[test]
443 fn hint_appends_after_message_preserving_code() {
444 let err =
445 AppError::invalid_input("task", "no such task 'buld'").hint("Did you mean 'build'?");
446 assert_eq!(err.code, ErrorCode::InvalidInput);
447 assert_eq!(
448 err.message,
449 "invalid task: no such task 'buld' Did you mean 'build'?"
450 );
451 }
452
453 #[test]
454 fn hint_preserves_cause_and_details() {
455 use std::io;
456 let io_err = io::Error::new(io::ErrorKind::NotFound, "missing");
457 let err = AppError::new(ErrorCode::InvalidInput, "bad")
458 .with_detail("field", "task")
459 .with_cause(io_err)
460 .hint("try again");
461 assert_eq!(err.message, "bad try again");
462 assert_eq!(err.code, ErrorCode::InvalidInput);
463 assert_eq!(err.retryable, ErrorCode::InvalidInput.is_retryable());
464 assert_eq!(err.http_status, ErrorCode::InvalidInput.http_status());
465 assert!(err.cause.is_some());
466 assert_eq!(
467 err.details.get("field").and_then(|v| v.as_str()),
468 Some("task")
469 );
470 }
471
472 #[test]
473 fn hint_is_a_no_op_for_an_empty_hint() {
474 let err = AppError::invalid_input("task", "no such task 'buld'").hint("");
475 assert_eq!(err.message, "invalid task: no such task 'buld'");
476 }
477
478 #[test]
479 fn hint_is_a_no_op_for_a_whitespace_only_hint() {
480 let err = AppError::invalid_input("task", "no such task 'buld'").hint(" \t");
481 assert_eq!(err.message, "invalid task: no such task 'buld'");
482 }
483
484 #[test]
485 fn hint_trims_surrounding_whitespace_to_a_single_separator() {
486 let err = AppError::new(ErrorCode::InvalidInput, "bad").hint(" try again ");
487 assert_eq!(err.message, "bad try again");
488 }
489
490 #[test]
491 fn not_found_without_id() {
492 let err = AppError::not_found("User", None);
493 assert_eq!(err.code, ErrorCode::NotFound);
494 assert!(err.message.contains("User"));
495 assert!(!err.retryable);
496 }
497
498 #[test]
499 fn not_found_with_id() {
500 let err = AppError::not_found("User", Some("42"));
501 assert_eq!(err.code, ErrorCode::NotFound);
502 assert!(err.message.contains("42"));
503 }
504
505 #[test]
506 fn unauthorized_sets_code_and_not_retryable() {
507 let err = AppError::unauthorized("token missing");
508 assert_eq!(err.code, ErrorCode::Unauthorized);
509 assert!(!err.retryable);
510 }
511
512 #[test]
513 fn invalid_input_sets_code() {
514 let err = AppError::invalid_input("email", "must contain @");
515 assert_eq!(err.code, ErrorCode::InvalidInput);
516 assert!(err.message.contains("email"));
517 assert!(err.message.contains("must contain @"));
518 }
519
520 #[test]
521 fn timeout_is_retryable() {
522 let err = AppError::timeout("db query");
523 assert_eq!(err.code, ErrorCode::Timeout);
524 assert!(err.retryable);
525 assert!(err.message.contains("db query"));
526 }
527
528 #[test]
529 fn rate_limited_is_retryable() {
530 let err = AppError::rate_limited();
531 assert_eq!(err.code, ErrorCode::RateLimited);
532 assert!(err.retryable);
533 }
534
535 #[test]
538 fn display_includes_message() {
539 let err = AppError::new(ErrorCode::NotFound, "item not found");
540 let display = format!("{err}");
541 assert!(display.contains("item not found"), "display was: {display}");
542 }
543
544 #[test]
545 fn display_includes_code() {
546 let err = AppError::new(ErrorCode::NotFound, "item not found");
547 let display = format!("{err}");
548 assert!(display.contains("NOT_FOUND"), "display was: {display}");
549 }
550
551 #[test]
554 fn is_retryable_reflects_retryable_field() {
555 let err = AppError::new(ErrorCode::ServiceUnavailable, "down");
556 assert!(err.is_retryable());
557 }
558
559 #[test]
560 fn is_not_found_true_for_not_found_code() {
561 let err = AppError::not_found("Resource", None);
562 assert!(err.is_not_found());
563 }
564
565 #[test]
566 fn is_not_found_false_for_other_code() {
567 let err = AppError::new(ErrorCode::Internal, "err");
568 assert!(!err.is_not_found());
569 }
570
571 #[test]
572 fn is_unauthorized_true_for_unauthorized_code() {
573 let err = AppError::unauthorized("denied");
574 assert!(err.is_unauthorized());
575 }
576
577 #[test]
578 fn is_unauthorized_true_for_token_expired() {
579 let err = AppError::token_expired();
580 assert!(err.is_unauthorized());
581 }
582}