Skip to main content

fastapi_output/components/
errors.rs

1//! Error formatting component.
2//!
3//! Provides formatted error display for validation errors, HTTP errors,
4//! and internal errors with location path display.
5
6use 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/// Location item for error paths.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum LocItem {
16    /// Field name.
17    Field(String),
18    /// Array index.
19    Index(usize),
20}
21
22impl LocItem {
23    /// Create a field location item.
24    #[must_use]
25    pub fn field(name: impl Into<String>) -> Self {
26        Self::Field(name.into())
27    }
28
29    /// Create an index location item.
30    #[must_use]
31    pub const fn index(idx: usize) -> Self {
32        Self::Index(idx)
33    }
34
35    /// Format the location item.
36    #[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/// A single validation error.
46#[derive(Debug, Clone)]
47pub struct ValidationErrorDetail {
48    /// Location path (e.g., ["body", "user", "email"]).
49    pub loc: Vec<LocItem>,
50    /// Error message.
51    pub msg: String,
52    /// Error type (e.g., "value_error", "type_error").
53    pub error_type: String,
54    /// The actual input value that caused the error.
55    pub input: Option<String>,
56    /// Expected value or constraint (e.g., "integer", "min: 1", "email format").
57    pub expected: Option<String>,
58    /// Context information (e.g., constraint values).
59    pub ctx: Option<ValidationContext>,
60}
61
62/// Context information for validation errors.
63#[derive(Debug, Clone, Default)]
64pub struct ValidationContext {
65    /// Minimum constraint value.
66    pub min: Option<String>,
67    /// Maximum constraint value.
68    pub max: Option<String>,
69    /// Pattern that was expected.
70    pub pattern: Option<String>,
71    /// Expected type name.
72    pub expected_type: Option<String>,
73    /// Additional context key-value pairs.
74    pub extra: Vec<(String, String)>,
75}
76
77impl ValidationContext {
78    /// Create a new empty context.
79    #[must_use]
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Set minimum constraint.
85    #[must_use]
86    pub fn min(mut self, min: impl Into<String>) -> Self {
87        self.min = Some(min.into());
88        self
89    }
90
91    /// Set maximum constraint.
92    #[must_use]
93    pub fn max(mut self, max: impl Into<String>) -> Self {
94        self.max = Some(max.into());
95        self
96    }
97
98    /// Set pattern constraint.
99    #[must_use]
100    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
101        self.pattern = Some(pattern.into());
102        self
103    }
104
105    /// Set expected type.
106    #[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    /// Add extra context.
113    #[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    /// Check if context has any values.
120    #[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    /// Format context as a string.
130    #[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    /// Create a new validation error.
154    #[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    /// Set the input value that caused the error.
167    #[must_use]
168    pub fn input(mut self, input: impl Into<String>) -> Self {
169        self.input = Some(input.into());
170        self
171    }
172
173    /// Set the expected value or constraint.
174    #[must_use]
175    pub fn expected(mut self, expected: impl Into<String>) -> Self {
176        self.expected = Some(expected.into());
177        self
178    }
179
180    /// Set the validation context.
181    #[must_use]
182    pub fn ctx(mut self, ctx: ValidationContext) -> Self {
183        self.ctx = Some(ctx);
184        self
185    }
186
187    /// Format the location path as a string.
188    #[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/// HTTP error information.
213#[derive(Debug, Clone)]
214pub struct HttpErrorInfo {
215    /// HTTP status code.
216    pub status: u16,
217    /// Error detail message.
218    pub detail: String,
219    /// Optional error code.
220    pub code: Option<String>,
221    /// Request path (for context).
222    pub path: Option<String>,
223    /// Request method (for context).
224    pub method: Option<String>,
225}
226
227impl HttpErrorInfo {
228    /// Create a new HTTP error.
229    #[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    /// Set the error code.
241    #[must_use]
242    pub fn code(mut self, code: impl Into<String>) -> Self {
243        self.code = Some(code.into());
244        self
245    }
246
247    /// Set the request path.
248    #[must_use]
249    pub fn path(mut self, path: impl Into<String>) -> Self {
250        self.path = Some(path.into());
251        self
252    }
253
254    /// Set the request method.
255    #[must_use]
256    pub fn method(mut self, method: impl Into<String>) -> Self {
257        self.method = Some(method.into());
258        self
259    }
260
261    /// Get the status category name.
262    #[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/// Formatted error output.
285#[derive(Debug, Clone)]
286pub struct FormattedError {
287    /// Plain text version.
288    pub plain: String,
289    /// ANSI-formatted version.
290    pub rich: String,
291}
292
293/// Error formatter.
294#[derive(Debug, Clone)]
295pub struct ErrorFormatter {
296    mode: OutputMode,
297    theme: FastApiTheme,
298    /// Whether to show error codes.
299    pub show_codes: bool,
300    /// Whether to show request context.
301    pub show_context: bool,
302}
303
304impl ErrorFormatter {
305    /// Create a new error formatter.
306    #[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    /// Set the theme.
317    #[must_use]
318    pub fn theme(mut self, theme: FastApiTheme) -> Self {
319        self.theme = theme;
320        self
321    }
322
323    /// Format a list of validation errors.
324    #[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            // Show input value if present
360            if let Some(input) = &error.input {
361                lines.push(format!("    Input: {input}"));
362            }
363
364            // Show expected value if present
365            if let Some(expected) = &error.expected {
366                lines.push(format!("    Expected: {expected}"));
367            }
368
369            // Show context if present
370            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        // Header
396        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            // Error line with location
406            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            // Show input vs expected comparison
416            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            // Show context if present
430            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            // Error type
440            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    /// Format an HTTP error.
452    #[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        // Status line
474        lines.push(format!(
475            "HTTP {status} {category}",
476            status = error.status,
477            category = error.status_category()
478        ));
479
480        // Detail
481        lines.push(format!("Detail: {detail}", detail = error.detail));
482
483        // Code
484        if self.show_codes {
485            if let Some(code) = &error.code {
486                lines.push(format!("Code: {code}"));
487            }
488        }
489
490        // Context
491        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        // Status line with color
507        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        // Detail
515        lines.push(format!("  {detail}", detail = error.detail));
516
517        // Code
518        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        // Context
525        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    /// Format a simple error message.
545    #[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}