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