Skip to main content

netspeed_cli/
theme.rs

1//! Color theme system for terminal output.
2//!
3//! Provides theme-aware coloring that adapts to different terminal backgrounds.
4//! When a theme is set, all formatters use theme-aware colors instead of
5//! hardcoded `green()`, `red()`, `cyan()` calls.
6//!
7//! ## Note
8//!
9//! Terminal environment detection (`no_color`) has been moved to the
10//! [`crate::terminal`] module.
11
12use owo_colors::OwoColorize;
13
14use crate::terminal;
15
16/// Color theme for terminal output.
17///
18/// # Example
19///
20/// ```
21/// use netspeed_cli::theme::Theme;
22///
23/// // Parse from a string name
24/// assert_eq!(Theme::from_name("dark"), Some(Theme::Dark));
25/// assert_eq!(Theme::from_name("light"), Some(Theme::Light));
26/// assert_eq!(Theme::from_name("invalid"), None);
27///
28/// // Round-trip: name() → from_name()
29/// assert_eq!(Theme::from_name(Theme::HighContrast.name()), Some(Theme::HighContrast));
30///
31/// // Default is Dark
32/// assert_eq!(Theme::default(), Theme::Dark);
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum Theme {
36    /// Default dark terminal theme (bright colors)
37    #[default]
38    Dark,
39    /// Light terminal background (darker colors for readability)
40    Light,
41    /// High contrast (bold colors, larger visual weight)
42    HighContrast,
43    /// Monochrome (no colors, bold/italic for emphasis)
44    Monochrome,
45}
46
47impl Theme {
48    /// Parse theme from string.
49    ///
50    /// Returns `Some(Theme)` for valid names (including aliases like `"mono"`
51    /// and `"highcontrast"`), or `None` for unrecognized names.
52    ///
53    /// # Example
54    ///
55    /// ```
56    /// use netspeed_cli::theme::Theme;
57    ///
58    /// // Canonical names
59    /// assert_eq!(Theme::from_name("dark"), Some(Theme::Dark));
60    /// assert_eq!(Theme::from_name("light"), Some(Theme::Light));
61    /// assert_eq!(Theme::from_name("high-contrast"), Some(Theme::HighContrast));
62    /// assert_eq!(Theme::from_name("monochrome"), Some(Theme::Monochrome));
63    ///
64    /// // Aliases
65    /// assert_eq!(Theme::from_name("mono"), Some(Theme::Monochrome));
66    /// assert_eq!(Theme::from_name("highcontrast"), Some(Theme::HighContrast));
67    ///
68    /// // Case-insensitive
69    /// assert_eq!(Theme::from_name("DARK"), Some(Theme::Dark));
70    /// assert_eq!(Theme::from_name("Light"), Some(Theme::Light));
71    ///
72    /// // Invalid names return None
73    /// assert_eq!(Theme::from_name("solarized"), None);
74    /// ```
75    #[must_use]
76    pub fn from_name(name: &str) -> Option<Self> {
77        Self::is_valid_name(name).then_some(Self::from_name_unchecked(name))
78    }
79
80    /// Check if a theme name is valid without returning the theme.
81    ///
82    /// # Example
83    ///
84    /// ```
85    /// use netspeed_cli::theme::Theme;
86    ///
87    /// // Canonical names are valid
88    /// assert!(Theme::is_valid_name("dark"));
89    /// assert!(Theme::is_valid_name("high-contrast"));
90    ///
91    /// // Aliases are also valid
92    /// assert!(Theme::is_valid_name("mono"));
93    /// assert!(Theme::is_valid_name("highcontrast"));
94    ///
95    /// // Case-insensitive
96    /// assert!(Theme::is_valid_name("DARK"));
97    ///
98    /// // Invalid names
99    /// assert!(!Theme::is_valid_name("neon"));
100    /// assert!(!Theme::is_valid_name(""));
101    /// ```
102    #[must_use]
103    pub fn is_valid_name(name: &str) -> bool {
104        matches!(
105            name.to_lowercase().as_str(),
106            "dark" | "light" | "high-contrast" | "highcontrast" | "monochrome" | "mono"
107        )
108    }
109
110    /// Internal: convert validated name to theme (assumes valid input).
111    fn from_name_unchecked(name: &str) -> Self {
112        match name.to_lowercase().as_str() {
113            "dark" => Self::Dark,
114            "light" => Self::Light,
115            "high-contrast" | "highcontrast" => Self::HighContrast,
116            "monochrome" | "mono" => Self::Monochrome,
117            _ => Self::Dark, // Safe default
118        }
119    }
120
121    /// Validate this theme name and return error message if invalid.
122    ///
123    /// Returns `Ok(())` if valid, `Err(msg)` with the list of valid options if invalid.
124    /// Use this for config-file validation where you need an error message;
125    /// use [`from_name()`](Theme::from_name) if you just need the `Theme` value.
126    ///
127    /// # Example
128    ///
129    /// ```
130    /// use netspeed_cli::theme::Theme;
131    ///
132    /// // Valid names pass validation
133    /// assert!(Theme::validate("dark").is_ok());
134    /// assert!(Theme::validate("light").is_ok());
135    /// assert!(Theme::validate("high-contrast").is_ok());
136    /// assert!(Theme::validate("monochrome").is_ok());
137    ///
138    /// // Invalid names produce a descriptive error
139    /// let err = Theme::validate("neon").unwrap_err();
140    /// assert!(err.contains("Invalid theme"));
141    /// assert!(err.contains("neon"));
142    /// assert!(err.contains("dark"));  // lists valid options
143    /// ```
144    pub fn validate(name: &str) -> Result<(), String> {
145        if Self::is_valid_name(name) {
146            Ok(())
147        } else {
148            Err(format!(
149                "Invalid theme '{}'. Valid options: {}",
150                name,
151                Self::VALID_NAMES.join(", ")
152            ))
153        }
154    }
155
156    /// Type identifier for error messages (DIP: shared validation pattern).
157    pub const TYPE_NAME: &'static str = "theme";
158
159    /// List of valid theme names for error messages.
160    pub const VALID_NAMES: &'static [&'static str] =
161        &["dark", "light", "high-contrast", "monochrome"];
162
163    /// CLI-friendly name.
164    ///
165    /// # Example
166    ///
167    /// ```
168    /// use netspeed_cli::theme::Theme;
169    ///
170    /// assert_eq!(Theme::Dark.name(), "dark");
171    /// assert_eq!(Theme::Light.name(), "light");
172    /// assert_eq!(Theme::HighContrast.name(), "high-contrast");
173    /// assert_eq!(Theme::Monochrome.name(), "monochrome");
174    /// ```
175    #[must_use]
176    pub fn name(&self) -> &'static str {
177        match self {
178            Self::Dark => "dark",
179            Self::Light => "light",
180            Self::HighContrast => "high-contrast",
181            Self::Monochrome => "monochrome",
182        }
183    }
184}
185
186/// Theme-aware color wrapper.
187///
188/// Use these instead of direct `.green()`, `.red()`, etc. to respect the active theme.
189/// Each method takes a string and a [`Theme`], returning a styled string that
190/// adapts to the theme's color palette.
191///
192/// # Example
193///
194/// ```
195/// use netspeed_cli::theme::{Colors, Theme};
196///
197/// // Monochrome always returns plain text (no ANSI escapes)
198/// assert_eq!(Colors::good("OK", Theme::Monochrome), "OK");
199/// assert_eq!(Colors::warn("caution", Theme::Monochrome), "caution");
200/// assert_eq!(Colors::bad("FAIL", Theme::Monochrome), "FAIL");
201/// assert_eq!(Colors::info("note", Theme::Monochrome), "note");
202///
203/// // Other themes add ANSI styling but always preserve the original text
204/// assert!(Colors::good("OK", Theme::Dark).contains("OK"));
205/// assert!(Colors::bad("FAIL", Theme::Light).contains("FAIL"));
206/// ```
207pub struct Colors;
208
209impl Colors {
210    /// Good/success color.
211    ///
212    /// Green in Dark/HighContrast/Light themes, plain text in Monochrome.
213    ///
214    /// # Example
215    ///
216    /// ```
217    /// use netspeed_cli::theme::{Colors, Theme};
218    ///
219    /// // Monochrome: plain text
220    /// assert_eq!(Colors::good("100 Mbps", Theme::Monochrome), "100 Mbps");
221    ///
222    /// // Dark/Light/HighContrast: styled with green (contains the text)
223    /// assert!(Colors::good("100 Mbps", Theme::Dark).contains("100 Mbps"));
224    /// assert!(Colors::good("100 Mbps", Theme::Light).contains("100 Mbps"));
225    /// ```
226    #[must_use]
227    pub fn good(s: &str, theme: Theme) -> String {
228        if terminal::no_color() || theme == Theme::Monochrome {
229            s.to_string()
230        } else {
231            match theme {
232                Theme::Dark | Theme::HighContrast => s.green().bold().to_string(),
233                Theme::Light => s.green().to_string(),
234                Theme::Monochrome => s.bold().to_string(),
235            }
236        }
237    }
238
239    /// Warning/caution color.
240    ///
241    /// Yellow in Dark/HighContrast/Light themes, plain text in Monochrome.
242    ///
243    /// # Example
244    ///
245    /// ```
246    /// use netspeed_cli::theme::{Colors, Theme};
247    ///
248    /// assert_eq!(Colors::warn("high latency", Theme::Monochrome), "high latency");
249    /// assert!(Colors::warn("high latency", Theme::Dark).contains("high latency"));
250    /// ```
251    #[must_use]
252    pub fn warn(s: &str, theme: Theme) -> String {
253        if terminal::no_color() || theme == Theme::Monochrome {
254            s.to_string()
255        } else {
256            match theme {
257                Theme::Dark | Theme::HighContrast => s.yellow().bold().to_string(),
258                Theme::Light => s.yellow().to_string(),
259                Theme::Monochrome => s.italic().to_string(),
260            }
261        }
262    }
263
264    /// Error/bad color.
265    ///
266    /// Red in Dark/HighContrast/Light themes, plain text in Monochrome.
267    ///
268    /// # Example
269    ///
270    /// ```
271    /// use netspeed_cli::theme::{Colors, Theme};
272    ///
273    /// assert_eq!(Colors::bad("FAILED", Theme::Monochrome), "FAILED");
274    /// assert!(Colors::bad("FAILED", Theme::Dark).contains("FAILED"));
275    /// ```
276    #[must_use]
277    pub fn bad(s: &str, theme: Theme) -> String {
278        if terminal::no_color() || theme == Theme::Monochrome {
279            s.to_string()
280        } else {
281            match theme {
282                Theme::Dark | Theme::HighContrast => s.red().bold().to_string(),
283                Theme::Light => s.red().to_string(),
284                Theme::Monochrome => s.bold().to_string(),
285            }
286        }
287    }
288
289    /// Info/neutral color (cyan/blue).
290    ///
291    /// Cyan in Dark/HighContrast, blue in Light, plain text in Monochrome.
292    ///
293    /// # Example
294    ///
295    /// ```
296    /// use netspeed_cli::theme::{Colors, Theme};
297    ///
298    /// assert_eq!(Colors::info("Server: 1234", Theme::Monochrome), "Server: 1234");
299    /// assert!(Colors::info("Server: 1234", Theme::Dark).contains("Server: 1234"));
300    /// assert!(Colors::info("Server: 1234", Theme::Light).contains("Server: 1234"));
301    /// ```
302    #[must_use]
303    pub fn info(s: &str, theme: Theme) -> String {
304        if terminal::no_color() || theme == Theme::Monochrome {
305            s.to_string()
306        } else {
307            match theme {
308                Theme::Dark => s.cyan().to_string(),
309                Theme::Light => s.blue().to_string(),
310                Theme::HighContrast => s.cyan().bold().to_string(),
311                Theme::Monochrome => s.italic().to_string(),
312            }
313        }
314    }
315
316    /// Dimmed/secondary text.
317    ///
318    /// Dimmed in Dark/Light, plain text in HighContrast/Monochrome
319    /// (kept readable at high contrast).
320    ///
321    /// # Example
322    ///
323    /// ```
324    /// use netspeed_cli::theme::{Colors, Theme};
325    ///
326    /// // Monochrome and HighContrast: plain text (readability over style)
327    /// assert_eq!(Colors::dimmed("secondary", Theme::Monochrome), "secondary");
328    /// assert_eq!(Colors::dimmed("secondary", Theme::HighContrast), "secondary");
329    ///
330    /// // Dark/Light: dimmed styling (contains the text)
331    /// assert!(Colors::dimmed("secondary", Theme::Dark).contains("secondary"));
332    /// assert!(Colors::dimmed("secondary", Theme::Light).contains("secondary"));
333    /// ```
334    #[must_use]
335    pub fn dimmed(s: &str, theme: Theme) -> String {
336        if terminal::no_color() || theme == Theme::Monochrome {
337            s.to_string()
338        } else {
339            match theme {
340                Theme::Dark | Theme::Light => s.dimmed().to_string(),
341                Theme::HighContrast | Theme::Monochrome => s.to_string(), // Keep readable in high contrast
342            }
343        }
344    }
345
346    /// Bold/emphasized text.
347    ///
348    /// Always applies bold regardless of theme (theme parameter reserved
349    /// for future theme-specific bold behavior).
350    ///
351    /// # Example
352    ///
353    /// ```
354    /// use netspeed_cli::theme::{Colors, Theme};
355    ///
356    /// // Bold always contains the original text
357    /// assert!(Colors::bold("important", Theme::Dark).contains("important"));
358    /// assert!(Colors::bold("important", Theme::Light).contains("important"));
359    /// assert!(Colors::bold("important", Theme::Monochrome).contains("important"));
360    /// ```
361    #[must_use]
362    pub fn bold(s: &str, _theme: Theme) -> String {
363        s.bold().to_string()
364    }
365
366    /// Muted/secondary text (`bright_black` equivalent).
367    ///
368    /// Bright black in Dark/HighContrast, dimmed in Light, plain in Monochrome.
369    ///
370    /// # Example
371    ///
372    /// ```
373    /// use netspeed_cli::theme::{Colors, Theme};
374    ///
375    /// // Monochrome: plain text
376    /// assert_eq!(Colors::muted("hint", Theme::Monochrome), "hint");
377    ///
378    /// // Other themes contain the original text
379    /// assert!(Colors::muted("hint", Theme::Dark).contains("hint"));
380    /// assert!(Colors::muted("hint", Theme::Light).contains("hint"));
381    /// assert!(Colors::muted("hint", Theme::HighContrast).contains("hint"));
382    /// ```
383    #[must_use]
384    pub fn muted(s: &str, theme: Theme) -> String {
385        if terminal::no_color() || theme == Theme::Monochrome {
386            s.to_string()
387        } else {
388            match theme {
389                Theme::Dark | Theme::HighContrast => s.bright_black().to_string(),
390                Theme::Light => s.dimmed().to_string(),
391                Theme::Monochrome => s.to_string(),
392            }
393        }
394    }
395
396    /// Header/section title color.
397    ///
398    /// Cyan+bold+underline in Dark/HighContrast, blue+bold+underline in Light,
399    /// plain text in Monochrome (no color/underline).
400    ///
401    /// # Example
402    ///
403    /// ```
404    /// use netspeed_cli::theme::{Colors, Theme};
405    ///
406    /// // Monochrome: plain text (no color/underline)
407    /// assert!(Colors::header("Results", Theme::Monochrome).contains("Results"));
408    ///
409    /// // Dark: cyan + bold + underline
410    /// assert!(Colors::header("Results", Theme::Dark).contains("Results"));
411    ///
412    /// // Light: blue + bold + underline
413    /// assert!(Colors::header("Results", Theme::Light).contains("Results"));
414    /// ```
415    #[must_use]
416    pub fn header(s: &str, theme: Theme) -> String {
417        if terminal::no_color() || theme == Theme::Monochrome {
418            s.to_string()
419        } else {
420            match theme {
421                Theme::Dark | Theme::HighContrast => s.cyan().bold().underline().to_string(),
422                Theme::Light => s.blue().bold().underline().to_string(),
423                Theme::Monochrome => s.bold().to_string(),
424            }
425        }
426    }
427}
428
429/// Resolve the active theme from config, CLI, and environment.
430///
431/// Priority order:
432/// 1. **`minimal=true`** → always [`Monochrome`](Theme::Monochrome)
433/// 2. **`NO_COLOR` env var set** → [`Monochrome`](Theme::Monochrome)
434/// 3. **Valid `config_theme`** → the matching [`Theme`]
435/// 4. **Invalid `config_theme`** → [`Dark`](Theme::Dark) (default)
436///
437/// # Example
438///
439/// ```
440/// use netspeed_cli::theme::{Theme, resolve};
441///
442/// // minimal=true always forces Monochrome (deterministic)
443/// assert_eq!(resolve("dark", true), Theme::Monochrome);
444/// assert_eq!(resolve("light", true), Theme::Monochrome);
445/// assert_eq!(resolve("high-contrast", true), Theme::Monochrome);
446/// ```
447///
448/// ```ignore
449/// // These depend on NO_COLOR not being set in the environment:
450/// assert_eq!(resolve("dark", false), Theme::Dark);
451/// assert_eq!(resolve("light", false), Theme::Light);
452/// assert_eq!(resolve("high-contrast", false), Theme::HighContrast);
453/// assert_eq!(resolve("monochrome", false), Theme::Monochrome);
454///
455/// // Invalid theme falls back to Dark (the default)
456/// assert_eq!(resolve("invalid", false), Theme::Dark);
457/// ```
458#[must_use]
459pub fn resolve(config_theme: &str, minimal: bool) -> Theme {
460    if minimal || terminal::no_color() {
461        return Theme::Monochrome;
462    }
463    Theme::from_name(config_theme).unwrap_or_default()
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_is_valid_name() {
472        assert!(Theme::is_valid_name("dark"));
473        assert!(Theme::is_valid_name("DARK"));
474        assert!(Theme::is_valid_name("high-contrast"));
475        assert!(Theme::is_valid_name("monochrome"));
476        // Aliases
477        assert!(Theme::is_valid_name("mono")); // alias for monochrome
478        assert!(Theme::is_valid_name("highcontrast")); // alias without hyphen
479        assert!(!Theme::is_valid_name("invalid"));
480    }
481
482    #[test]
483    fn test_validate_valid() {
484        assert!(Theme::validate("dark").is_ok());
485        assert!(Theme::validate("light").is_ok());
486        assert!(Theme::validate("high-contrast").is_ok());
487    }
488
489    #[test]
490    fn test_validate_invalid() {
491        let result = Theme::validate("invalid");
492        assert!(result.is_err());
493        let err = result.unwrap_err();
494        assert!(err.contains("Invalid theme"));
495        assert!(err.contains("valid"));
496    }
497
498    #[test]
499    fn test_theme_from_name() {
500        assert!(Theme::from_name("dark").is_some());
501        assert!(Theme::from_name("light").is_some());
502        assert!(Theme::from_name("high-contrast").is_some());
503        assert!(Theme::from_name("highcontrast").is_some());
504        assert!(Theme::from_name("monochrome").is_some());
505        assert!(Theme::from_name("mono").is_some());
506        assert!(Theme::from_name("invalid").is_none());
507    }
508
509    #[test]
510    fn test_theme_name_roundtrip() {
511        for theme in [
512            Theme::Dark,
513            Theme::Light,
514            Theme::HighContrast,
515            Theme::Monochrome,
516        ] {
517            assert_eq!(Theme::from_name(theme.name()), Some(theme));
518        }
519    }
520
521    #[test]
522    fn test_resolve_theme_minimal() {
523        assert_eq!(resolve("dark", true), Theme::Monochrome);
524        assert_eq!(resolve("light", true), Theme::Monochrome);
525    }
526
527    #[test]
528    fn test_resolve_theme_default() {
529        if terminal::no_color() {
530            return;
531        }
532        assert_eq!(resolve("dark", false), Theme::Dark);
533        assert_eq!(resolve("invalid", false), Theme::Dark);
534        assert_eq!(resolve("light", false), Theme::Light);
535        assert_eq!(resolve("high-contrast", false), Theme::HighContrast);
536    }
537}