1use crate::mode::OutputMode;
7use crate::themes::FastApiTheme;
8use std::fmt::Write;
9
10const ANSI_RESET: &str = "\x1b[0m";
11const ANSI_BOLD: &str = "\x1b[1m";
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum LocItem {
16 Field(String),
18 Index(usize),
20}
21
22impl LocItem {
23 #[must_use]
25 pub fn field(name: impl Into<String>) -> Self {
26 Self::Field(name.into())
27 }
28
29 #[must_use]
31 pub const fn index(idx: usize) -> Self {
32 Self::Index(idx)
33 }
34
35 #[must_use]
37 pub fn format(&self) -> String {
38 match self {
39 Self::Field(name) => name.clone(),
40 Self::Index(idx) => format!("[{idx}]"),
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct ValidationErrorDetail {
48 pub loc: Vec<LocItem>,
50 pub msg: String,
52 pub error_type: String,
54 pub input: Option<String>,
56 pub expected: Option<String>,
58 pub ctx: Option<ValidationContext>,
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct ValidationContext {
65 pub min: Option<String>,
67 pub max: Option<String>,
69 pub pattern: Option<String>,
71 pub expected_type: Option<String>,
73 pub extra: Vec<(String, String)>,
75}
76
77impl ValidationContext {
78 #[must_use]
80 pub fn new() -> Self {
81 Self::default()
82 }
83
84 #[must_use]
86 pub fn min(mut self, min: impl Into<String>) -> Self {
87 self.min = Some(min.into());
88 self
89 }
90
91 #[must_use]
93 pub fn max(mut self, max: impl Into<String>) -> Self {
94 self.max = Some(max.into());
95 self
96 }
97
98 #[must_use]
100 pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
101 self.pattern = Some(pattern.into());
102 self
103 }
104
105 #[must_use]
107 pub fn expected_type(mut self, expected: impl Into<String>) -> Self {
108 self.expected_type = Some(expected.into());
109 self
110 }
111
112 #[must_use]
114 pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
115 self.extra.push((key.into(), value.into()));
116 self
117 }
118
119 #[must_use]
121 pub fn is_empty(&self) -> bool {
122 self.min.is_none()
123 && self.max.is_none()
124 && self.pattern.is_none()
125 && self.expected_type.is_none()
126 && self.extra.is_empty()
127 }
128
129 #[must_use]
131 pub fn format(&self) -> String {
132 let mut parts = Vec::new();
133 if let Some(min) = &self.min {
134 parts.push(format!("min={min}"));
135 }
136 if let Some(max) = &self.max {
137 parts.push(format!("max={max}"));
138 }
139 if let Some(pattern) = &self.pattern {
140 parts.push(format!("pattern={pattern}"));
141 }
142 if let Some(expected) = &self.expected_type {
143 parts.push(format!("expected={expected}"));
144 }
145 for (k, v) in &self.extra {
146 parts.push(format!("{k}={v}"));
147 }
148 parts.join(", ")
149 }
150}
151
152impl ValidationErrorDetail {
153 #[must_use]
155 pub fn new(loc: Vec<LocItem>, msg: impl Into<String>, error_type: impl Into<String>) -> Self {
156 Self {
157 loc,
158 msg: msg.into(),
159 error_type: error_type.into(),
160 input: None,
161 expected: None,
162 ctx: None,
163 }
164 }
165
166 #[must_use]
168 pub fn input(mut self, input: impl Into<String>) -> Self {
169 self.input = Some(input.into());
170 self
171 }
172
173 #[must_use]
175 pub fn expected(mut self, expected: impl Into<String>) -> Self {
176 self.expected = Some(expected.into());
177 self
178 }
179
180 #[must_use]
182 pub fn ctx(mut self, ctx: ValidationContext) -> Self {
183 self.ctx = Some(ctx);
184 self
185 }
186
187 #[must_use]
189 pub fn format_loc(&self) -> String {
190 if self.loc.is_empty() {
191 return String::new();
192 }
193
194 let mut result = String::new();
195 for (i, item) in self.loc.iter().enumerate() {
196 match item {
197 LocItem::Field(name) => {
198 if i > 0 {
199 result.push('.');
200 }
201 result.push_str(name);
202 }
203 LocItem::Index(idx) => {
204 let _ = write!(result, "[{idx}]");
205 }
206 }
207 }
208 result
209 }
210}
211
212#[derive(Debug, Clone)]
214pub struct HttpErrorInfo {
215 pub status: u16,
217 pub detail: String,
219 pub code: Option<String>,
221 pub path: Option<String>,
223 pub method: Option<String>,
225}
226
227impl HttpErrorInfo {
228 #[must_use]
230 pub fn new(status: u16, detail: impl Into<String>) -> Self {
231 Self {
232 status,
233 detail: detail.into(),
234 code: None,
235 path: None,
236 method: None,
237 }
238 }
239
240 #[must_use]
242 pub fn code(mut self, code: impl Into<String>) -> Self {
243 self.code = Some(code.into());
244 self
245 }
246
247 #[must_use]
249 pub fn path(mut self, path: impl Into<String>) -> Self {
250 self.path = Some(path.into());
251 self
252 }
253
254 #[must_use]
256 pub fn method(mut self, method: impl Into<String>) -> Self {
257 self.method = Some(method.into());
258 self
259 }
260
261 #[must_use]
263 pub fn status_category(&self) -> &'static str {
264 match self.status {
265 400 => "Bad Request",
266 401 => "Unauthorized",
267 403 => "Forbidden",
268 404 => "Not Found",
269 405 => "Method Not Allowed",
270 409 => "Conflict",
271 422 => "Unprocessable Entity",
272 429 => "Too Many Requests",
273 500 => "Internal Server Error",
274 502 => "Bad Gateway",
275 503 => "Service Unavailable",
276 504 => "Gateway Timeout",
277 _ if self.status >= 400 && self.status < 500 => "Client Error",
278 _ if self.status >= 500 => "Server Error",
279 _ => "Error",
280 }
281 }
282}
283
284#[derive(Debug, Clone)]
286pub struct FormattedError {
287 pub plain: String,
289 pub rich: String,
291}
292
293#[derive(Debug, Clone)]
295pub struct ErrorFormatter {
296 mode: OutputMode,
297 theme: FastApiTheme,
298 pub show_codes: bool,
300 pub show_context: bool,
302}
303
304impl ErrorFormatter {
305 #[must_use]
307 pub fn new(mode: OutputMode) -> Self {
308 Self {
309 mode,
310 theme: FastApiTheme::default(),
311 show_codes: true,
312 show_context: true,
313 }
314 }
315
316 #[must_use]
318 pub fn theme(mut self, theme: FastApiTheme) -> Self {
319 self.theme = theme;
320 self
321 }
322
323 #[must_use]
325 pub fn format_validation_errors(&self, errors: &[ValidationErrorDetail]) -> FormattedError {
326 match self.mode {
327 OutputMode::Plain => {
328 let plain = self.format_validation_plain(errors);
329 FormattedError {
330 plain: plain.clone(),
331 rich: plain,
332 }
333 }
334 OutputMode::Minimal | OutputMode::Rich => {
335 let plain = self.format_validation_plain(errors);
336 let rich = self.format_validation_rich(errors);
337 FormattedError { plain, rich }
338 }
339 }
340 }
341
342 fn format_validation_plain(&self, errors: &[ValidationErrorDetail]) -> String {
343 let mut lines = Vec::new();
344
345 lines.push(format!(
346 "Validation Error ({count} error(s)):",
347 count = errors.len()
348 ));
349 lines.push(String::new());
350
351 for error in errors {
352 let loc = error.format_loc();
353 if loc.is_empty() {
354 lines.push(format!(" - {msg}", msg = error.msg));
355 } else {
356 lines.push(format!(" - {loc}: {msg}", msg = error.msg));
357 }
358
359 if let Some(input) = &error.input {
361 lines.push(format!(" Input: {input}"));
362 }
363
364 if let Some(expected) = &error.expected {
366 lines.push(format!(" Expected: {expected}"));
367 }
368
369 if let Some(ctx) = &error.ctx {
371 if !ctx.is_empty() {
372 lines.push(format!(" Context: {}", ctx.format()));
373 }
374 }
375
376 if self.show_codes {
377 lines.push(format!(
378 " [type: {error_type}]",
379 error_type = error.error_type
380 ));
381 }
382 }
383
384 lines.join("\n")
385 }
386
387 fn format_validation_rich(&self, errors: &[ValidationErrorDetail]) -> String {
388 let mut lines = Vec::new();
389 let error_color = self.theme.error.to_ansi_fg();
390 let muted = self.theme.muted.to_ansi_fg();
391 let accent = self.theme.accent.to_ansi_fg();
392 let warning = self.theme.warning.to_ansi_fg();
393 let info = self.theme.info.to_ansi_fg();
394
395 lines.push(format!(
397 "{error_color}{ANSI_BOLD}✗ Validation Error{ANSI_RESET} {muted}({count} error(s)){ANSI_RESET}",
398 count = errors.len()
399 ));
400 lines.push(String::new());
401
402 for error in errors {
403 let loc = error.format_loc();
404
405 if loc.is_empty() {
407 lines.push(format!(" {warning}●{ANSI_RESET} {msg}", msg = error.msg));
408 } else {
409 lines.push(format!(
410 " {warning}●{ANSI_RESET} {accent}{loc}{ANSI_RESET}: {msg}",
411 msg = error.msg
412 ));
413 }
414
415 if error.input.is_some() || error.expected.is_some() {
417 if let Some(input) = &error.input {
418 lines.push(format!(
419 " {muted}Got:{ANSI_RESET} {error_color}{input}{ANSI_RESET}"
420 ));
421 }
422 if let Some(expected) = &error.expected {
423 lines.push(format!(
424 " {muted}Expected:{ANSI_RESET} {info}{expected}{ANSI_RESET}"
425 ));
426 }
427 }
428
429 if let Some(ctx) = &error.ctx {
431 if !ctx.is_empty() {
432 lines.push(format!(
433 " {muted}Constraints: {}{ANSI_RESET}",
434 ctx.format()
435 ));
436 }
437 }
438
439 if self.show_codes {
441 lines.push(format!(
442 " {muted}[type: {error_type}]{ANSI_RESET}",
443 error_type = error.error_type
444 ));
445 }
446 }
447
448 lines.join("\n")
449 }
450
451 #[must_use]
453 pub fn format_http_error(&self, error: &HttpErrorInfo) -> FormattedError {
454 match self.mode {
455 OutputMode::Plain => {
456 let plain = self.format_http_plain(error);
457 FormattedError {
458 plain: plain.clone(),
459 rich: plain,
460 }
461 }
462 OutputMode::Minimal | OutputMode::Rich => {
463 let plain = self.format_http_plain(error);
464 let rich = self.format_http_rich(error);
465 FormattedError { plain, rich }
466 }
467 }
468 }
469
470 fn format_http_plain(&self, error: &HttpErrorInfo) -> String {
471 let mut lines = Vec::new();
472
473 lines.push(format!(
475 "HTTP {status} {category}",
476 status = error.status,
477 category = error.status_category()
478 ));
479
480 lines.push(format!("Detail: {detail}", detail = error.detail));
482
483 if self.show_codes {
485 if let Some(code) = &error.code {
486 lines.push(format!("Code: {code}"));
487 }
488 }
489
490 if self.show_context {
492 if let (Some(method), Some(path)) = (&error.method, &error.path) {
493 lines.push(format!("Request: {method} {path}"));
494 }
495 }
496
497 lines.join("\n")
498 }
499
500 fn format_http_rich(&self, error: &HttpErrorInfo) -> String {
501 let mut lines = Vec::new();
502 let status_color = self.status_color(error.status).to_ansi_fg();
503 let muted = self.theme.muted.to_ansi_fg();
504 let accent = self.theme.accent.to_ansi_fg();
505
506 let icon = if error.status >= 500 { "✗" } else { "⚠" };
508 lines.push(format!(
509 "{status_color}{ANSI_BOLD}{icon} HTTP {status}{ANSI_RESET} {muted}{category}{ANSI_RESET}",
510 status = error.status,
511 category = error.status_category()
512 ));
513
514 lines.push(format!(" {detail}", detail = error.detail));
516
517 if self.show_codes {
519 if let Some(code) = &error.code {
520 lines.push(format!(" {muted}Code: {accent}{code}{ANSI_RESET}"));
521 }
522 }
523
524 if self.show_context {
526 if let (Some(method), Some(path)) = (&error.method, &error.path) {
527 lines.push(format!(
528 " {muted}Request: {accent}{method} {path}{ANSI_RESET}"
529 ));
530 }
531 }
532
533 lines.join("\n")
534 }
535
536 fn status_color(&self, status: u16) -> crate::themes::Color {
537 match status {
538 400..=499 => self.theme.status_4xx,
539 500..=599 => self.theme.status_5xx,
540 _ => self.theme.muted,
541 }
542 }
543
544 #[must_use]
546 pub fn format_simple(&self, message: &str) -> FormattedError {
547 let plain = format!("Error: {message}");
548
549 let rich = match self.mode {
550 OutputMode::Plain => plain.clone(),
551 OutputMode::Minimal | OutputMode::Rich => {
552 let error_color = self.theme.error.to_ansi_fg();
553 format!("{error_color}{ANSI_BOLD}✗ Error:{ANSI_RESET} {message}")
554 }
555 };
556
557 FormattedError { plain, rich }
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 #[test]
566 fn test_loc_item_format() {
567 assert_eq!(LocItem::field("name").format(), "name");
568 assert_eq!(LocItem::index(0).format(), "[0]");
569 }
570
571 #[test]
572 fn test_validation_error_format_loc() {
573 let error = ValidationErrorDetail::new(
574 vec![
575 LocItem::field("body"),
576 LocItem::field("users"),
577 LocItem::index(0),
578 LocItem::field("email"),
579 ],
580 "invalid email",
581 "value_error",
582 );
583
584 assert_eq!(error.format_loc(), "body.users[0].email");
585 }
586
587 #[test]
588 fn test_validation_error_empty_loc() {
589 let error = ValidationErrorDetail::new(vec![], "missing field", "value_error");
590 assert_eq!(error.format_loc(), "");
591 }
592
593 #[test]
594 fn test_http_error_builder() {
595 let error = HttpErrorInfo::new(404, "Resource not found")
596 .code("NOT_FOUND")
597 .path("/api/users/123")
598 .method("GET");
599
600 assert_eq!(error.status, 404);
601 assert_eq!(error.detail, "Resource not found");
602 assert_eq!(error.code, Some("NOT_FOUND".to_string()));
603 assert_eq!(error.path, Some("/api/users/123".to_string()));
604 }
605
606 #[test]
607 fn test_http_error_status_category() {
608 assert_eq!(HttpErrorInfo::new(400, "").status_category(), "Bad Request");
609 assert_eq!(HttpErrorInfo::new(404, "").status_category(), "Not Found");
610 assert_eq!(
611 HttpErrorInfo::new(500, "").status_category(),
612 "Internal Server Error"
613 );
614 assert_eq!(
615 HttpErrorInfo::new(418, "").status_category(),
616 "Client Error"
617 );
618 }
619
620 #[test]
621 fn test_formatter_validation_plain() {
622 let formatter = ErrorFormatter::new(OutputMode::Plain);
623 let errors = vec![
624 ValidationErrorDetail::new(
625 vec![LocItem::field("body"), LocItem::field("email")],
626 "invalid email format",
627 "value_error.email",
628 ),
629 ValidationErrorDetail::new(
630 vec![LocItem::field("body"), LocItem::field("age")],
631 "must be positive",
632 "value_error.number",
633 ),
634 ];
635
636 let result = formatter.format_validation_errors(&errors);
637
638 assert!(result.plain.contains("Validation Error"));
639 assert!(result.plain.contains("2 error(s)"));
640 assert!(result.plain.contains("body.email"));
641 assert!(result.plain.contains("invalid email format"));
642 assert!(result.plain.contains("body.age"));
643 assert!(!result.plain.contains("\x1b["));
644 }
645
646 #[test]
647 fn test_formatter_validation_rich_has_ansi() {
648 let formatter = ErrorFormatter::new(OutputMode::Rich);
649 let errors = vec![ValidationErrorDetail::new(
650 vec![LocItem::field("name")],
651 "required",
652 "value_error",
653 )];
654
655 let result = formatter.format_validation_errors(&errors);
656
657 assert!(result.rich.contains("\x1b["));
658 }
659
660 #[test]
661 fn test_formatter_http_plain() {
662 let formatter = ErrorFormatter::new(OutputMode::Plain);
663 let error = HttpErrorInfo::new(404, "User not found")
664 .code("USER_NOT_FOUND")
665 .path("/api/users/123")
666 .method("GET");
667
668 let result = formatter.format_http_error(&error);
669
670 assert!(result.plain.contains("HTTP 404"));
671 assert!(result.plain.contains("Not Found"));
672 assert!(result.plain.contains("User not found"));
673 assert!(result.plain.contains("USER_NOT_FOUND"));
674 assert!(result.plain.contains("GET /api/users/123"));
675 }
676
677 #[test]
678 fn test_formatter_simple() {
679 let formatter = ErrorFormatter::new(OutputMode::Plain);
680 let result = formatter.format_simple("Something went wrong");
681
682 assert!(result.plain.contains("Error:"));
683 assert!(result.plain.contains("Something went wrong"));
684 }
685
686 #[test]
687 fn test_formatter_no_codes() {
688 let mut formatter = ErrorFormatter::new(OutputMode::Plain);
689 formatter.show_codes = false;
690
691 let errors = vec![ValidationErrorDetail::new(
692 vec![LocItem::field("field")],
693 "error",
694 "error_type",
695 )];
696
697 let result = formatter.format_validation_errors(&errors);
698
699 assert!(!result.plain.contains("error_type"));
700 }
701}