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}