Skip to main content

fastapi_output/
themes.rs

1//! Theme system for fastapi_rust console output.
2//!
3//! Defines color palettes, icons, spacing, and box styles for consistent
4//! visual output across all components. Colors follow the FastAPI
5//! visual identity and Swagger UI conventions for familiarity.
6//!
7//! # Theme Presets
8//!
9//! - `FastApi` - Default theme inspired by FastAPI documentation
10//! - `Neon` - High-contrast cyberpunk theme
11//! - `Minimal` - Grayscale with subtle accents
12//! - `Monokai` - Dark theme inspired by the Monokai color scheme
13//! - `Light` - Optimized for light terminal backgrounds
14//! - `Accessible` - High-contrast, WCAG-compliant colors
15//!
16//! # Components
17//!
18//! - [`FastApiTheme`] - Color palette for all UI elements
19//! - [`ThemeIcons`] - Unicode icons with ASCII fallbacks
20//! - [`ThemeSpacing`] - Consistent layout spacing values
21//! - [`BoxStyle`] - Box drawing character sets
22//!
23//! # Example
24//!
25//! ```rust
26//! use fastapi_output::themes::{FastApiTheme, ThemePreset, ThemeIcons, ThemeSpacing};
27//!
28//! // Get default theme
29//! let theme = FastApiTheme::default();
30//!
31//! // Get theme by preset
32//! let neon = FastApiTheme::from_preset(ThemePreset::Neon);
33//!
34//! // Use icons (with ASCII fallback)
35//! let icons = ThemeIcons::unicode();
36//! println!("{} Success!", icons.success);
37//!
38//! // Consistent spacing
39//! let spacing = ThemeSpacing::default();
40//! let indent = " ".repeat(spacing.indent);
41//!
42//! // Parse from environment variable
43//! let preset: ThemePreset = "monokai".parse().unwrap();
44//! ```
45
46// Hex color literals (0xRRGGBB) are idiomatic and readable as-is
47#![allow(clippy::unreadable_literal)]
48
49use std::str::FromStr;
50
51/// A color in RGB format.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct Color {
54    /// Red component (0-255).
55    pub r: u8,
56    /// Green component (0-255).
57    pub g: u8,
58    /// Blue component (0-255).
59    pub b: u8,
60}
61
62impl Color {
63    /// Create a new color from RGB values.
64    #[must_use]
65    pub const fn new(r: u8, g: u8, b: u8) -> Self {
66        Self { r, g, b }
67    }
68
69    /// Create a color from a hex value (0xRRGGBB).
70    #[must_use]
71    pub const fn from_hex(hex: u32) -> Self {
72        Self {
73            r: ((hex >> 16) & 0xFF) as u8,
74            g: ((hex >> 8) & 0xFF) as u8,
75            b: (hex & 0xFF) as u8,
76        }
77    }
78
79    /// Convert to hex string (e.g., "#009688").
80    #[must_use]
81    pub fn to_hex(&self) -> String {
82        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
83    }
84
85    /// Convert to RGB tuple.
86    #[must_use]
87    pub const fn to_rgb(&self) -> (u8, u8, u8) {
88        (self.r, self.g, self.b)
89    }
90
91    /// Convert to ANSI 24-bit foreground escape code.
92    #[must_use]
93    pub fn to_ansi_fg(&self) -> String {
94        format!("\x1b[38;2;{};{};{}m", self.r, self.g, self.b)
95    }
96
97    /// Convert to ANSI 24-bit background escape code.
98    #[must_use]
99    pub fn to_ansi_bg(&self) -> String {
100        format!("\x1b[48;2;{};{};{}m", self.r, self.g, self.b)
101    }
102
103    /// Calculate relative luminance for contrast calculations.
104    ///
105    /// Uses the WCAG formula for relative luminance.
106    #[must_use]
107    pub fn luminance(&self) -> f64 {
108        fn channel_luminance(c: u8) -> f64 {
109            let c = f64::from(c) / 255.0;
110            if c <= 0.03928 {
111                c / 12.92
112            } else {
113                ((c + 0.055) / 1.055).powf(2.4)
114            }
115        }
116        0.2126 * channel_luminance(self.r)
117            + 0.7152 * channel_luminance(self.g)
118            + 0.0722 * channel_luminance(self.b)
119    }
120
121    /// Calculate WCAG contrast ratio between this color and another.
122    ///
123    /// Returns a value between 1.0 (no contrast) and 21.0 (max contrast).
124    /// WCAG AA requires 4.5:1 for normal text, 3:1 for large text.
125    #[must_use]
126    pub fn contrast_ratio(&self, other: &Color) -> f64 {
127        let l1 = self.luminance();
128        let l2 = other.luminance();
129        let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
130        (lighter + 0.05) / (darker + 0.05)
131    }
132}
133
134// ================================================================================================
135// Theme Icons
136// ================================================================================================
137
138/// Icons used throughout the theme for visual feedback.
139///
140/// Provides both Unicode icons and ASCII fallbacks for terminals
141/// that don't support extended Unicode characters.
142///
143/// # Example
144///
145/// ```rust
146/// use fastapi_output::themes::ThemeIcons;
147///
148/// let icons = ThemeIcons::unicode();
149/// println!("{} Success!", icons.success);
150///
151/// // For older terminals
152/// let ascii = ThemeIcons::ascii();
153/// println!("{} Success!", ascii.success);
154/// ```
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ThemeIcons {
157    /// Success indicator (e.g., checkmark).
158    pub success: &'static str,
159    /// Failure/error indicator (e.g., X).
160    pub failure: &'static str,
161    /// Warning indicator.
162    pub warning: &'static str,
163    /// Info indicator.
164    pub info: &'static str,
165    /// Right arrow for flow indication.
166    pub arrow_right: &'static str,
167    /// Left arrow.
168    pub arrow_left: &'static str,
169    /// Bullet point.
170    pub bullet: &'static str,
171    /// Lock/security indicator.
172    pub lock: &'static str,
173    /// Unlock indicator.
174    pub unlock: &'static str,
175    /// HTTP indicator.
176    pub http: &'static str,
177    /// Loading/in-progress indicator.
178    pub loading: &'static str,
179    /// Route/path indicator.
180    pub route: &'static str,
181    /// Database/storage indicator.
182    pub database: &'static str,
183    /// Time/clock indicator.
184    pub time: &'static str,
185    /// Size/memory indicator.
186    pub size: &'static str,
187}
188
189impl ThemeIcons {
190    /// Create icons using Unicode characters.
191    ///
192    /// Recommended for modern terminals with good Unicode support.
193    #[must_use]
194    pub const fn unicode() -> Self {
195        Self {
196            success: "\u{2713}",     // ✓
197            failure: "\u{2717}",     // ✗
198            warning: "\u{26A0}",     // ⚠
199            info: "\u{2139}",        // ℹ
200            arrow_right: "\u{2192}", // →
201            arrow_left: "\u{2190}",  // ←
202            bullet: "\u{2022}",      // •
203            lock: "\u{1F512}",       // 🔒
204            unlock: "\u{1F513}",     // 🔓
205            http: "\u{1F310}",       // 🌐
206            loading: "\u{25CF}",     // ●
207            route: "\u{2192}",       // →
208            database: "\u{1F5C4}",   // 🗄
209            time: "\u{23F1}",        // ⏱
210            size: "\u{1F4BE}",       // 💾
211        }
212    }
213
214    /// Create icons using ASCII-only characters.
215    ///
216    /// Use for terminals without Unicode support or when
217    /// consistent character widths are required.
218    #[must_use]
219    pub const fn ascii() -> Self {
220        Self {
221            success: "[OK]",
222            failure: "[X]",
223            warning: "[!]",
224            info: "[i]",
225            arrow_right: "->",
226            arrow_left: "<-",
227            bullet: "*",
228            lock: "[#]",
229            unlock: "[ ]",
230            http: "[H]",
231            loading: "...",
232            route: "->",
233            database: "[D]",
234            time: "[T]",
235            size: "[S]",
236        }
237    }
238
239    /// Create compact Unicode icons (single-width preferred).
240    ///
241    /// Uses single-width Unicode where possible for better alignment.
242    #[must_use]
243    pub const fn compact() -> Self {
244        Self {
245            success: "\u{2713}", // ✓
246            failure: "\u{2717}", // ✗
247            warning: "!",
248            info: "i",
249            arrow_right: ">",
250            arrow_left: "<",
251            bullet: "\u{2022}", // •
252            lock: "#",
253            unlock: "o",
254            http: "@",
255            loading: ".",
256            route: "/",
257            database: "D",
258            time: "T",
259            size: "S",
260        }
261    }
262
263    /// Auto-detect based on environment.
264    ///
265    /// Returns ASCII icons if TERM is "dumb" or if running in
266    /// a known agent environment that prefers plain text.
267    #[must_use]
268    pub fn auto() -> Self {
269        if std::env::var("TERM").is_ok_and(|t| t == "dumb")
270            || std::env::var("CI").is_ok()
271            || std::env::var("CLAUDE_CODE").is_ok()
272            || std::env::var("CODEX_CLI").is_ok()
273        {
274            Self::ascii()
275        } else {
276            Self::unicode()
277        }
278    }
279}
280
281impl Default for ThemeIcons {
282    fn default() -> Self {
283        Self::unicode()
284    }
285}
286
287// ================================================================================================
288// Theme Spacing
289// ================================================================================================
290
291/// Spacing values for consistent layout across components.
292///
293/// All values are in character units for terminal output.
294///
295/// # Example
296///
297/// ```rust
298/// use fastapi_output::themes::ThemeSpacing;
299///
300/// let spacing = ThemeSpacing::default();
301/// let indent = " ".repeat(spacing.indent);
302/// println!("{}Indented content", indent);
303/// ```
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub struct ThemeSpacing {
306    /// Standard indentation level (characters).
307    pub indent: usize,
308    /// Padding inside panels/boxes (characters).
309    pub panel_padding: usize,
310    /// Cell padding in tables (characters).
311    pub table_cell_padding: usize,
312    /// Gap between sections (blank lines).
313    pub section_gap: usize,
314    /// Gap between related items (blank lines).
315    pub item_gap: usize,
316    /// Width of method column in route tables.
317    pub method_width: usize,
318    /// Width of status code column.
319    pub status_width: usize,
320}
321
322impl ThemeSpacing {
323    /// Create default spacing suitable for most terminals.
324    #[must_use]
325    pub const fn default_spacing() -> Self {
326        Self {
327            indent: 2,
328            panel_padding: 1,
329            table_cell_padding: 1,
330            section_gap: 1,
331            item_gap: 0,
332            method_width: 7, // "OPTIONS" is longest at 7 chars
333            status_width: 3, // "500" is 3 chars
334        }
335    }
336
337    /// Create compact spacing for dense output.
338    #[must_use]
339    pub const fn compact() -> Self {
340        Self {
341            indent: 1,
342            panel_padding: 0,
343            table_cell_padding: 1,
344            section_gap: 0,
345            item_gap: 0,
346            method_width: 6,
347            status_width: 3,
348        }
349    }
350
351    /// Create spacious layout for readability.
352    #[must_use]
353    pub const fn spacious() -> Self {
354        Self {
355            indent: 4,
356            panel_padding: 2,
357            table_cell_padding: 2,
358            section_gap: 2,
359            item_gap: 1,
360            method_width: 8,
361            status_width: 4,
362        }
363    }
364}
365
366impl Default for ThemeSpacing {
367    fn default() -> Self {
368        Self::default_spacing()
369    }
370}
371
372// ================================================================================================
373// Box Styles
374// ================================================================================================
375
376/// Box drawing character sets for panels and tables.
377///
378/// Supports multiple styles from minimal ASCII to decorative Unicode.
379///
380/// # Example
381///
382/// ```rust
383/// use fastapi_output::themes::BoxStyle;
384///
385/// let style = BoxStyle::rounded();
386/// println!("{}{}{}", style.top_left, style.horizontal, style.top_right);
387/// // ╭─╮
388/// ```
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub struct BoxStyle {
391    /// Top-left corner character.
392    pub top_left: char,
393    /// Top-right corner character.
394    pub top_right: char,
395    /// Bottom-left corner character.
396    pub bottom_left: char,
397    /// Bottom-right corner character.
398    pub bottom_right: char,
399    /// Horizontal line character.
400    pub horizontal: char,
401    /// Vertical line character.
402    pub vertical: char,
403    /// Left T-junction for tables.
404    pub left_tee: char,
405    /// Right T-junction for tables.
406    pub right_tee: char,
407    /// Top T-junction for tables.
408    pub top_tee: char,
409    /// Bottom T-junction for tables.
410    pub bottom_tee: char,
411    /// Cross/plus for table intersections.
412    pub cross: char,
413}
414
415impl BoxStyle {
416    /// Rounded corners using Unicode box drawing characters.
417    #[must_use]
418    pub const fn rounded() -> Self {
419        Self {
420            top_left: '\u{256D}',     // ╭
421            top_right: '\u{256E}',    // ╮
422            bottom_left: '\u{2570}',  // ╰
423            bottom_right: '\u{256F}', // ╯
424            horizontal: '\u{2500}',   // ─
425            vertical: '\u{2502}',     // │
426            left_tee: '\u{251C}',     // ├
427            right_tee: '\u{2524}',    // ┤
428            top_tee: '\u{252C}',      // ┬
429            bottom_tee: '\u{2534}',   // ┴
430            cross: '\u{253C}',        // ┼
431        }
432    }
433
434    /// Square corners using Unicode box drawing characters.
435    #[must_use]
436    pub const fn square() -> Self {
437        Self {
438            top_left: '\u{250C}',     // ┌
439            top_right: '\u{2510}',    // ┐
440            bottom_left: '\u{2514}',  // └
441            bottom_right: '\u{2518}', // ┘
442            horizontal: '\u{2500}',   // ─
443            vertical: '\u{2502}',     // │
444            left_tee: '\u{251C}',     // ├
445            right_tee: '\u{2524}',    // ┤
446            top_tee: '\u{252C}',      // ┬
447            bottom_tee: '\u{2534}',   // ┴
448            cross: '\u{253C}',        // ┼
449        }
450    }
451
452    /// Heavy/bold box drawing characters.
453    #[must_use]
454    pub const fn heavy() -> Self {
455        Self {
456            top_left: '\u{250F}',     // ┏
457            top_right: '\u{2513}',    // ┓
458            bottom_left: '\u{2517}',  // ┗
459            bottom_right: '\u{251B}', // ┛
460            horizontal: '\u{2501}',   // ━
461            vertical: '\u{2503}',     // ┃
462            left_tee: '\u{2523}',     // ┣
463            right_tee: '\u{252B}',    // ┫
464            top_tee: '\u{2533}',      // ┳
465            bottom_tee: '\u{253B}',   // ┻
466            cross: '\u{254B}',        // ╋
467        }
468    }
469
470    /// Double-line box drawing characters.
471    #[must_use]
472    pub const fn double() -> Self {
473        Self {
474            top_left: '\u{2554}',     // ╔
475            top_right: '\u{2557}',    // ╗
476            bottom_left: '\u{255A}',  // ╚
477            bottom_right: '\u{255D}', // ╝
478            horizontal: '\u{2550}',   // ═
479            vertical: '\u{2551}',     // ║
480            left_tee: '\u{2560}',     // ╠
481            right_tee: '\u{2563}',    // ╣
482            top_tee: '\u{2566}',      // ╦
483            bottom_tee: '\u{2569}',   // ╩
484            cross: '\u{256C}',        // ╬
485        }
486    }
487
488    /// ASCII-only box drawing using +, -, |.
489    #[must_use]
490    pub const fn ascii() -> Self {
491        Self {
492            top_left: '+',
493            top_right: '+',
494            bottom_left: '+',
495            bottom_right: '+',
496            horizontal: '-',
497            vertical: '|',
498            left_tee: '+',
499            right_tee: '+',
500            top_tee: '+',
501            bottom_tee: '+',
502            cross: '+',
503        }
504    }
505
506    /// No visible borders (space characters).
507    #[must_use]
508    pub const fn none() -> Self {
509        Self {
510            top_left: ' ',
511            top_right: ' ',
512            bottom_left: ' ',
513            bottom_right: ' ',
514            horizontal: ' ',
515            vertical: ' ',
516            left_tee: ' ',
517            right_tee: ' ',
518            top_tee: ' ',
519            bottom_tee: ' ',
520            cross: ' ',
521        }
522    }
523
524    /// Draw a horizontal line of the specified width.
525    #[must_use]
526    pub fn horizontal_line(&self, width: usize) -> String {
527        std::iter::repeat_n(self.horizontal, width).collect()
528    }
529
530    /// Draw a complete top border with corners.
531    #[must_use]
532    pub fn top_border(&self, width: usize) -> String {
533        format!(
534            "{}{}{}",
535            self.top_left,
536            self.horizontal_line(width.saturating_sub(2)),
537            self.top_right
538        )
539    }
540
541    /// Draw a complete bottom border with corners.
542    #[must_use]
543    pub fn bottom_border(&self, width: usize) -> String {
544        format!(
545            "{}{}{}",
546            self.bottom_left,
547            self.horizontal_line(width.saturating_sub(2)),
548            self.bottom_right
549        )
550    }
551}
552
553impl Default for BoxStyle {
554    fn default() -> Self {
555        Self::rounded()
556    }
557}
558
559/// Preset for box style selection.
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
561pub enum BoxStylePreset {
562    /// Rounded corners (default).
563    #[default]
564    Rounded,
565    /// Square corners.
566    Square,
567    /// Heavy/bold lines.
568    Heavy,
569    /// Double-line borders.
570    Double,
571    /// ASCII-only characters.
572    Ascii,
573    /// No visible borders.
574    None,
575}
576
577impl BoxStylePreset {
578    /// Get the `BoxStyle` for this preset.
579    #[must_use]
580    pub const fn style(&self) -> BoxStyle {
581        match self {
582            Self::Rounded => BoxStyle::rounded(),
583            Self::Square => BoxStyle::square(),
584            Self::Heavy => BoxStyle::heavy(),
585            Self::Double => BoxStyle::double(),
586            Self::Ascii => BoxStyle::ascii(),
587            Self::None => BoxStyle::none(),
588        }
589    }
590}
591
592impl FromStr for BoxStylePreset {
593    type Err = BoxStyleParseError;
594
595    fn from_str(s: &str) -> Result<Self, Self::Err> {
596        match s.to_lowercase().as_str() {
597            "rounded" => Ok(Self::Rounded),
598            "square" => Ok(Self::Square),
599            "heavy" | "bold" => Ok(Self::Heavy),
600            "double" => Ok(Self::Double),
601            "ascii" | "plain" => Ok(Self::Ascii),
602            "none" | "invisible" => Ok(Self::None),
603            _ => Err(BoxStyleParseError(s.to_string())),
604        }
605    }
606}
607
608/// Error parsing box style name.
609#[derive(Debug, Clone)]
610pub struct BoxStyleParseError(String);
611
612impl std::fmt::Display for BoxStyleParseError {
613    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
614        write!(
615            f,
616            "unknown box style '{}', available: rounded, square, heavy, double, ascii, none",
617            self.0
618        )
619    }
620}
621
622impl std::error::Error for BoxStyleParseError {}
623
624/// Convert RGB tuple to hex string.
625#[must_use]
626pub fn rgb_to_hex(rgb: (u8, u8, u8)) -> String {
627    format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2)
628}
629
630/// Parse hex color to RGB tuple.
631///
632/// Supports both 6-digit (#RRGGBB) and 3-digit (#RGB) formats.
633/// The leading '#' is optional.
634///
635/// # Example
636///
637/// ```rust
638/// use fastapi_output::themes::hex_to_rgb;
639///
640/// assert_eq!(hex_to_rgb("#009688"), Some((0, 150, 136)));
641/// assert_eq!(hex_to_rgb("FF5500"), Some((255, 85, 0)));
642/// assert_eq!(hex_to_rgb("#F00"), Some((255, 0, 0)));
643/// ```
644#[must_use]
645pub fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
646    let hex = hex.trim_start_matches('#');
647    if hex.len() == 6 {
648        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
649        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
650        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
651        Some((r, g, b))
652    } else if hex.len() == 3 {
653        let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
654        let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
655        let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
656        Some((r, g, b))
657    } else {
658        None
659    }
660}
661
662/// FastAPI-inspired color theme for console output.
663///
664/// Contains colors for:
665/// - Brand identity (primary, secondary, accent)
666/// - Semantic meaning (success, warning, error, info)
667/// - HTTP methods (GET, POST, PUT, DELETE, etc.)
668/// - Status codes (1xx, 2xx, 3xx, 4xx, 5xx)
669/// - Structural elements (border, header, muted, highlight)
670#[derive(Debug, Clone, PartialEq, Eq)]
671pub struct FastApiTheme {
672    // === Brand Colors ===
673    /// Primary brand color (teal, inspired by FastAPI docs).
674    pub primary: Color,
675    /// Secondary brand color.
676    pub secondary: Color,
677    /// Accent color for highlights.
678    pub accent: Color,
679
680    // === Semantic Colors ===
681    /// Success indicator color (green).
682    pub success: Color,
683    /// Warning indicator color (orange/yellow).
684    pub warning: Color,
685    /// Error indicator color (red).
686    pub error: Color,
687    /// Info indicator color (blue).
688    pub info: Color,
689
690    // === HTTP Method Colors (Swagger UI conventions) ===
691    /// GET method color (blue).
692    pub http_get: Color,
693    /// POST method color (green).
694    pub http_post: Color,
695    /// PUT method color (orange).
696    pub http_put: Color,
697    /// DELETE method color (red).
698    pub http_delete: Color,
699    /// PATCH method color (cyan).
700    pub http_patch: Color,
701    /// OPTIONS method color (gray).
702    pub http_options: Color,
703    /// HEAD method color (purple).
704    pub http_head: Color,
705
706    // === Status Code Colors ===
707    /// 1xx informational (gray).
708    pub status_1xx: Color,
709    /// 2xx success (green).
710    pub status_2xx: Color,
711    /// 3xx redirect (cyan).
712    pub status_3xx: Color,
713    /// 4xx client error (yellow/orange).
714    pub status_4xx: Color,
715    /// 5xx server error (red).
716    pub status_5xx: Color,
717
718    // === Structural Colors ===
719    /// Border color for boxes/panels.
720    pub border: Color,
721    /// Header text color.
722    pub header: Color,
723    /// Muted/secondary text color.
724    pub muted: Color,
725    /// Background highlight color.
726    pub highlight_bg: Color,
727}
728
729impl FastApiTheme {
730    /// Create a theme from a preset.
731    #[must_use]
732    pub fn from_preset(preset: ThemePreset) -> Self {
733        match preset {
734            ThemePreset::FastApi | ThemePreset::Default => Self::fastapi(),
735            ThemePreset::Neon => Self::neon(),
736            ThemePreset::Minimal => Self::minimal(),
737            ThemePreset::Monokai => Self::monokai(),
738            ThemePreset::Light => Self::light(),
739            ThemePreset::Accessible => Self::accessible(),
740        }
741    }
742
743    /// Create the default FastAPI-inspired theme.
744    ///
745    /// Colors chosen to match FastAPI documentation styling
746    /// and Swagger UI conventions for familiarity.
747    #[must_use]
748    pub fn fastapi() -> Self {
749        Self {
750            // Brand colors (FastAPI teal/green)
751            primary: Color::from_hex(0x009688),   // Teal 500
752            secondary: Color::from_hex(0x4CAF50), // Green 500
753            accent: Color::from_hex(0xFF9800),    // Orange 500
754
755            // Semantic colors
756            success: Color::from_hex(0x4CAF50), // Green
757            warning: Color::from_hex(0xFF9800), // Orange
758            error: Color::from_hex(0xF44336),   // Red
759            info: Color::from_hex(0x2196F3),    // Blue
760
761            // HTTP methods (Swagger UI)
762            http_get: Color::from_hex(0x61AFFE),     // Blue
763            http_post: Color::from_hex(0x49CC90),    // Green
764            http_put: Color::from_hex(0xFCA130),     // Orange
765            http_delete: Color::from_hex(0xF93E3E),  // Red
766            http_patch: Color::from_hex(0x50E3C2),   // Cyan
767            http_options: Color::from_hex(0x808080), // Gray
768            http_head: Color::from_hex(0x9370DB),    // Purple
769
770            // Status codes
771            status_1xx: Color::from_hex(0x808080), // Gray
772            status_2xx: Color::from_hex(0x4CAF50), // Green
773            status_3xx: Color::from_hex(0x00BCD4), // Cyan
774            status_4xx: Color::from_hex(0xFFC107), // Yellow/Amber
775            status_5xx: Color::from_hex(0xF44336), // Red
776
777            // Structural
778            border: Color::from_hex(0x9E9E9E),       // Gray 500
779            header: Color::from_hex(0x009688),       // Primary
780            muted: Color::from_hex(0x757575),        // Gray 600
781            highlight_bg: Color::from_hex(0x263238), // Blue Grey 900
782        }
783    }
784
785    /// Create a neon/cyberpunk theme with high contrast.
786    #[must_use]
787    pub fn neon() -> Self {
788        Self {
789            primary: Color::from_hex(0x00FFFF),   // Cyan
790            secondary: Color::from_hex(0xFF00FF), // Magenta
791            accent: Color::from_hex(0xFFFF00),    // Yellow
792
793            success: Color::from_hex(0x00FF80), // Neon green
794            warning: Color::from_hex(0xFFFF00), // Yellow
795            error: Color::from_hex(0xFF0040),   // Hot pink/red
796            info: Color::from_hex(0x0080FF),    // Electric blue
797
798            http_get: Color::from_hex(0x00FFFF),
799            http_post: Color::from_hex(0x00FF80),
800            http_put: Color::from_hex(0xFFA500),
801            http_delete: Color::from_hex(0xFF0040),
802            http_patch: Color::from_hex(0xFF00FF),
803            http_options: Color::from_hex(0x808080),
804            http_head: Color::from_hex(0x9400D3),
805
806            status_1xx: Color::from_hex(0x808080),
807            status_2xx: Color::from_hex(0x00FF80),
808            status_3xx: Color::from_hex(0x00FFFF),
809            status_4xx: Color::from_hex(0xFFFF00),
810            status_5xx: Color::from_hex(0xFF0040),
811
812            border: Color::from_hex(0x00FFFF),
813            header: Color::from_hex(0xFF00FF),
814            muted: Color::from_hex(0x646464),
815            highlight_bg: Color::from_hex(0x141428),
816        }
817    }
818
819    /// Create a minimal grayscale theme with accent colors.
820    #[must_use]
821    pub fn minimal() -> Self {
822        Self {
823            primary: Color::from_hex(0xC8C8C8),
824            secondary: Color::from_hex(0xB4B4B4),
825            accent: Color::from_hex(0xFF9800),
826
827            success: Color::from_hex(0x64C864),
828            warning: Color::from_hex(0xFFB400),
829            error: Color::from_hex(0xFF6464),
830            info: Color::from_hex(0x6496FF),
831
832            http_get: Color::from_hex(0x9696C8),
833            http_post: Color::from_hex(0x96C896),
834            http_put: Color::from_hex(0xC8B464),
835            http_delete: Color::from_hex(0xC86464),
836            http_patch: Color::from_hex(0x64C8C8),
837            http_options: Color::from_hex(0x808080),
838            http_head: Color::from_hex(0xB496C8),
839
840            status_1xx: Color::from_hex(0x808080),
841            status_2xx: Color::from_hex(0x64C864),
842            status_3xx: Color::from_hex(0x64C8C8),
843            status_4xx: Color::from_hex(0xC8B464),
844            status_5xx: Color::from_hex(0xC86464),
845
846            border: Color::from_hex(0x646464),
847            header: Color::from_hex(0xDCDCDC),
848            muted: Color::from_hex(0x505050),
849            highlight_bg: Color::from_hex(0x1E1E1E),
850        }
851    }
852
853    /// Create a Monokai-inspired dark theme.
854    #[must_use]
855    pub fn monokai() -> Self {
856        Self {
857            primary: Color::from_hex(0xA6E22E),   // Monokai green
858            secondary: Color::from_hex(0x66D9EF), // Monokai cyan
859            accent: Color::from_hex(0xFD971F),    // Monokai orange
860
861            success: Color::from_hex(0xA6E22E),
862            warning: Color::from_hex(0xFD971F),
863            error: Color::from_hex(0xF92672), // Monokai pink/red
864            info: Color::from_hex(0x66D9EF),
865
866            http_get: Color::from_hex(0x66D9EF),
867            http_post: Color::from_hex(0xA6E22E),
868            http_put: Color::from_hex(0xFD971F),
869            http_delete: Color::from_hex(0xF92672),
870            http_patch: Color::from_hex(0xAE81FF), // Monokai purple
871            http_options: Color::from_hex(0x75715E),
872            http_head: Color::from_hex(0xAE81FF),
873
874            status_1xx: Color::from_hex(0x75715E),
875            status_2xx: Color::from_hex(0xA6E22E),
876            status_3xx: Color::from_hex(0x66D9EF),
877            status_4xx: Color::from_hex(0xFD971F),
878            status_5xx: Color::from_hex(0xF92672),
879
880            border: Color::from_hex(0x75715E),
881            header: Color::from_hex(0xF8F8F2),
882            muted: Color::from_hex(0x75715E),
883            highlight_bg: Color::from_hex(0x272822),
884        }
885    }
886
887    /// Create a theme optimized for light terminal backgrounds.
888    ///
889    /// Uses darker, more saturated colors that maintain good contrast
890    /// against white or light-colored terminal backgrounds.
891    #[must_use]
892    pub fn light() -> Self {
893        Self {
894            // Brand colors - darker for light backgrounds
895            primary: Color::from_hex(0x00796B),   // Darker teal
896            secondary: Color::from_hex(0x388E3C), // Darker green
897            accent: Color::from_hex(0xE65100),    // Darker orange
898
899            // Semantic colors - saturated for visibility
900            success: Color::from_hex(0x2E7D32), // Dark green
901            warning: Color::from_hex(0xE65100), // Dark orange
902            error: Color::from_hex(0xC62828),   // Dark red
903            info: Color::from_hex(0x1565C0),    // Dark blue
904
905            // HTTP methods - darker versions of Swagger colors
906            http_get: Color::from_hex(0x1976D2),    // Darker blue
907            http_post: Color::from_hex(0x2E7D32),   // Dark green
908            http_put: Color::from_hex(0xE65100),    // Dark orange
909            http_delete: Color::from_hex(0xC62828), // Dark red
910            http_patch: Color::from_hex(0x00838F),  // Dark cyan
911            http_options: Color::from_hex(0x616161), // Medium gray
912            http_head: Color::from_hex(0x6A1B9A),   // Dark purple
913
914            // Status codes
915            status_1xx: Color::from_hex(0x616161), // Gray
916            status_2xx: Color::from_hex(0x2E7D32), // Dark green
917            status_3xx: Color::from_hex(0x00838F), // Dark cyan
918            status_4xx: Color::from_hex(0xE65100), // Dark orange
919            status_5xx: Color::from_hex(0xC62828), // Dark red
920
921            // Structural - dark for light backgrounds
922            border: Color::from_hex(0x9E9E9E),       // Medium gray
923            header: Color::from_hex(0x212121),       // Near black
924            muted: Color::from_hex(0x757575),        // Medium gray
925            highlight_bg: Color::from_hex(0xE3F2FD), // Very light blue
926        }
927    }
928
929    /// Create a high-contrast accessible theme.
930    ///
931    /// Designed to meet WCAG 2.1 Level AA contrast requirements (4.5:1 minimum).
932    /// Uses bright, saturated colors for maximum visibility.
933    ///
934    /// # Color Choices
935    ///
936    /// - All colors have >4.5:1 contrast ratio against dark backgrounds
937    /// - Uses pure, saturated terminal colors for maximum compatibility
938    /// - Semantic colors follow universal conventions (red=error, green=success)
939    /// - Avoids colors that may be difficult for colorblind users
940    #[must_use]
941    pub fn accessible() -> Self {
942        Self {
943            // Brand colors - high contrast, saturated
944            primary: Color::from_hex(0x00FFFF),   // Bright cyan
945            secondary: Color::from_hex(0x00FF00), // Bright green
946            accent: Color::from_hex(0xFFFF00),    // Bright yellow
947
948            // Semantic colors - pure terminal colors
949            success: Color::from_hex(0x00FF00), // Bright green
950            warning: Color::from_hex(0xFFFF00), // Bright yellow
951            error: Color::from_hex(0xFF0000),   // Bright red
952            info: Color::from_hex(0x00FFFF),    // Bright cyan
953
954            // HTTP methods - distinct, high-contrast colors
955            http_get: Color::from_hex(0x00FFFF),     // Cyan
956            http_post: Color::from_hex(0x00FF00),    // Green
957            http_put: Color::from_hex(0xFFFF00),     // Yellow
958            http_delete: Color::from_hex(0xFF0000),  // Red
959            http_patch: Color::from_hex(0xFF00FF),   // Magenta
960            http_options: Color::from_hex(0xFFFFFF), // White
961            http_head: Color::from_hex(0xFF00FF),    // Magenta
962
963            // Status codes - clear semantic mapping
964            status_1xx: Color::from_hex(0xFFFFFF), // White
965            status_2xx: Color::from_hex(0x00FF00), // Green
966            status_3xx: Color::from_hex(0x00FFFF), // Cyan
967            status_4xx: Color::from_hex(0xFFFF00), // Yellow
968            status_5xx: Color::from_hex(0xFF0000), // Red
969
970            // Structural - maximum contrast
971            border: Color::from_hex(0xFFFFFF),       // White
972            header: Color::from_hex(0xFFFFFF),       // White
973            muted: Color::from_hex(0xC0C0C0),        // Silver/light gray
974            highlight_bg: Color::from_hex(0x000080), // Navy blue
975        }
976    }
977
978    // === Color Lookup Methods ===
979
980    /// Get the color for an HTTP method.
981    ///
982    /// # Example
983    ///
984    /// ```rust
985    /// use fastapi_output::themes::FastApiTheme;
986    ///
987    /// let theme = FastApiTheme::default();
988    /// let get_color = theme.http_method_color("GET");
989    /// let post_color = theme.http_method_color("post"); // case-insensitive
990    /// ```
991    #[must_use]
992    pub fn http_method_color(&self, method: &str) -> Color {
993        match method.to_uppercase().as_str() {
994            "GET" => self.http_get,
995            "POST" => self.http_post,
996            "PUT" => self.http_put,
997            "DELETE" => self.http_delete,
998            "PATCH" => self.http_patch,
999            "OPTIONS" => self.http_options,
1000            "HEAD" => self.http_head,
1001            _ => self.muted,
1002        }
1003    }
1004
1005    /// Get the color for an HTTP status code.
1006    ///
1007    /// # Example
1008    ///
1009    /// ```rust
1010    /// use fastapi_output::themes::FastApiTheme;
1011    ///
1012    /// let theme = FastApiTheme::default();
1013    /// let success_color = theme.status_code_color(200);
1014    /// let error_color = theme.status_code_color(500);
1015    /// ```
1016    #[must_use]
1017    pub fn status_code_color(&self, code: u16) -> Color {
1018        match code {
1019            100..=199 => self.status_1xx,
1020            200..=299 => self.status_2xx,
1021            300..=399 => self.status_3xx,
1022            400..=499 => self.status_4xx,
1023            500..=599 => self.status_5xx,
1024            _ => self.muted,
1025        }
1026    }
1027
1028    // === Hex String Helpers ===
1029
1030    /// Get primary color as hex string.
1031    #[must_use]
1032    pub fn primary_hex(&self) -> String {
1033        self.primary.to_hex()
1034    }
1035
1036    /// Get success color as hex string.
1037    #[must_use]
1038    pub fn success_hex(&self) -> String {
1039        self.success.to_hex()
1040    }
1041
1042    /// Get error color as hex string.
1043    #[must_use]
1044    pub fn error_hex(&self) -> String {
1045        self.error.to_hex()
1046    }
1047
1048    /// Get warning color as hex string.
1049    #[must_use]
1050    pub fn warning_hex(&self) -> String {
1051        self.warning.to_hex()
1052    }
1053
1054    /// Get info color as hex string.
1055    #[must_use]
1056    pub fn info_hex(&self) -> String {
1057        self.info.to_hex()
1058    }
1059
1060    /// Get accent color as hex string.
1061    #[must_use]
1062    pub fn accent_hex(&self) -> String {
1063        self.accent.to_hex()
1064    }
1065
1066    /// Get color for HTTP method as hex string.
1067    #[must_use]
1068    pub fn http_method_hex(&self, method: &str) -> String {
1069        self.http_method_color(method).to_hex()
1070    }
1071
1072    /// Get color for status code as hex string.
1073    #[must_use]
1074    pub fn status_code_hex(&self, code: u16) -> String {
1075        self.status_code_color(code).to_hex()
1076    }
1077}
1078
1079impl Default for FastApiTheme {
1080    fn default() -> Self {
1081        Self::fastapi()
1082    }
1083}
1084
1085/// Predefined theme presets.
1086///
1087/// Select a theme by name or environment variable.
1088#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1089pub enum ThemePreset {
1090    /// Default FastAPI-inspired theme.
1091    #[default]
1092    Default,
1093    /// Alias for Default - FastAPI-inspired theme.
1094    FastApi,
1095    /// Neon/cyberpunk high contrast theme.
1096    Neon,
1097    /// Minimal grayscale with accents.
1098    Minimal,
1099    /// Monokai dark theme.
1100    Monokai,
1101    /// Theme optimized for light terminal backgrounds.
1102    Light,
1103    /// High-contrast, WCAG-compliant accessible theme.
1104    Accessible,
1105}
1106
1107impl ThemePreset {
1108    /// Get the `FastApiTheme` for this preset.
1109    #[must_use]
1110    pub fn theme(&self) -> FastApiTheme {
1111        FastApiTheme::from_preset(*self)
1112    }
1113
1114    /// List all available preset names.
1115    #[must_use]
1116    pub fn available_presets() -> &'static [&'static str] {
1117        &[
1118            "default",
1119            "fastapi",
1120            "neon",
1121            "minimal",
1122            "monokai",
1123            "light",
1124            "accessible",
1125        ]
1126    }
1127}
1128
1129impl std::fmt::Display for ThemePreset {
1130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1131        match self {
1132            Self::Default => write!(f, "default"),
1133            Self::FastApi => write!(f, "fastapi"),
1134            Self::Neon => write!(f, "neon"),
1135            Self::Minimal => write!(f, "minimal"),
1136            Self::Monokai => write!(f, "monokai"),
1137            Self::Light => write!(f, "light"),
1138            Self::Accessible => write!(f, "accessible"),
1139        }
1140    }
1141}
1142
1143impl FromStr for ThemePreset {
1144    type Err = ThemePresetParseError;
1145
1146    fn from_str(s: &str) -> Result<Self, Self::Err> {
1147        match s.to_lowercase().as_str() {
1148            "default" | "fastapi" => Ok(Self::FastApi),
1149            "neon" | "cyberpunk" => Ok(Self::Neon),
1150            "minimal" | "gray" | "grey" => Ok(Self::Minimal),
1151            "monokai" | "dark" => Ok(Self::Monokai),
1152            "light" => Ok(Self::Light),
1153            "accessible" | "a11y" => Ok(Self::Accessible),
1154            _ => Err(ThemePresetParseError(s.to_string())),
1155        }
1156    }
1157}
1158
1159/// Error parsing theme preset name.
1160#[derive(Debug, Clone)]
1161pub struct ThemePresetParseError(String);
1162
1163impl ThemePresetParseError {
1164    /// Get the invalid preset name that was provided.
1165    #[must_use]
1166    pub fn invalid_name(&self) -> &str {
1167        &self.0
1168    }
1169}
1170
1171impl std::fmt::Display for ThemePresetParseError {
1172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1173        write!(
1174            f,
1175            "unknown theme preset '{}', available: {}",
1176            self.0,
1177            ThemePreset::available_presets().join(", ")
1178        )
1179    }
1180}
1181
1182impl std::error::Error for ThemePresetParseError {}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187
1188    fn is_not_black(c: Color) -> bool {
1189        c.r > 0 || c.g > 0 || c.b > 0
1190    }
1191
1192    // === Color Tests ===
1193
1194    #[test]
1195    fn test_color_from_hex() {
1196        let color = Color::from_hex(0xFF5500);
1197        assert_eq!(color.r, 0xFF);
1198        assert_eq!(color.g, 0x55);
1199        assert_eq!(color.b, 0x00);
1200    }
1201
1202    #[test]
1203    fn test_color_to_hex() {
1204        let color = Color::new(255, 85, 0);
1205        assert_eq!(color.to_hex(), "#ff5500");
1206    }
1207
1208    #[test]
1209    fn test_color_to_rgb() {
1210        let color = Color::new(100, 150, 200);
1211        assert_eq!(color.to_rgb(), (100, 150, 200));
1212    }
1213
1214    #[test]
1215    fn test_color_to_ansi() {
1216        let color = Color::new(255, 128, 64);
1217        assert_eq!(color.to_ansi_fg(), "\x1b[38;2;255;128;64m");
1218        assert_eq!(color.to_ansi_bg(), "\x1b[48;2;255;128;64m");
1219    }
1220
1221    // === Conversion Utility Tests ===
1222
1223    #[test]
1224    fn test_rgb_to_hex() {
1225        assert_eq!(rgb_to_hex((0, 150, 136)), "#009688");
1226        assert_eq!(rgb_to_hex((255, 255, 255)), "#ffffff");
1227        assert_eq!(rgb_to_hex((0, 0, 0)), "#000000");
1228    }
1229
1230    #[test]
1231    fn test_hex_to_rgb_6_digit() {
1232        assert_eq!(hex_to_rgb("#009688"), Some((0, 150, 136)));
1233        assert_eq!(hex_to_rgb("009688"), Some((0, 150, 136)));
1234        assert_eq!(hex_to_rgb("#FF5500"), Some((255, 85, 0)));
1235        assert_eq!(hex_to_rgb("#ffffff"), Some((255, 255, 255)));
1236    }
1237
1238    #[test]
1239    fn test_hex_to_rgb_3_digit() {
1240        assert_eq!(hex_to_rgb("#F00"), Some((255, 0, 0)));
1241        assert_eq!(hex_to_rgb("0F0"), Some((0, 255, 0)));
1242        assert_eq!(hex_to_rgb("#FFF"), Some((255, 255, 255)));
1243    }
1244
1245    #[test]
1246    fn test_hex_to_rgb_invalid() {
1247        assert_eq!(hex_to_rgb("invalid"), None);
1248        assert_eq!(hex_to_rgb("#12345"), None);
1249        assert_eq!(hex_to_rgb(""), None);
1250        assert_eq!(hex_to_rgb("#GGG"), None);
1251    }
1252
1253    // === Theme Tests ===
1254
1255    #[test]
1256    fn test_theme_default_has_all_colors() {
1257        let theme = FastApiTheme::default();
1258
1259        // Brand colors
1260        assert!(is_not_black(theme.primary));
1261        assert!(is_not_black(theme.secondary));
1262        assert!(is_not_black(theme.accent));
1263
1264        // Semantic colors
1265        assert!(is_not_black(theme.success));
1266        assert!(is_not_black(theme.warning));
1267        assert!(is_not_black(theme.error));
1268        assert!(is_not_black(theme.info));
1269
1270        // HTTP method colors
1271        assert!(is_not_black(theme.http_get));
1272        assert!(is_not_black(theme.http_post));
1273        assert!(is_not_black(theme.http_put));
1274        assert!(is_not_black(theme.http_delete));
1275    }
1276
1277    #[test]
1278    fn test_theme_presets_differ() {
1279        let fastapi = FastApiTheme::fastapi();
1280        let neon = FastApiTheme::neon();
1281        let minimal = FastApiTheme::minimal();
1282        let monokai = FastApiTheme::monokai();
1283        let light = FastApiTheme::light();
1284        let accessible = FastApiTheme::accessible();
1285
1286        assert_ne!(fastapi, neon);
1287        assert_ne!(fastapi, minimal);
1288        assert_ne!(fastapi, monokai);
1289        assert_ne!(fastapi, light);
1290        assert_ne!(fastapi, accessible);
1291        assert_ne!(neon, minimal);
1292        assert_ne!(neon, monokai);
1293        assert_ne!(neon, light);
1294        assert_ne!(neon, accessible);
1295        assert_ne!(minimal, monokai);
1296        assert_ne!(minimal, light);
1297        assert_ne!(minimal, accessible);
1298        assert_ne!(monokai, light);
1299        assert_ne!(monokai, accessible);
1300        assert_ne!(light, accessible);
1301    }
1302
1303    #[test]
1304    fn test_theme_from_preset() {
1305        assert_eq!(
1306            FastApiTheme::from_preset(ThemePreset::Default),
1307            FastApiTheme::fastapi()
1308        );
1309        assert_eq!(
1310            FastApiTheme::from_preset(ThemePreset::FastApi),
1311            FastApiTheme::fastapi()
1312        );
1313        assert_eq!(
1314            FastApiTheme::from_preset(ThemePreset::Neon),
1315            FastApiTheme::neon()
1316        );
1317        assert_eq!(
1318            FastApiTheme::from_preset(ThemePreset::Minimal),
1319            FastApiTheme::minimal()
1320        );
1321        assert_eq!(
1322            FastApiTheme::from_preset(ThemePreset::Monokai),
1323            FastApiTheme::monokai()
1324        );
1325        assert_eq!(
1326            FastApiTheme::from_preset(ThemePreset::Light),
1327            FastApiTheme::light()
1328        );
1329        assert_eq!(
1330            FastApiTheme::from_preset(ThemePreset::Accessible),
1331            FastApiTheme::accessible()
1332        );
1333    }
1334
1335    // === HTTP Method Color Tests ===
1336
1337    #[test]
1338    fn test_http_method_colors() {
1339        let theme = FastApiTheme::default();
1340
1341        assert_eq!(theme.http_method_color("GET"), theme.http_get);
1342        assert_eq!(theme.http_method_color("get"), theme.http_get);
1343        assert_eq!(theme.http_method_color("POST"), theme.http_post);
1344        assert_eq!(theme.http_method_color("PUT"), theme.http_put);
1345        assert_eq!(theme.http_method_color("DELETE"), theme.http_delete);
1346        assert_eq!(theme.http_method_color("PATCH"), theme.http_patch);
1347        assert_eq!(theme.http_method_color("OPTIONS"), theme.http_options);
1348        assert_eq!(theme.http_method_color("HEAD"), theme.http_head);
1349        assert_eq!(theme.http_method_color("UNKNOWN"), theme.muted);
1350    }
1351
1352    #[test]
1353    fn test_http_method_hex() {
1354        let theme = FastApiTheme::default();
1355        assert_eq!(theme.http_method_hex("GET"), theme.http_get.to_hex());
1356        assert_eq!(theme.http_method_hex("POST"), theme.http_post.to_hex());
1357    }
1358
1359    // === Status Code Color Tests ===
1360
1361    #[test]
1362    fn test_status_code_colors() {
1363        let theme = FastApiTheme::default();
1364
1365        assert_eq!(theme.status_code_color(100), theme.status_1xx);
1366        assert_eq!(theme.status_code_color(199), theme.status_1xx);
1367        assert_eq!(theme.status_code_color(200), theme.status_2xx);
1368        assert_eq!(theme.status_code_color(201), theme.status_2xx);
1369        assert_eq!(theme.status_code_color(301), theme.status_3xx);
1370        assert_eq!(theme.status_code_color(404), theme.status_4xx);
1371        assert_eq!(theme.status_code_color(500), theme.status_5xx);
1372        assert_eq!(theme.status_code_color(503), theme.status_5xx);
1373        assert_eq!(theme.status_code_color(600), theme.muted);
1374        assert_eq!(theme.status_code_color(99), theme.muted);
1375    }
1376
1377    #[test]
1378    fn test_status_code_hex() {
1379        let theme = FastApiTheme::default();
1380        assert_eq!(theme.status_code_hex(200), theme.status_2xx.to_hex());
1381        assert_eq!(theme.status_code_hex(500), theme.status_5xx.to_hex());
1382    }
1383
1384    // === Hex Helper Tests ===
1385
1386    #[test]
1387    fn test_hex_helpers() {
1388        let theme = FastApiTheme::default();
1389
1390        assert_eq!(theme.primary_hex(), theme.primary.to_hex());
1391        assert_eq!(theme.success_hex(), theme.success.to_hex());
1392        assert_eq!(theme.error_hex(), theme.error.to_hex());
1393        assert_eq!(theme.warning_hex(), theme.warning.to_hex());
1394        assert_eq!(theme.info_hex(), theme.info.to_hex());
1395        assert_eq!(theme.accent_hex(), theme.accent.to_hex());
1396    }
1397
1398    // === ThemePreset Tests ===
1399
1400    #[test]
1401    fn test_theme_preset_display() {
1402        assert_eq!(ThemePreset::Default.to_string(), "default");
1403        assert_eq!(ThemePreset::FastApi.to_string(), "fastapi");
1404        assert_eq!(ThemePreset::Neon.to_string(), "neon");
1405        assert_eq!(ThemePreset::Minimal.to_string(), "minimal");
1406        assert_eq!(ThemePreset::Monokai.to_string(), "monokai");
1407        assert_eq!(ThemePreset::Light.to_string(), "light");
1408        assert_eq!(ThemePreset::Accessible.to_string(), "accessible");
1409    }
1410
1411    #[test]
1412    fn test_theme_preset_from_str() {
1413        assert_eq!(
1414            "default".parse::<ThemePreset>().unwrap(),
1415            ThemePreset::FastApi
1416        );
1417        assert_eq!(
1418            "fastapi".parse::<ThemePreset>().unwrap(),
1419            ThemePreset::FastApi
1420        );
1421        assert_eq!(
1422            "FASTAPI".parse::<ThemePreset>().unwrap(),
1423            ThemePreset::FastApi
1424        );
1425        assert_eq!("neon".parse::<ThemePreset>().unwrap(), ThemePreset::Neon);
1426        assert_eq!(
1427            "cyberpunk".parse::<ThemePreset>().unwrap(),
1428            ThemePreset::Neon
1429        );
1430        assert_eq!(
1431            "minimal".parse::<ThemePreset>().unwrap(),
1432            ThemePreset::Minimal
1433        );
1434        assert_eq!("gray".parse::<ThemePreset>().unwrap(), ThemePreset::Minimal);
1435        assert_eq!("grey".parse::<ThemePreset>().unwrap(), ThemePreset::Minimal);
1436        assert_eq!(
1437            "monokai".parse::<ThemePreset>().unwrap(),
1438            ThemePreset::Monokai
1439        );
1440        assert_eq!("dark".parse::<ThemePreset>().unwrap(), ThemePreset::Monokai);
1441        assert_eq!("light".parse::<ThemePreset>().unwrap(), ThemePreset::Light);
1442        assert_eq!(
1443            "accessible".parse::<ThemePreset>().unwrap(),
1444            ThemePreset::Accessible
1445        );
1446        assert_eq!(
1447            "a11y".parse::<ThemePreset>().unwrap(),
1448            ThemePreset::Accessible
1449        );
1450    }
1451
1452    #[test]
1453    fn test_theme_preset_from_str_invalid() {
1454        let err = "invalid".parse::<ThemePreset>().unwrap_err();
1455        assert_eq!(err.invalid_name(), "invalid");
1456        assert!(err.to_string().contains("invalid"));
1457        assert!(err.to_string().contains("available"));
1458    }
1459
1460    #[test]
1461    fn test_theme_preset_theme() {
1462        assert_eq!(ThemePreset::FastApi.theme(), FastApiTheme::fastapi());
1463        assert_eq!(ThemePreset::Neon.theme(), FastApiTheme::neon());
1464        assert_eq!(ThemePreset::Light.theme(), FastApiTheme::light());
1465        assert_eq!(ThemePreset::Accessible.theme(), FastApiTheme::accessible());
1466    }
1467
1468    #[test]
1469    fn test_available_presets() {
1470        let presets = ThemePreset::available_presets();
1471        assert!(presets.contains(&"default"));
1472        assert!(presets.contains(&"fastapi"));
1473        assert!(presets.contains(&"neon"));
1474        assert!(presets.contains(&"minimal"));
1475        assert!(presets.contains(&"monokai"));
1476        assert!(presets.contains(&"light"));
1477        assert!(presets.contains(&"accessible"));
1478    }
1479
1480    // === ThemeIcons Tests ===
1481
1482    #[test]
1483    fn test_theme_icons_unicode() {
1484        let icons = ThemeIcons::unicode();
1485        assert!(!icons.success.is_empty());
1486        assert!(!icons.failure.is_empty());
1487        assert!(!icons.warning.is_empty());
1488        assert!(!icons.info.is_empty());
1489    }
1490
1491    #[test]
1492    fn test_theme_icons_ascii() {
1493        let icons = ThemeIcons::ascii();
1494        assert!(icons.success.is_ascii());
1495        assert!(icons.failure.is_ascii());
1496        assert!(icons.warning.is_ascii());
1497        assert!(icons.info.is_ascii());
1498    }
1499
1500    #[test]
1501    fn test_theme_icons_compact() {
1502        let icons = ThemeIcons::compact();
1503        assert!(!icons.success.is_empty());
1504        assert!(!icons.arrow_right.is_empty());
1505    }
1506
1507    // === ThemeSpacing Tests ===
1508
1509    #[test]
1510    fn test_theme_spacing_default() {
1511        let spacing = ThemeSpacing::default();
1512        assert!(spacing.indent > 0);
1513        assert!(spacing.method_width >= 7); // "OPTIONS" is 7 chars
1514    }
1515
1516    #[test]
1517    fn test_theme_spacing_compact() {
1518        let compact = ThemeSpacing::compact();
1519        let default = ThemeSpacing::default();
1520        assert!(compact.indent <= default.indent);
1521    }
1522
1523    #[test]
1524    fn test_theme_spacing_spacious() {
1525        let spacious = ThemeSpacing::spacious();
1526        let default = ThemeSpacing::default();
1527        assert!(spacious.indent >= default.indent);
1528    }
1529
1530    // === BoxStyle Tests ===
1531
1532    #[test]
1533    fn test_box_style_rounded() {
1534        let style = BoxStyle::rounded();
1535        assert_ne!(style.top_left, style.horizontal);
1536        assert_ne!(style.vertical, style.horizontal);
1537    }
1538
1539    #[test]
1540    fn test_box_style_ascii() {
1541        let style = BoxStyle::ascii();
1542        assert_eq!(style.top_left, '+');
1543        assert_eq!(style.horizontal, '-');
1544        assert_eq!(style.vertical, '|');
1545    }
1546
1547    #[test]
1548    fn test_box_style_horizontal_line() {
1549        let style = BoxStyle::rounded();
1550        let line = style.horizontal_line(5);
1551        assert_eq!(line.chars().count(), 5);
1552    }
1553
1554    #[test]
1555    fn test_box_style_borders() {
1556        let style = BoxStyle::rounded();
1557        let top = style.top_border(10);
1558        let bottom = style.bottom_border(10);
1559        assert!(top.starts_with(style.top_left));
1560        assert!(top.ends_with(style.top_right));
1561        assert!(bottom.starts_with(style.bottom_left));
1562        assert!(bottom.ends_with(style.bottom_right));
1563    }
1564
1565    #[test]
1566    fn test_box_style_preset_parse() {
1567        assert_eq!(
1568            "rounded".parse::<BoxStylePreset>().unwrap(),
1569            BoxStylePreset::Rounded
1570        );
1571        assert_eq!(
1572            "heavy".parse::<BoxStylePreset>().unwrap(),
1573            BoxStylePreset::Heavy
1574        );
1575        assert_eq!(
1576            "ascii".parse::<BoxStylePreset>().unwrap(),
1577            BoxStylePreset::Ascii
1578        );
1579        assert!("invalid".parse::<BoxStylePreset>().is_err());
1580    }
1581
1582    // === Color Contrast Tests ===
1583
1584    #[test]
1585    fn test_color_luminance() {
1586        let black = Color::new(0, 0, 0);
1587        let white = Color::new(255, 255, 255);
1588        assert!(black.luminance() < 0.01);
1589        assert!(white.luminance() > 0.99);
1590    }
1591
1592    #[test]
1593    fn test_color_contrast_ratio() {
1594        let black = Color::new(0, 0, 0);
1595        let white = Color::new(255, 255, 255);
1596        let ratio = black.contrast_ratio(&white);
1597        // Max contrast is 21:1
1598        assert!(ratio > 20.0);
1599        assert!(ratio <= 21.0);
1600    }
1601
1602    #[test]
1603    fn test_accessible_theme_high_contrast() {
1604        let accessible = FastApiTheme::accessible();
1605        let black = Color::new(0, 0, 0);
1606
1607        // All semantic colors should have good contrast against black background
1608        assert!(accessible.success.contrast_ratio(&black) >= 4.5);
1609        assert!(accessible.error.contrast_ratio(&black) >= 4.5);
1610        assert!(accessible.warning.contrast_ratio(&black) >= 4.5);
1611        assert!(accessible.info.contrast_ratio(&black) >= 4.5);
1612    }
1613}