diff --git a/README.md b/README.md index 2b8e3fe..58411d4 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,14 @@ Rust. - Support for basic colors, bright colors, and background colors - Text styling (bold, dim, italic, underline, inverse, strikethrough) - RGB and HEX color support for both text and background -- Style chaining +- Composed style chaining with predictable override behavior - Works with string literals, owned strings, and format macros - Zero dependencies - Supports the `NO_COLOR` environment variable - if this is set, all colors are disabled and the text is returned uncolored +- Supports explicit runtime color modes: `Auto`, `Always`, and `Never` - Detects if the output is NOT going to a terminal (e.g. is going to a file or a - pipe) and disables colors if so (this check can also be disabled) + pipe) and disables colors in `Auto` mode - Complete documentation and examples ## Installation @@ -62,7 +63,10 @@ println!("{}", "Italic blue on yellow".blue().italic().on_yellow()); // Using with format! macro let name = "World"; -println!("{}", format!("Hello, {}!", name.blue().bold())); +println!("Hello, {}!", name.blue().bold()); + +// Removing all styles +println!("{}", "Back to plain text".red().bold().clear()); ``` ## Available Methods @@ -130,8 +134,8 @@ println!("{}", format!("Hello, {}!", name.blue().bold())); - RGB values must be in range 0-255 (enforced at compile time via `u8` type) - Attempting to use RGB values > 255 will result in a compile error - Hex color codes can be provided with or without the '#' prefix -- Invalid hex codes (wrong length, invalid characters) will result in uncolored - text +- Invalid hex codes (wrong length, invalid characters) will result in plain + unstyled text - All color methods are guaranteed to return a valid string, never panicking ```rust @@ -169,32 +173,30 @@ std::env::set_var("NO_COLOR", "1"); println!("{}", "Red text".red()); // Prints without color ``` -## Terminal Detection Configuration +## Runtime Color Modes -By default, this library checks if the output is going to a terminal and -disables colors when it's not (e.g., when piping output to a file). This -behavior can be controlled using `ColorizeConfig`: +By default, this library uses `ColorMode::Auto`: it checks if stdout is going to +a terminal and disables colors when it is not. Applications can override that +behavior explicitly using `ColorizeConfig`: ```rust -use colored_text::{Colorize, ColorizeConfig}; +use colored_text::{ColorMode, Colorize, ColorizeConfig}; -// Disable terminal detection (colors will be enabled regardless of terminal status) -ColorizeConfig::set_terminal_check(false); +ColorizeConfig::set_color_mode(ColorMode::Always); println!("{}", "Always colored".red()); -// Re-enable terminal detection (default behavior) -ColorizeConfig::set_terminal_check(true); -println!("{}", "Only colored in terminal".red()); +ColorizeConfig::set_color_mode(ColorMode::Never); +println!("{}", "Never colored".red()); + +ColorizeConfig::set_color_mode(ColorMode::Auto); +println!("{}", "Colored only in terminals".red()); ``` -This is particularly useful in test environments where you might want to -force-enable colors regardless of the terminal status. The configuration is -thread-local, making it safe to use in parallel tests without affecting other -threads. +The runtime configuration is thread-local. This is useful in tests or +applications that want to force color on or off for a specific execution path. -Note: Even when terminal detection is disabled, the `NO_COLOR` environment -variable still takes precedence - if it's set, colors will be disabled -regardless of this setting. +`NO_COLOR` still takes precedence in `Auto` and `Always` mode. If `NO_COLOR` is +set, output is plain text. ## Terminal Compatibility diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..83f661e --- /dev/null +++ b/TODO.md @@ -0,0 +1,8 @@ +# TODO + +- Add a writer-aware rendering API so `ColorMode::Auto` can use the actual + output target instead of always consulting `stdout()`. +- Revisit whether to add `rust-version` to `Cargo.toml` once we want to commit + to an explicit MSRV policy. +- Support 3-character shorthand hex colors like `#f80` in addition to 6-digit + hex input. diff --git a/examples/basic.rs b/examples/basic.rs index a82f887..70f45cd 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,4 +1,4 @@ -use colored_text::Colorize; +use colored_text::{ColorMode, Colorize, ColorizeConfig}; fn main() { // Basic colors @@ -62,7 +62,7 @@ fn main() { // Using with format! macro println!("\nUsing with format! macro:"); let name = "World"; - println!("{}", format!("Hello, {}!", name.blue().bold())); + println!("Hello, {}!", name.blue().bold()); // Using with String println!("\nUsing with String:"); @@ -79,9 +79,11 @@ fn main() { "important".yellow().underline() ); - // Disabling colors - println!("\nDisabling colors by setting NO_COLOR environment variable:"); - std::env::set_var("NO_COLOR", "1"); - println!("{}", "This text should have no color".red().bold()); - std::env::remove_var("NO_COLOR"); + // Runtime color modes + println!("\nRuntime color modes:"); + ColorizeConfig::set_color_mode(ColorMode::Always); + println!("{}", "Forced color".red().bold()); + ColorizeConfig::set_color_mode(ColorMode::Never); + println!("{}", "Forced plain output".red().bold()); + ColorizeConfig::set_color_mode(ColorMode::Auto); } diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..eaf17c6 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,125 @@ +/// Convert HSL color values to RGB. +/// +/// - `h`: Hue in degrees +/// - `s`: Saturation percentage +/// - `l`: Lightness percentage +pub(crate) fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { + let h = h / 360.0; + let s = s / 100.0; + let l = l / 100.0; + + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - ((h * 6.0) % 2.0 - 1.0).abs()); + let m = l - c / 2.0; + + let (r, g, b) = match (h * 6.0) as i32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + + ( + ((r + m) * 255.0) as u8, + ((g + m) * 255.0) as u8, + ((b + m) * 255.0) as u8, + ) +} + +pub(crate) fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return None; + } + + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + + Some((r, g, b)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum NamedColor { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + BrightRed, + BrightGreen, + BrightYellow, + BrightBlue, + BrightMagenta, + BrightCyan, + BrightWhite, +} + +impl NamedColor { + fn foreground_code(self) -> &'static str { + match self { + Self::Black => "30", + Self::Red => "31", + Self::Green => "32", + Self::Yellow => "33", + Self::Blue => "34", + Self::Magenta => "35", + Self::Cyan => "36", + Self::White => "37", + Self::BrightRed => "91", + Self::BrightGreen => "92", + Self::BrightYellow => "93", + Self::BrightBlue => "94", + Self::BrightMagenta => "95", + Self::BrightCyan => "96", + Self::BrightWhite => "97", + } + } + + fn background_code(self) -> &'static str { + match self { + Self::Black => "40", + Self::Red => "41", + Self::Green => "42", + Self::Yellow => "43", + Self::Blue => "44", + Self::Magenta => "45", + Self::Cyan => "46", + Self::White => "47", + Self::BrightRed => "101", + Self::BrightGreen => "102", + Self::BrightYellow => "103", + Self::BrightBlue => "104", + Self::BrightMagenta => "105", + Self::BrightCyan => "106", + Self::BrightWhite => "107", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ColorSpec { + Named(NamedColor), + Rgb(u8, u8, u8), +} + +impl ColorSpec { + pub(crate) fn foreground_code(&self) -> String { + match self { + Self::Named(color) => color.foreground_code().to_string(), + Self::Rgb(r, g, b) => format!("38;2;{};{};{}", r, g, b), + } + } + + pub(crate) fn background_code(&self) -> String { + match self { + Self::Named(color) => color.background_code().to_string(), + Self::Rgb(r, g, b) => format!("48;2;{};{};{}", r, g, b), + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..269281c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,101 @@ +use std::cell::RefCell; +use std::io::IsTerminal; + +/// Runtime color policy for rendered output. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ColorMode { + /// Enable styling only when stdout is a terminal. + #[default] + Auto, + /// Always emit styling, even when stdout is not a terminal. + /// + /// `NO_COLOR` still takes precedence and disables styled output. + Always, + /// Never emit styling. + Never, +} + +/// Configuration for controlling runtime color behavior. +/// +/// The active configuration is stored per thread. This makes it straightforward +/// to force a specific color mode in tests or narrow execution paths without +/// changing global process state. +#[derive(Clone, Debug)] +pub struct ColorizeConfig { + color_mode: ColorMode, +} + +thread_local! { + static CONFIG: RefCell = RefCell::new(ColorizeConfig::default()); + #[cfg(test)] + static TERMINAL_OVERRIDE: RefCell> = RefCell::new(None); +} + +impl Default for ColorizeConfig { + fn default() -> Self { + Self { + color_mode: ColorMode::Auto, + } + } +} + +impl ColorizeConfig { + /// Set the runtime color policy for the current thread. + /// + /// In [`ColorMode::Auto`], styling is emitted only when stdout is a + /// terminal. In [`ColorMode::Always`], styling is emitted regardless of + /// terminal detection. In [`ColorMode::Never`], styling is disabled. + pub fn set_color_mode(mode: ColorMode) { + CONFIG.with(|config| config.borrow_mut().color_mode = mode); + } + + /// Get the runtime color policy for the current thread. + pub fn color_mode() -> ColorMode { + CONFIG.with(|config| config.borrow().color_mode) + } + + /// Compatibility shim for the previous API. + /// + /// `true` maps to [`ColorMode::Auto`], and `false` maps to + /// [`ColorMode::Always`]. + #[deprecated(note = "use ColorizeConfig::set_color_mode(ColorMode) instead")] + pub fn set_terminal_check(check: bool) { + let mode = if check { + ColorMode::Auto + } else { + ColorMode::Always + }; + Self::set_color_mode(mode); + } +} + +/// Evaluate the current runtime color policy for this thread. +/// +/// This respects [`ColorizeConfig::color_mode()`], and `NO_COLOR` takes +/// precedence over both [`ColorMode::Auto`] and [`ColorMode::Always`]. +pub(crate) fn should_colorize() -> bool { + match ColorizeConfig::color_mode() { + ColorMode::Never => false, + ColorMode::Always => std::env::var_os("NO_COLOR").is_none(), + ColorMode::Auto => std::env::var_os("NO_COLOR").is_none() && stdout_is_terminal(), + } +} + +fn stdout_is_terminal() -> bool { + #[cfg(test)] + if let Some(value) = TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) { + return value; + } + + std::io::stdout().is_terminal() +} + +#[cfg(test)] +pub(crate) fn set_terminal_override_for_tests(value: Option) { + TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow_mut() = value); +} + +#[cfg(test)] +pub(crate) fn get_terminal_override_for_tests() -> Option { + TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) +} diff --git a/src/lib.rs b/src/lib.rs index dffb285..ddafd4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,13 @@ //! A library for adding colors and styles to terminal text output. //! -//! This library provides a simple and intuitive way to add colors and styles to text -//! in terminal applications. It works with both string literals and owned strings, -//! and supports various text colors, background colors, and text styles. +//! This library provides a simple and intuitive way to add colors and styles to +//! text in terminal applications. It works with both string literals and owned +//! strings, and supports various text colors, background colors, and text +//! styles. +//! +//! Styling is composed before rendering, so chained calls behave predictably: +//! the most recent foreground/background color wins, text styles accumulate, and +//! ANSI escape codes are emitted only once when the styled value is displayed. //! //! # Examples //! @@ -23,6 +28,9 @@ //! // RGB and Hex colors //! println!("{}", "RGB color".rgb(255, 128, 0)); //! println!("{}", "Hex color".hex("#ff8000")); +//! +//! // Clearing styles +//! println!("{}", "Plain text".red().bold().clear()); //! ``` //! //! # Features @@ -31,16 +39,18 @@ //! - Background colors //! - Bright color variants //! - Text styles (bold, dim, italic, underline) -//! - RGB and Hex color support -//! - Style chaining +//! - RGB, HSL, and Hex color support +//! - Composed style chaining //! - Works with format! macro +//! - Explicit runtime color modes //! //! # Input Handling //! //! - RGB values must be in range 0-255 (enforced at compile time via `u8` type) //! - Attempting to use RGB values > 255 will result in a compile error -//! - Hex color codes can be provided with or without the '#' prefix -//! - Invalid hex codes (wrong length, invalid characters) will result in uncolored text +//! - Hex color codes can be provided with or without the `#` prefix +//! - Invalid hex codes (wrong length or invalid characters) return plain +//! unstyled text //! - All color methods are guaranteed to return a valid string, never panicking //! //! ```rust @@ -50,763 +60,45 @@ //! println!("{}", "Valid hex".hex("#ff8000")); //! println!("{}", "Also valid".hex("ff8000")); //! -//! // Invalid hex codes return uncolored text -//! println!("{}", "Invalid hex".hex("xyz")); // Returns uncolored text -//! println!("{}", "Too short".hex("#f8")); // Returns uncolored text +//! // Invalid hex codes return plain text +//! println!("{}", "Invalid hex".hex("xyz")); // Returns plain text +//! println!("{}", "Too short".hex("#f8")); // Returns plain text +//! ``` +//! +//! # Runtime Color Control +//! +//! The crate supports three runtime modes via [`ColorMode`]: +//! +//! - [`ColorMode::Auto`] enables styling only when stdout is a terminal +//! - [`ColorMode::Always`] forces styling on even when stdout is not a terminal +//! - [`ColorMode::Never`] disables styling completely +//! +//! The `NO_COLOR` environment variable disables styling in `Auto` and +//! `Always`. +//! +//! ```rust +//! use colored_text::{ColorMode, Colorize, ColorizeConfig}; +//! +//! ColorizeConfig::set_color_mode(ColorMode::Always); +//! println!("{}", "Always colored".red()); +//! +//! ColorizeConfig::set_color_mode(ColorMode::Never); +//! println!("{}", "Never colored".red()); //! ``` //! //! # Note //! -//! Colors and styles are implemented using ANSI escape codes, which are supported -//! by most modern terminals. If your terminal doesn't support ANSI escape codes, -//! the text will be displayed without styling. - -use std::cell::RefCell; -use std::io::IsTerminal; - -/// Configuration for controlling terminal detection behavior. -#[derive(Clone, Debug)] -pub struct ColorizeConfig { - check_terminal: bool, -} - -thread_local! { - static CONFIG: RefCell = RefCell::new(ColorizeConfig::default()); -} - -impl Default for ColorizeConfig { - fn default() -> Self { - Self { - check_terminal: true, // By default, we check the terminal - } - } -} - -impl ColorizeConfig { - /// Set whether to check if output is to a terminal. - /// - /// - If true (default), colors will be disabled when not outputting to a terminal - /// - If false, terminal detection is skipped and colors are enabled (unless NO_COLOR is set) - pub fn set_terminal_check(check: bool) { - CONFIG.with(|c| c.borrow_mut().check_terminal = check); - } - - /// Get the current configuration for this thread - fn current() -> Self { - CONFIG.with(|c| c.borrow().clone()) - } -} - -/// Check if colors should be applied based on: -/// - NO_COLOR environment variable (returns false if set to any value) -/// - Whether stdout is connected to a terminal (if terminal checking is enabled) -/// -/// Terminal checking can be disabled using `ColorizeConfig::set_terminal_check(false)`, -/// in which case colors will be enabled regardless of terminal status (unless NO_COLOR is set). -fn should_colorize() -> bool { - // Always check NO_COLOR env var - if std::env::var("NO_COLOR").is_ok() { - return false; - } - - // Only check terminal if configured to do so - !ColorizeConfig::current().check_terminal || std::io::stdout().is_terminal() -} - -/// Convert HSL color values to RGB. -/// - h: Hue (0-360 degrees) -/// - s: Saturation (0-100 percent) -/// - l: Lightness (0-100 percent) -fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { - // Normalize to 0-1 - let h = h / 360.0; - let s = s / 100.0; - let l = l / 100.0; - - // Calculate intermediate values - let c = (1.0 - (2.0 * l - 1.0).abs()) * s; - let x = c * (1.0 - ((h * 6.0) % 2.0 - 1.0).abs()); - let m = l - c / 2.0; - - // Convert to RGB based on hue segment - let (r, g, b) = match (h * 6.0) as i32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - - // Convert to 0-255 range - ( - ((r + m) * 255.0) as u8, - ((g + m) * 255.0) as u8, - ((b + m) * 255.0) as u8, - ) -} - -/// Helper function to convert a hex color string to RGB values. -/// Returns None for invalid hex codes: -/// - Must be 6 characters (not counting optional # prefix) -/// - Must contain valid hex digits (0-9, a-f, A-F) -/// - Invalid hex codes will return None, resulting in uncolored text -fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> { - let hex = hex.trim_start_matches('#'); - if hex.len() != 6 { - return None; - } - - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - - Some((r, g, b)) -} - -/// Trait for adding color and style methods to strings. -/// -/// This trait provides methods to colorize and style text for terminal output. -/// It can be used with both string literals and owned strings. -pub trait Colorize { - /// Returns a colored version of the string - /// Internal method to apply ANSI color codes to text. - /// This is used by all other coloring methods. - fn colorize(&self, color_code: &str) -> String; - - // Basic colors - /// Colors the text red using ANSI escape codes. - fn red(&self) -> String; - /// Colors the text green using ANSI escape codes. - fn green(&self) -> String; - /// Colors the text yellow using ANSI escape codes. - fn yellow(&self) -> String; - /// Colors the text blue using ANSI escape codes. - fn blue(&self) -> String; - /// Colors the text magenta using ANSI escape codes. - fn magenta(&self) -> String; - /// Colors the text cyan using ANSI escape codes. - fn cyan(&self) -> String; - /// Colors the text white using ANSI escape codes. - fn white(&self) -> String; - /// Colors the text black using ANSI escape codes. - fn black(&self) -> String; - - // Bright colors - /// Colors the text bright red using ANSI escape codes. - fn bright_red(&self) -> String; - /// Colors the text bright green using ANSI escape codes. - fn bright_green(&self) -> String; - /// Colors the text bright yellow using ANSI escape codes. - fn bright_yellow(&self) -> String; - /// Colors the text bright blue using ANSI escape codes. - fn bright_blue(&self) -> String; - /// Colors the text bright magenta using ANSI escape codes. - fn bright_magenta(&self) -> String; - /// Colors the text bright cyan using ANSI escape codes. - fn bright_cyan(&self) -> String; - /// Colors the text bright white using ANSI escape codes. - fn bright_white(&self) -> String; - - // Styles - /// Makes the text bold using ANSI escape codes. - fn bold(&self) -> String; - /// Makes the text dimmed using ANSI escape codes. - fn dim(&self) -> String; - /// Makes the text italic using ANSI escape codes. - /// Note: Not all terminals support italic text. - fn italic(&self) -> String; - /// Underlines the text using ANSI escape codes. - fn underline(&self) -> String; - /// Inverts the text and background colors using ANSI escape codes. - fn inverse(&self) -> String; - /// Adds a strikethrough to the text using ANSI escape codes. - /// Note: Not all terminals support strikethrough. - fn strikethrough(&self) -> String; - - // Background colors - /// Sets the background color to red using ANSI escape codes. - fn on_red(&self) -> String; - /// Sets the background color to green using ANSI escape codes. - fn on_green(&self) -> String; - /// Sets the background color to yellow using ANSI escape codes. - fn on_yellow(&self) -> String; - /// Sets the background color to blue using ANSI escape codes. - fn on_blue(&self) -> String; - /// Sets the background color to magenta using ANSI escape codes. - fn on_magenta(&self) -> String; - /// Sets the background color to cyan using ANSI escape codes. - fn on_cyan(&self) -> String; - /// Sets the background color to white using ANSI escape codes. - fn on_white(&self) -> String; - /// Sets the background color to black using ANSI escape codes. - fn on_black(&self) -> String; - - // RGB, HSL, and Hex color support - /// Set text color using RGB values (0-255, compile-time enforced) - fn rgb(&self, r: u8, g: u8, b: u8) -> String; - /// Set background color using RGB values (0-255, compile-time enforced) - fn on_rgb(&self, r: u8, g: u8, b: u8) -> String; - /// Set text color using HSL values (hue: 0-360, saturation: 0-100, lightness: 0-100) - fn hsl(&self, h: f32, s: f32, l: f32) -> String; - /// Set background color using HSL values (hue: 0-360, saturation: 0-100, lightness: 0-100) - fn on_hsl(&self, h: f32, s: f32, l: f32) -> String; - /// Set text color using hex code (e.g., "#ff8000" or "ff8000"). - /// Returns uncolored text if the hex code is invalid. - fn hex(&self, hex: &str) -> String; - /// Set background color using hex code (e.g., "#ff8000" or "ff8000"). - /// Returns uncolored text if the hex code is invalid. - fn on_hex(&self, hex: &str) -> String; - - /// Removes all color and style formatting from the text. - fn clear(&self) -> String; -} - -impl Colorize for T { - fn colorize(&self, color_code: &str) -> String { - if !should_colorize() { - return self.to_string(); - } - format!("\x1b[{}m{}\x1b[0m", color_code, self) - } - - fn red(&self) -> String { - self.colorize("31") - } - fn green(&self) -> String { - self.colorize("32") - } - fn yellow(&self) -> String { - self.colorize("33") - } - fn blue(&self) -> String { - self.colorize("34") - } - fn magenta(&self) -> String { - self.colorize("35") - } - fn cyan(&self) -> String { - self.colorize("36") - } - fn white(&self) -> String { - self.colorize("37") - } - fn black(&self) -> String { - self.colorize("30") - } - - fn bright_red(&self) -> String { - self.colorize("91") - } - fn bright_green(&self) -> String { - self.colorize("92") - } - fn bright_yellow(&self) -> String { - self.colorize("93") - } - fn bright_blue(&self) -> String { - self.colorize("94") - } - fn bright_magenta(&self) -> String { - self.colorize("95") - } - fn bright_cyan(&self) -> String { - self.colorize("96") - } - fn bright_white(&self) -> String { - self.colorize("97") - } - - fn bold(&self) -> String { - self.colorize("1") - } - fn dim(&self) -> String { - self.colorize("2") - } - fn italic(&self) -> String { - self.colorize("3") - } - fn underline(&self) -> String { - self.colorize("4") - } - - fn inverse(&self) -> String { - self.colorize("7") - } - - fn strikethrough(&self) -> String { - self.colorize("9") - } - - fn on_red(&self) -> String { - self.colorize("41") - } - fn on_green(&self) -> String { - self.colorize("42") - } - fn on_yellow(&self) -> String { - self.colorize("43") - } - fn on_blue(&self) -> String { - self.colorize("44") - } - fn on_magenta(&self) -> String { - self.colorize("45") - } - fn on_cyan(&self) -> String { - self.colorize("46") - } - fn on_white(&self) -> String { - self.colorize("47") - } - fn on_black(&self) -> String { - self.colorize("40") - } - - fn rgb(&self, r: u8, g: u8, b: u8) -> String { - if !should_colorize() { - return self.to_string(); - } - format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, self) - } +//! Colors and styles are implemented using ANSI escape codes, which are +//! supported by most modern terminals. If your terminal does not support ANSI +//! escape codes, or if color output is disabled by policy, the text is +//! displayed without styling. - fn on_rgb(&self, r: u8, g: u8, b: u8) -> String { - if !should_colorize() { - return self.to_string(); - } - format!("\x1b[48;2;{};{};{}m{}\x1b[0m", r, g, b, self) - } - - fn hsl(&self, h: f32, s: f32, l: f32) -> String { - if !should_colorize() { - return self.to_string(); - } - let (r, g, b) = hsl_to_rgb(h, s, l); - self.rgb(r, g, b) - } - - fn on_hsl(&self, h: f32, s: f32, l: f32) -> String { - if !should_colorize() { - return self.to_string(); - } - let (r, g, b) = hsl_to_rgb(h, s, l); - self.on_rgb(r, g, b) - } - - fn hex(&self, hex: &str) -> String { - if !should_colorize() { - return self.to_string(); - } - if let Some((r, g, b)) = hex_to_rgb(hex) { - self.rgb(r, g, b) - } else { - self.clear() // Return uncolored text if hex code is invalid - } - } - - fn on_hex(&self, hex: &str) -> String { - if !should_colorize() { - return self.to_string(); - } - if let Some((r, g, b)) = hex_to_rgb(hex) { - self.on_rgb(r, g, b) - } else { - self.clear() // Return uncolored text if hex code is invalid - } - } - - fn clear(&self) -> String { - format!("\x1b[0m{}\x1b[0m", self) - } -} +mod color; +mod config; +mod style; #[cfg(test)] -mod tests { - use super::*; - use rstest::*; - - /// Disables terminal checks for color support during testing. - /// - /// This function is used in tests to ensure that color codes are always - /// generated, regardless of whether the output is going to a terminal or - /// not. This allows us to verify the exact ANSI escape sequences that would - /// be generated under normal circumstances. This is needed since 'nextest' - /// at least seems to grab the output and so the terminal check would always - /// return false. - /// - /// # Example - /// ``` - /// #[test] - /// fn test_colors() { - /// no_terminal_check(); - /// assert_eq!("test".red(), "\x1b[31mtest\x1b[0m"); - /// } - /// ``` - fn no_terminal_check() { - ColorizeConfig::set_terminal_check(false); - } - - // Test data for basic colors - #[rstest] - #[case("red", "31")] - #[case("green", "32")] - #[case("yellow", "33")] - #[case("blue", "34")] - #[case("magenta", "35")] - #[case("cyan", "36")] - #[case("white", "37")] - #[case("black", "30")] - fn test_basic_colors(#[case] color: &str, #[case] code: &str) { - no_terminal_check(); - let text = "test"; - let expected = format!("\x1b[{}m{}\x1b[0m", code, text); - match color { - "red" => assert_eq!(text.red(), expected), - "green" => assert_eq!(text.green(), expected), - "yellow" => assert_eq!(text.yellow(), expected), - "blue" => assert_eq!(text.blue(), expected), - "magenta" => assert_eq!(text.magenta(), expected), - "cyan" => assert_eq!(text.cyan(), expected), - "white" => assert_eq!(text.white(), expected), - "black" => assert_eq!(text.black(), expected), - _ => unreachable!(), - } - } - - // Test data for bright colors - #[rstest] - #[case("bright_red", "91")] - #[case("bright_green", "92")] - #[case("bright_yellow", "93")] - #[case("bright_blue", "94")] - #[case("bright_magenta", "95")] - #[case("bright_cyan", "96")] - #[case("bright_white", "97")] - fn test_bright_colors(#[case] color: &str, #[case] code: &str) { - no_terminal_check(); - let text = "test"; - let expected = format!("\x1b[{}m{}\x1b[0m", code, text); - match color { - "bright_red" => assert_eq!(text.bright_red(), expected), - "bright_green" => assert_eq!(text.bright_green(), expected), - "bright_yellow" => assert_eq!(text.bright_yellow(), expected), - "bright_blue" => assert_eq!(text.bright_blue(), expected), - "bright_magenta" => assert_eq!(text.bright_magenta(), expected), - "bright_cyan" => assert_eq!(text.bright_cyan(), expected), - "bright_white" => assert_eq!(text.bright_white(), expected), - _ => unreachable!(), - } - } - - // Test data for background colors - #[rstest] - #[case("on_red", "41")] - #[case("on_green", "42")] - #[case("on_yellow", "43")] - #[case("on_blue", "44")] - #[case("on_magenta", "45")] - #[case("on_cyan", "46")] - #[case("on_white", "47")] - #[case("on_black", "40")] - fn test_background_colors(#[case] color: &str, #[case] code: &str) { - no_terminal_check(); - let text = "test"; - let expected = format!("\x1b[{}m{}\x1b[0m", code, text); - match color { - "on_red" => assert_eq!(text.on_red(), expected), - "on_green" => assert_eq!(text.on_green(), expected), - "on_yellow" => assert_eq!(text.on_yellow(), expected), - "on_blue" => assert_eq!(text.on_blue(), expected), - "on_magenta" => assert_eq!(text.on_magenta(), expected), - "on_cyan" => assert_eq!(text.on_cyan(), expected), - "on_white" => assert_eq!(text.on_white(), expected), - "on_black" => assert_eq!(text.on_black(), expected), - _ => unreachable!(), - } - } - - // Test data for styles - #[rstest] - #[case("bold", "1")] - #[case("dim", "2")] - #[case("italic", "3")] - #[case("underline", "4")] - #[case("inverse", "7")] - #[case("strikethrough", "9")] - fn test_styles(#[case] style: &str, #[case] code: &str) { - no_terminal_check(); - let text = "test"; - let expected = format!("\x1b[{}m{}\x1b[0m", code, text); - match style { - "bold" => assert_eq!(text.bold(), expected), - "dim" => assert_eq!(text.dim(), expected), - "italic" => assert_eq!(text.italic(), expected), - "underline" => assert_eq!(text.underline(), expected), - "inverse" => assert_eq!(text.inverse(), expected), - "strikethrough" => assert_eq!(text.strikethrough(), expected), - _ => unreachable!(), - } - } - - // Test RGB colors with various values - #[rstest] - #[case(255, 128, 0)] - #[case(0, 255, 0)] - #[case(128, 128, 128)] - #[case(0, 0, 0)] - #[case(255, 255, 255)] - fn test_rgb_colors(#[case] r: u8, #[case] g: u8, #[case] b: u8) { - no_terminal_check(); - let text = "test"; - assert_eq!( - text.rgb(r, g, b), - format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) - ); - assert_eq!( - text.on_rgb(r, g, b), - format!("\x1b[48;2;{};{};{}m{}\x1b[0m", r, g, b, text) - ); - } - - // Test hex colors with various values - #[rstest] - #[case("#ff8000", 255, 128, 0)] - #[case("#00ff00", 0, 255, 0)] - #[case("#808080", 128, 128, 128)] - #[case("#000000", 0, 0, 0)] - #[case("#ffffff", 255, 255, 255)] - fn test_hex_colors(#[case] hex: &str, #[case] r: u8, #[case] g: u8, #[case] b: u8) { - no_terminal_check(); - let text = "test"; - assert_eq!( - text.hex(hex), - format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) - ); - assert_eq!( - text.on_hex(hex), - format!("\x1b[48;2;{};{};{}m{}\x1b[0m", r, g, b, text) - ); - - // Test without # prefix - let hex_no_hash = hex.trim_start_matches('#'); - assert_eq!( - text.hex(hex_no_hash), - format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) - ); - assert_eq!( - text.on_hex(hex_no_hash), - format!("\x1b[48;2;{};{};{}m{}\x1b[0m", r, g, b, text) - ); - } - - #[rstest] - #[case("invalid")] - #[case("#12")] - #[case("not-a-color")] - #[case("#12345")] - #[case("#1234567")] - #[case("#xyz")] - fn test_invalid_hex(#[case] hex: &str) { - no_terminal_check(); - let text = "test"; - assert_eq!(text.hex(hex), "\x1b[0mtest\x1b[0m"); - assert_eq!(text.on_hex(hex), "\x1b[0mtest\x1b[0m"); - } - - #[test] - fn test_string_and_str() { - let string = String::from("test"); - assert_eq!(string.red(), "test".red()); - assert_eq!(string.blue(), "test".blue()); - } - - #[test] - fn test_format_macro() { - no_terminal_check(); - assert_eq!(format!("{}", "test".red()), format!("\x1b[31mtest\x1b[0m")); - } - - #[test] - fn test_chaining() { - no_terminal_check(); - assert_eq!("test".red().bold(), "\x1b[1m\x1b[31mtest\x1b[0m\x1b[0m"); - assert_eq!( - "test".blue().italic().on_yellow(), - "\x1b[43m\x1b[3m\x1b[34mtest\x1b[0m\x1b[0m\x1b[0m" - ); - } - - /// Helper function to check if two RGB values are equal within a tolerance of 1 - /// This accounts for floating-point rounding differences in HSL to RGB conversion - fn assert_rgb_approx_eq(actual: &str, expected: &str) { - no_terminal_check(); - let extract_rgb = |s: &str| { - let parts: Vec<&str> = s.split(';').collect(); - if parts.len() >= 5 { - let r = parts[2].parse::().unwrap(); - let g = parts[3].parse::().unwrap(); - let b = parts[4].split('m').next().unwrap().parse::().unwrap(); - (r, g, b) - } else { - panic!("Invalid ANSI color sequence"); - } - }; - - let (r1, g1, b1) = extract_rgb(actual); - let (r2, g2, b2) = extract_rgb(expected); - - assert!( - (r1 - r2).abs() <= 1 && (g1 - g2).abs() <= 1 && (b1 - b2).abs() <= 1, - "RGB values differ by more than 1: ({}, {}, {}) vs ({}, {}, {})", - r1, - g1, - b1, - r2, - g2, - b2 - ); - } - - #[rstest] - #[case(0.0, 100.0, 50.0, 255, 0, 0)] // Red (hue segment 0) - #[case(60.0, 100.0, 50.0, 255, 255, 0)] // Yellow (boundary 0-1) - #[case(90.0, 100.0, 50.0, 128, 255, 0)] // Chartreuse (hue segment 1) - #[case(120.0, 100.0, 50.0, 0, 255, 0)] // Green (boundary 1-2) - #[case(150.0, 100.0, 50.0, 0, 255, 128)] // Spring Green (hue segment 2) - #[case(180.0, 100.0, 50.0, 0, 255, 255)] // Cyan (boundary 2-3) - #[case(210.0, 100.0, 50.0, 0, 128, 255)] // Azure (hue segment 3) - #[case(240.0, 100.0, 50.0, 0, 0, 255)] // Blue (boundary 3-4) - #[case(300.0, 100.0, 50.0, 255, 0, 255)] // Magenta (boundary 4-5) - #[case(330.0, 100.0, 50.0, 255, 0, 128)] // Rose (hue segment 5) - #[case(360.0, 100.0, 50.0, 255, 0, 0)] // Red again (full circle) - fn test_hsl_colors_comprehensive( - #[case] h: f32, - #[case] s: f32, - #[case] l: f32, - #[case] r: u8, - #[case] g: u8, - #[case] b: u8, - ) { - no_terminal_check(); - let actual = "test".hsl(h, s, l); - let expected = "test".rgb(r, g, b); - assert_rgb_approx_eq(&actual, &expected); - } - - #[test] - fn test_hsl_edge_cases() { - // Helper closure for approximate RGB comparison - let assert_hsl_rgb = |h, s, l, r, g, b| { - let actual = "test".hsl(h, s, l); - let expected = "test".rgb(r, g, b); - assert_rgb_approx_eq(&actual, &expected); - }; - no_terminal_check(); - - // Gray scale (0% saturation) - assert_hsl_rgb(0.0, 0.0, 0.0, 0, 0, 0); // Black - assert_hsl_rgb(0.0, 0.0, 25.0, 64, 64, 64); // Dark gray - assert_hsl_rgb(0.0, 0.0, 50.0, 128, 128, 128); // Mid gray - assert_hsl_rgb(0.0, 0.0, 75.0, 191, 191, 191); // Light gray - assert_hsl_rgb(0.0, 0.0, 100.0, 255, 255, 255); // White - - // Saturation variations (red hue) - assert_hsl_rgb(0.0, 25.0, 50.0, 159, 96, 96); // Low saturation - assert_hsl_rgb(0.0, 50.0, 50.0, 191, 64, 64); // Medium saturation - assert_hsl_rgb(0.0, 75.0, 50.0, 223, 32, 32); // High saturation - - // Lightness variations with full saturation - assert_hsl_rgb(120.0, 100.0, 25.0, 0, 128, 0); // Dark green - assert_hsl_rgb(120.0, 100.0, 75.0, 128, 255, 128); // Light green - } - - #[test] - fn test_hsl_background_colors() { - no_terminal_check(); - // Red background - let actual = "test".on_hsl(0.0, 100.0, 50.0); - let expected = "test".on_rgb(255, 0, 0); - assert_rgb_approx_eq(&actual, &expected); - - // Green background - let actual = "test".on_hsl(120.0, 100.0, 50.0); - let expected = "test".on_rgb(0, 255, 0); - assert_rgb_approx_eq(&actual, &expected); - - // Blue background - let actual = "test".on_hsl(240.0, 100.0, 50.0); - let expected = "test".on_rgb(0, 0, 255); - assert_rgb_approx_eq(&actual, &expected); - } - - #[test] - fn test_no_color_and_terminal_detection() { - // Test NO_COLOR environment variable we also disable the terminal check - // here so we are sure the NO_COLOR variable is the only thing that - // disables color output. - no_terminal_check(); - std::env::set_var("NO_COLOR", "1"); - - // Test basic colors - assert_eq!("test".red(), "test"); - assert_eq!("test".blue(), "test"); - - // Test bright colors - assert_eq!("test".bright_red(), "test"); - assert_eq!("test".bright_blue(), "test"); - - // Test background colors - assert_eq!("test".on_red(), "test"); - assert_eq!("test".on_blue(), "test"); - - // Test styles - assert_eq!("test".bold(), "test"); - assert_eq!("test".italic(), "test"); - - // Test RGB colors - assert_eq!("test".rgb(255, 128, 0), "test"); - assert_eq!("test".on_rgb(255, 128, 0), "test"); - - // Test hex colors - assert_eq!("test".hex("#ff8000"), "test"); - assert_eq!("test".on_hex("#ff8000"), "test"); - - // Test HSL colors - assert_eq!("test".hsl(0.0, 100.0, 50.0), "test"); - assert_eq!("test".on_hsl(0.0, 100.0, 50.0), "test"); - - // Test chaining - assert_eq!("test".red().bold(), "test"); - assert_eq!("test".blue().italic().on_yellow(), "test"); - - // Test with String - let string = String::from("test"); - assert_eq!(string.red(), "test"); - assert_eq!(string.blue(), "test"); - - // Clean up - std::env::remove_var("NO_COLOR"); - - // Note: We can't easily test the terminal detection in unit tests - // since std::io::stdout().is_terminal() depends on the actual - // terminal state. The behavior has been manually verified: - // - Returns true when running normally in a terminal - // - Returns false when output is piped (e.g., `cargo test | cat`) - // - Returns false when output is redirected (e.g., `cargo test > output.txt`) - } - - #[test] - #[should_panic(expected = "Invalid ANSI color sequence")] - fn test_assert_rgb_approx_eq_invalid_sequence() { - assert_rgb_approx_eq("invalid", "also invalid"); - } +mod tests; - #[test] - #[should_panic(expected = "RGB values differ by more than 1: (255, 0, 0) vs (252, 0, 0)")] - fn test_assert_rgb_approx_eq_large_diff() { - no_terminal_check(); - let color1 = "test".rgb(255, 0, 0); - let color2 = "test".rgb(252, 0, 0); - assert_rgb_approx_eq(&color1, &color2); - } -} +pub use config::{ColorMode, ColorizeConfig}; +pub use style::{Colorize, StyledText}; diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..816e704 --- /dev/null +++ b/src/style.rs @@ -0,0 +1,560 @@ +use std::fmt::{self, Display}; + +use crate::color::{hex_to_rgb, hsl_to_rgb, ColorSpec, NamedColor}; +use crate::config::should_colorize; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct StyleFlags { + bold: bool, + dim: bool, + italic: bool, + underline: bool, + inverse: bool, + strikethrough: bool, +} + +impl StyleFlags { + fn sgr_codes(&self) -> Vec { + let mut codes = Vec::new(); + if self.bold { + codes.push("1".to_string()); + } + if self.dim { + codes.push("2".to_string()); + } + if self.italic { + codes.push("3".to_string()); + } + if self.underline { + codes.push("4".to_string()); + } + if self.inverse { + codes.push("7".to_string()); + } + if self.strikethrough { + codes.push("9".to_string()); + } + codes + } +} + +/// A styled text value that composes colors and text attributes before render. +/// +/// `StyledText` is an immutable builder-style value. Each styling method returns +/// a new value with the additional color or style applied. +#[must_use = "StyledText must be rendered, converted, or otherwise used"] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StyledText { + text: String, + foreground: Option, + background: Option, + styles: StyleFlags, + raw_codes: Vec, +} + +impl StyledText { + /// Create a plain styled value from text. + pub fn plain(text: impl Into) -> Self { + Self { + text: text.into(), + foreground: None, + background: None, + styles: StyleFlags::default(), + raw_codes: Vec::new(), + } + } + + /// Return the plain, unstyled text payload. + pub fn plain_text(&self) -> &str { + &self.text + } + + fn with_foreground(mut self, color: ColorSpec) -> Self { + self.foreground = Some(color); + self + } + + fn with_background(mut self, color: ColorSpec) -> Self { + self.background = Some(color); + self + } + + fn set_style(mut self, update: impl FnOnce(&mut StyleFlags)) -> Self { + update(&mut self.styles); + self + } + + fn active_codes(&self) -> Vec { + let mut codes = self.raw_codes.clone(); + codes.extend(self.styles.sgr_codes()); + + if let Some(foreground) = &self.foreground { + codes.push(foreground.foreground_code()); + } + + if let Some(background) = &self.background { + codes.push(background.background_code()); + } + + codes + } + + /// Apply a raw ANSI SGR code sequence to the value. + /// + /// This is an escape hatch for manual SGR composition. Prefer the typed + /// color and style methods when possible. + pub fn colorize(mut self, color_code: &str) -> Self { + if !color_code.trim().is_empty() { + self.raw_codes.push(color_code.to_string()); + } + self + } + + /// Apply the standard red foreground color. + pub fn red(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::Red)) + } + + /// Apply the standard green foreground color. + pub fn green(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::Green)) + } + + /// Apply the standard yellow foreground color. + pub fn yellow(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::Yellow)) + } + + /// Apply the standard blue foreground color. + pub fn blue(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::Blue)) + } + + /// Apply the standard magenta foreground color. + pub fn magenta(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::Magenta)) + } + + /// Apply the standard cyan foreground color. + pub fn cyan(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::Cyan)) + } + + /// Apply the standard white foreground color. + pub fn white(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::White)) + } + + /// Apply the standard black foreground color. + pub fn black(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::Black)) + } + + /// Apply the bright red foreground color. + pub fn bright_red(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::BrightRed)) + } + + /// Apply the bright green foreground color. + pub fn bright_green(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::BrightGreen)) + } + + /// Apply the bright yellow foreground color. + pub fn bright_yellow(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::BrightYellow)) + } + + /// Apply the bright blue foreground color. + pub fn bright_blue(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::BrightBlue)) + } + + /// Apply the bright magenta foreground color. + pub fn bright_magenta(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::BrightMagenta)) + } + + /// Apply the bright cyan foreground color. + pub fn bright_cyan(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::BrightCyan)) + } + + /// Apply the bright white foreground color. + pub fn bright_white(self) -> Self { + self.with_foreground(ColorSpec::Named(NamedColor::BrightWhite)) + } + + /// Add bold text styling. + pub fn bold(self) -> Self { + self.set_style(|styles| styles.bold = true) + } + + /// Add dim text styling. + pub fn dim(self) -> Self { + self.set_style(|styles| styles.dim = true) + } + + /// Add italic text styling. + pub fn italic(self) -> Self { + self.set_style(|styles| styles.italic = true) + } + + /// Add underline text styling. + pub fn underline(self) -> Self { + self.set_style(|styles| styles.underline = true) + } + + /// Swap the foreground and background when rendered. + pub fn inverse(self) -> Self { + self.set_style(|styles| styles.inverse = true) + } + + /// Add strikethrough text styling. + pub fn strikethrough(self) -> Self { + self.set_style(|styles| styles.strikethrough = true) + } + + /// Apply the standard red background color. + pub fn on_red(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::Red)) + } + + /// Apply the standard green background color. + pub fn on_green(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::Green)) + } + + /// Apply the standard yellow background color. + pub fn on_yellow(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::Yellow)) + } + + /// Apply the standard blue background color. + pub fn on_blue(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::Blue)) + } + + /// Apply the standard magenta background color. + pub fn on_magenta(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::Magenta)) + } + + /// Apply the standard cyan background color. + pub fn on_cyan(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::Cyan)) + } + + /// Apply the standard white background color. + pub fn on_white(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::White)) + } + + /// Apply the standard black background color. + pub fn on_black(self) -> Self { + self.with_background(ColorSpec::Named(NamedColor::Black)) + } + + /// Apply a true-color RGB foreground. + pub fn rgb(self, r: u8, g: u8, b: u8) -> Self { + self.with_foreground(ColorSpec::Rgb(r, g, b)) + } + + /// Apply a true-color RGB background. + pub fn on_rgb(self, r: u8, g: u8, b: u8) -> Self { + self.with_background(ColorSpec::Rgb(r, g, b)) + } + + /// Convert HSL to RGB and apply it to the foreground color. + pub fn hsl(self, h: f32, s: f32, l: f32) -> Self { + let (r, g, b) = hsl_to_rgb(h, s, l); + self.rgb(r, g, b) + } + + /// Convert HSL to RGB and apply it to the background color. + pub fn on_hsl(self, h: f32, s: f32, l: f32) -> Self { + let (r, g, b) = hsl_to_rgb(h, s, l); + self.on_rgb(r, g, b) + } + + /// Apply a hex foreground color. + /// + /// Invalid input clears all styling and returns plain text. + pub fn hex(self, hex: &str) -> Self { + if let Some((r, g, b)) = hex_to_rgb(hex) { + self.rgb(r, g, b) + } else { + self.clear() + } + } + + /// Apply a hex background color. + /// + /// Invalid input clears all styling and returns plain text. + pub fn on_hex(self, hex: &str) -> Self { + if let Some((r, g, b)) = hex_to_rgb(hex) { + self.on_rgb(r, g, b) + } else { + self.clear() + } + } + + /// Remove all applied styling and return plain text. + pub fn clear(mut self) -> Self { + self.foreground = None; + self.background = None; + self.styles = StyleFlags::default(); + self.raw_codes.clear(); + self + } +} + +impl Display for StyledText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let codes = self.active_codes(); + if !should_colorize() || codes.is_empty() { + return f.write_str(&self.text); + } + + write!(f, "\x1b[{}m{}\x1b[0m", codes.join(";"), self.text) + } +} + +impl From for String { + fn from(value: StyledText) -> Self { + value.to_string() + } +} + +/// Trait for turning values into styled terminal text. +pub trait Colorize { + /// Apply a raw ANSI SGR code sequence to a displayable value. + fn colorize(&self, color_code: &str) -> StyledText; + + /// Apply the standard red foreground color. + fn red(&self) -> StyledText; + /// Apply the standard green foreground color. + fn green(&self) -> StyledText; + /// Apply the standard yellow foreground color. + fn yellow(&self) -> StyledText; + /// Apply the standard blue foreground color. + fn blue(&self) -> StyledText; + /// Apply the standard magenta foreground color. + fn magenta(&self) -> StyledText; + /// Apply the standard cyan foreground color. + fn cyan(&self) -> StyledText; + /// Apply the standard white foreground color. + fn white(&self) -> StyledText; + /// Apply the standard black foreground color. + fn black(&self) -> StyledText; + + /// Apply the bright red foreground color. + fn bright_red(&self) -> StyledText; + /// Apply the bright green foreground color. + fn bright_green(&self) -> StyledText; + /// Apply the bright yellow foreground color. + fn bright_yellow(&self) -> StyledText; + /// Apply the bright blue foreground color. + fn bright_blue(&self) -> StyledText; + /// Apply the bright magenta foreground color. + fn bright_magenta(&self) -> StyledText; + /// Apply the bright cyan foreground color. + fn bright_cyan(&self) -> StyledText; + /// Apply the bright white foreground color. + fn bright_white(&self) -> StyledText; + + /// Add bold text styling. + fn bold(&self) -> StyledText; + /// Add dim text styling. + fn dim(&self) -> StyledText; + /// Add italic text styling. + fn italic(&self) -> StyledText; + /// Add underline text styling. + fn underline(&self) -> StyledText; + /// Swap foreground and background when rendered. + fn inverse(&self) -> StyledText; + /// Add strikethrough text styling. + fn strikethrough(&self) -> StyledText; + + /// Apply the standard red background color. + fn on_red(&self) -> StyledText; + /// Apply the standard green background color. + fn on_green(&self) -> StyledText; + /// Apply the standard yellow background color. + fn on_yellow(&self) -> StyledText; + /// Apply the standard blue background color. + fn on_blue(&self) -> StyledText; + /// Apply the standard magenta background color. + fn on_magenta(&self) -> StyledText; + /// Apply the standard cyan background color. + fn on_cyan(&self) -> StyledText; + /// Apply the standard white background color. + fn on_white(&self) -> StyledText; + /// Apply the standard black background color. + fn on_black(&self) -> StyledText; + + /// Apply a true-color RGB foreground. + fn rgb(&self, r: u8, g: u8, b: u8) -> StyledText; + /// Apply a true-color RGB background. + fn on_rgb(&self, r: u8, g: u8, b: u8) -> StyledText; + /// Convert HSL to RGB and apply it to the foreground. + fn hsl(&self, h: f32, s: f32, l: f32) -> StyledText; + /// Convert HSL to RGB and apply it to the background. + fn on_hsl(&self, h: f32, s: f32, l: f32) -> StyledText; + /// Apply a hex foreground color, or plain text on invalid input. + fn hex(&self, hex: &str) -> StyledText; + /// Apply a hex background color, or plain text on invalid input. + fn on_hex(&self, hex: &str) -> StyledText; + /// Remove all styling and return plain text. + fn clear(&self) -> StyledText; +} + +impl Colorize for T { + fn colorize(&self, color_code: &str) -> StyledText { + StyledText::plain(self.to_string()).colorize(color_code) + } + + fn red(&self) -> StyledText { + StyledText::plain(self.to_string()).red() + } + + fn green(&self) -> StyledText { + StyledText::plain(self.to_string()).green() + } + + fn yellow(&self) -> StyledText { + StyledText::plain(self.to_string()).yellow() + } + + fn blue(&self) -> StyledText { + StyledText::plain(self.to_string()).blue() + } + + fn magenta(&self) -> StyledText { + StyledText::plain(self.to_string()).magenta() + } + + fn cyan(&self) -> StyledText { + StyledText::plain(self.to_string()).cyan() + } + + fn white(&self) -> StyledText { + StyledText::plain(self.to_string()).white() + } + + fn black(&self) -> StyledText { + StyledText::plain(self.to_string()).black() + } + + fn bright_red(&self) -> StyledText { + StyledText::plain(self.to_string()).bright_red() + } + + fn bright_green(&self) -> StyledText { + StyledText::plain(self.to_string()).bright_green() + } + + fn bright_yellow(&self) -> StyledText { + StyledText::plain(self.to_string()).bright_yellow() + } + + fn bright_blue(&self) -> StyledText { + StyledText::plain(self.to_string()).bright_blue() + } + + fn bright_magenta(&self) -> StyledText { + StyledText::plain(self.to_string()).bright_magenta() + } + + fn bright_cyan(&self) -> StyledText { + StyledText::plain(self.to_string()).bright_cyan() + } + + fn bright_white(&self) -> StyledText { + StyledText::plain(self.to_string()).bright_white() + } + + fn bold(&self) -> StyledText { + StyledText::plain(self.to_string()).bold() + } + + fn dim(&self) -> StyledText { + StyledText::plain(self.to_string()).dim() + } + + fn italic(&self) -> StyledText { + StyledText::plain(self.to_string()).italic() + } + + fn underline(&self) -> StyledText { + StyledText::plain(self.to_string()).underline() + } + + fn inverse(&self) -> StyledText { + StyledText::plain(self.to_string()).inverse() + } + + fn strikethrough(&self) -> StyledText { + StyledText::plain(self.to_string()).strikethrough() + } + + fn on_red(&self) -> StyledText { + StyledText::plain(self.to_string()).on_red() + } + + fn on_green(&self) -> StyledText { + StyledText::plain(self.to_string()).on_green() + } + + fn on_yellow(&self) -> StyledText { + StyledText::plain(self.to_string()).on_yellow() + } + + fn on_blue(&self) -> StyledText { + StyledText::plain(self.to_string()).on_blue() + } + + fn on_magenta(&self) -> StyledText { + StyledText::plain(self.to_string()).on_magenta() + } + + fn on_cyan(&self) -> StyledText { + StyledText::plain(self.to_string()).on_cyan() + } + + fn on_white(&self) -> StyledText { + StyledText::plain(self.to_string()).on_white() + } + + fn on_black(&self) -> StyledText { + StyledText::plain(self.to_string()).on_black() + } + + fn rgb(&self, r: u8, g: u8, b: u8) -> StyledText { + StyledText::plain(self.to_string()).rgb(r, g, b) + } + + fn on_rgb(&self, r: u8, g: u8, b: u8) -> StyledText { + StyledText::plain(self.to_string()).on_rgb(r, g, b) + } + + fn hsl(&self, h: f32, s: f32, l: f32) -> StyledText { + StyledText::plain(self.to_string()).hsl(h, s, l) + } + + fn on_hsl(&self, h: f32, s: f32, l: f32) -> StyledText { + StyledText::plain(self.to_string()).on_hsl(h, s, l) + } + + fn hex(&self, hex: &str) -> StyledText { + StyledText::plain(self.to_string()).hex(hex) + } + + fn on_hex(&self, hex: &str) -> StyledText { + StyledText::plain(self.to_string()).on_hex(hex) + } + + fn clear(&self) -> StyledText { + StyledText::plain(self.to_string()).clear() + } +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..0cc330d --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,485 @@ +use crate::color::{ColorSpec, NamedColor}; +use crate::config::{ + get_terminal_override_for_tests, set_terminal_override_for_tests, should_colorize, +}; +use crate::*; +use rstest::*; +use std::env; +use std::ffi::OsString; +use std::io::IsTerminal; +use std::sync::{LazyLock, Mutex, MutexGuard}; + +static TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +struct TestStateGuard { + _lock: MutexGuard<'static, ()>, + previous_mode: ColorMode, + previous_no_color: Option, + previous_terminal_override: Option, +} + +impl TestStateGuard { + fn colors_enabled(mode: ColorMode) -> Self { + Self::with_state(mode, None, Some(false)) + } + + fn no_color(mode: ColorMode) -> Self { + Self::with_state(mode, Some("1"), Some(false)) + } + + fn auto_terminal(is_terminal: bool) -> Self { + Self::with_state(ColorMode::Auto, None, Some(is_terminal)) + } + + fn with_state( + mode: ColorMode, + no_color: Option<&str>, + terminal_override: Option, + ) -> Self { + let guard = TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous_mode = ColorizeConfig::color_mode(); + let previous_no_color = env::var_os("NO_COLOR"); + let previous_terminal_override = get_terminal_override_for_tests(); + + match no_color { + Some(value) => env::set_var("NO_COLOR", value), + None => env::remove_var("NO_COLOR"), + } + ColorizeConfig::set_color_mode(mode); + set_terminal_override_for_tests(terminal_override); + + Self { + _lock: guard, + previous_mode, + previous_no_color, + previous_terminal_override, + } + } +} + +impl Drop for TestStateGuard { + fn drop(&mut self) { + ColorizeConfig::set_color_mode(self.previous_mode); + set_terminal_override_for_tests(self.previous_terminal_override); + match self.previous_no_color.as_ref() { + Some(value) => env::set_var("NO_COLOR", value), + None => env::remove_var("NO_COLOR"), + } + } +} + +#[rstest] +#[case("red", "\x1b[31mtest\x1b[0m")] +#[case("green", "\x1b[32mtest\x1b[0m")] +#[case("yellow", "\x1b[33mtest\x1b[0m")] +#[case("blue", "\x1b[34mtest\x1b[0m")] +#[case("magenta", "\x1b[35mtest\x1b[0m")] +#[case("cyan", "\x1b[36mtest\x1b[0m")] +#[case("white", "\x1b[37mtest\x1b[0m")] +#[case("black", "\x1b[30mtest\x1b[0m")] +fn test_basic_colors(#[case] color: &str, #[case] expected: &str) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let text = "test"; + let actual = match color { + "red" => text.red().to_string(), + "green" => text.green().to_string(), + "yellow" => text.yellow().to_string(), + "blue" => text.blue().to_string(), + "magenta" => text.magenta().to_string(), + "cyan" => text.cyan().to_string(), + "white" => text.white().to_string(), + "black" => text.black().to_string(), + _ => unreachable!(), + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case("bright_red", "\x1b[91mtest\x1b[0m")] +#[case("bright_green", "\x1b[92mtest\x1b[0m")] +#[case("bright_yellow", "\x1b[93mtest\x1b[0m")] +#[case("bright_blue", "\x1b[94mtest\x1b[0m")] +#[case("bright_magenta", "\x1b[95mtest\x1b[0m")] +#[case("bright_cyan", "\x1b[96mtest\x1b[0m")] +#[case("bright_white", "\x1b[97mtest\x1b[0m")] +fn test_bright_colors(#[case] color: &str, #[case] expected: &str) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let text = "test"; + let actual = match color { + "bright_red" => text.bright_red().to_string(), + "bright_green" => text.bright_green().to_string(), + "bright_yellow" => text.bright_yellow().to_string(), + "bright_blue" => text.bright_blue().to_string(), + "bright_magenta" => text.bright_magenta().to_string(), + "bright_cyan" => text.bright_cyan().to_string(), + "bright_white" => text.bright_white().to_string(), + _ => unreachable!(), + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case("on_red", "\x1b[41mtest\x1b[0m")] +#[case("on_green", "\x1b[42mtest\x1b[0m")] +#[case("on_yellow", "\x1b[43mtest\x1b[0m")] +#[case("on_blue", "\x1b[44mtest\x1b[0m")] +#[case("on_magenta", "\x1b[45mtest\x1b[0m")] +#[case("on_cyan", "\x1b[46mtest\x1b[0m")] +#[case("on_white", "\x1b[47mtest\x1b[0m")] +#[case("on_black", "\x1b[40mtest\x1b[0m")] +fn test_background_colors(#[case] color: &str, #[case] expected: &str) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let text = "test"; + let actual = match color { + "on_red" => text.on_red().to_string(), + "on_green" => text.on_green().to_string(), + "on_yellow" => text.on_yellow().to_string(), + "on_blue" => text.on_blue().to_string(), + "on_magenta" => text.on_magenta().to_string(), + "on_cyan" => text.on_cyan().to_string(), + "on_white" => text.on_white().to_string(), + "on_black" => text.on_black().to_string(), + _ => unreachable!(), + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case("bold", "\x1b[1mtest\x1b[0m")] +#[case("dim", "\x1b[2mtest\x1b[0m")] +#[case("italic", "\x1b[3mtest\x1b[0m")] +#[case("underline", "\x1b[4mtest\x1b[0m")] +#[case("inverse", "\x1b[7mtest\x1b[0m")] +#[case("strikethrough", "\x1b[9mtest\x1b[0m")] +fn test_styles(#[case] style: &str, #[case] expected: &str) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let text = "test"; + let actual = match style { + "bold" => text.bold().to_string(), + "dim" => text.dim().to_string(), + "italic" => text.italic().to_string(), + "underline" => text.underline().to_string(), + "inverse" => text.inverse().to_string(), + "strikethrough" => text.strikethrough().to_string(), + _ => unreachable!(), + }; + assert_eq!(actual, expected); +} + +#[rstest] +#[case(255, 128, 0)] +#[case(0, 255, 0)] +#[case(128, 128, 128)] +#[case(0, 0, 0)] +#[case(255, 255, 255)] +fn test_rgb_colors(#[case] r: u8, #[case] g: u8, #[case] b: u8) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let text = "test"; + assert_eq!( + text.rgb(r, g, b).to_string(), + format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) + ); + assert_eq!( + text.on_rgb(r, g, b).to_string(), + format!("\x1b[48;2;{};{};{}m{}\x1b[0m", r, g, b, text) + ); +} + +#[rstest] +#[case("#ff8000", 255, 128, 0)] +#[case("#00ff00", 0, 255, 0)] +#[case("#808080", 128, 128, 128)] +#[case("#000000", 0, 0, 0)] +#[case("#ffffff", 255, 255, 255)] +fn test_hex_colors(#[case] hex: &str, #[case] r: u8, #[case] g: u8, #[case] b: u8) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let text = "test"; + assert_eq!( + text.hex(hex).to_string(), + format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) + ); + assert_eq!( + text.on_hex(hex).to_string(), + format!("\x1b[48;2;{};{};{}m{}\x1b[0m", r, g, b, text) + ); + + let hex_without_prefix = hex.trim_start_matches('#'); + assert_eq!( + text.hex(hex_without_prefix).to_string(), + format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) + ); + assert_eq!( + text.on_hex(hex_without_prefix).to_string(), + format!("\x1b[48;2;{};{};{}m{}\x1b[0m", r, g, b, text) + ); +} + +#[rstest] +#[case("invalid")] +#[case("#12")] +#[case("not-a-color")] +#[case("#12345")] +#[case("#1234567")] +#[case("#xyz")] +fn test_invalid_hex_returns_plain_text(#[case] hex: &str) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let text = "test"; + assert_eq!(text.hex(hex).to_string(), "test"); + assert_eq!(text.on_hex(hex).to_string(), "test"); + assert_eq!(text.red().hex(hex).to_string(), "test"); + assert_eq!(text.on_blue().on_hex(hex).to_string(), "test"); +} + +#[test] +fn test_clear_returns_plain_text() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!("test".clear().to_string(), "test"); + assert_eq!("test".red().clear().to_string(), "test"); + assert_eq!( + "test".blue().italic().on_yellow().clear().to_string(), + "test" + ); +} + +#[test] +fn test_chaining_composes_once() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!("test".red().bold().to_string(), "\x1b[1;31mtest\x1b[0m"); + assert_eq!( + "test".blue().italic().on_yellow().to_string(), + "\x1b[3;34;43mtest\x1b[0m" + ); + assert_eq!( + "test".rgb(255, 128, 0).on_blue().to_string(), + "\x1b[38;2;255;128;0;44mtest\x1b[0m" + ); +} + +#[test] +fn test_conflicting_chains_use_last_color() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!("test".red().green().to_string(), "\x1b[32mtest\x1b[0m"); + assert_eq!("test".on_red().on_blue().to_string(), "\x1b[44mtest\x1b[0m"); +} + +#[test] +fn test_style_flags_accumulate() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!("test".bold().dim().to_string(), "\x1b[1;2mtest\x1b[0m"); + assert_eq!( + "test".underline().italic().strikethrough().to_string(), + "\x1b[3;4;9mtest\x1b[0m" + ); +} + +#[test] +fn test_string_and_plain_text_access() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let string = String::from("test"); + let styled = string.red().bold(); + assert_eq!(styled.to_string(), "\x1b[1;31mtest\x1b[0m"); + assert_eq!(styled.plain_text(), "test"); +} + +#[test] +fn test_format_macro_uses_display() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!(format!("{}", "test".red()), "\x1b[31mtest\x1b[0m"); +} + +fn assert_rgb_approx_eq(actual: &str, expected: &str) { + let extract_rgb = |s: &str| { + let start = s.find("38;2;").or_else(|| s.find("48;2;")); + if let Some(start) = start { + let sequence = &s[start..]; + let parts: Vec<&str> = sequence.split(';').collect(); + let r = parts.get(2).and_then(|part| part.parse::().ok()); + let g = parts.get(3).and_then(|part| part.parse::().ok()); + let b = parts + .get(4) + .and_then(|part| part.split('m').next()) + .and_then(|part| part.parse::().ok()); + + if let (Some(r), Some(g), Some(b)) = (r, g, b) { + return (r, g, b); + } + } + + panic!("Invalid ANSI color sequence"); + }; + + let (r1, g1, b1) = extract_rgb(actual); + let (r2, g2, b2) = extract_rgb(expected); + + assert!( + (r1 - r2).abs() <= 1 && (g1 - g2).abs() <= 1 && (b1 - b2).abs() <= 1, + "RGB values differ by more than 1: ({}, {}, {}) vs ({}, {}, {})", + r1, + g1, + b1, + r2, + g2, + b2 + ); +} + +#[rstest] +#[case(0.0, 100.0, 50.0, 255, 0, 0)] +#[case(60.0, 100.0, 50.0, 255, 255, 0)] +#[case(90.0, 100.0, 50.0, 128, 255, 0)] +#[case(120.0, 100.0, 50.0, 0, 255, 0)] +#[case(150.0, 100.0, 50.0, 0, 255, 128)] +#[case(180.0, 100.0, 50.0, 0, 255, 255)] +#[case(210.0, 100.0, 50.0, 0, 128, 255)] +#[case(240.0, 100.0, 50.0, 0, 0, 255)] +#[case(300.0, 100.0, 50.0, 255, 0, 255)] +#[case(330.0, 100.0, 50.0, 255, 0, 128)] +#[case(360.0, 100.0, 50.0, 255, 0, 0)] +fn test_hsl_colors_comprehensive( + #[case] h: f32, + #[case] s: f32, + #[case] l: f32, + #[case] r: u8, + #[case] g: u8, + #[case] b: u8, +) { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let actual = "test".hsl(h, s, l).to_string(); + let expected = "test".rgb(r, g, b).to_string(); + assert_rgb_approx_eq(&actual, &expected); +} + +#[test] +fn test_hsl_edge_cases() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + + let assert_hsl_rgb = |h, s, l, r, g, b| { + let actual = "test".hsl(h, s, l).to_string(); + let expected = "test".rgb(r, g, b).to_string(); + assert_rgb_approx_eq(&actual, &expected); + }; + + assert_hsl_rgb(0.0, 0.0, 0.0, 0, 0, 0); + assert_hsl_rgb(0.0, 0.0, 25.0, 64, 64, 64); + assert_hsl_rgb(0.0, 0.0, 50.0, 128, 128, 128); + assert_hsl_rgb(0.0, 0.0, 75.0, 191, 191, 191); + assert_hsl_rgb(0.0, 0.0, 100.0, 255, 255, 255); + + assert_hsl_rgb(0.0, 25.0, 50.0, 159, 96, 96); + assert_hsl_rgb(0.0, 50.0, 50.0, 191, 64, 64); + assert_hsl_rgb(0.0, 75.0, 50.0, 223, 32, 32); + + assert_hsl_rgb(120.0, 100.0, 25.0, 0, 128, 0); + assert_hsl_rgb(120.0, 100.0, 75.0, 128, 255, 128); +} + +#[test] +fn test_hsl_background_colors() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let actual = "test".on_hsl(0.0, 100.0, 50.0).to_string(); + let expected = "test".on_rgb(255, 0, 0).to_string(); + assert_rgb_approx_eq(&actual, &expected); + + let actual = "test".on_hsl(120.0, 100.0, 50.0).to_string(); + let expected = "test".on_rgb(0, 255, 0).to_string(); + assert_rgb_approx_eq(&actual, &expected); + + let actual = "test".on_hsl(240.0, 100.0, 50.0).to_string(); + let expected = "test".on_rgb(0, 0, 255).to_string(); + assert_rgb_approx_eq(&actual, &expected); +} + +#[test] +fn test_color_mode_always_forces_color() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!("test".red().to_string(), "\x1b[31mtest\x1b[0m"); +} + +#[test] +fn test_color_mode_auto_respects_tty_detection() { + let _guard = TestStateGuard::auto_terminal(false); + assert_eq!("test".red().to_string(), "test"); +} + +#[test] +fn test_color_mode_auto_uses_real_stdout_terminal_state_without_override() { + let _guard = TestStateGuard::with_state(ColorMode::Auto, None, None); + assert_eq!(should_colorize(), std::io::stdout().is_terminal()); +} + +#[test] +fn test_color_mode_auto_enables_color_for_terminal_output() { + let _guard = TestStateGuard::auto_terminal(true); + assert_eq!("test".red().to_string(), "\x1b[31mtest\x1b[0m"); +} + +#[test] +fn test_color_mode_never_disables_color() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Never); + assert_eq!("test".red().to_string(), "test"); + assert_eq!("test".blue().italic().on_yellow().to_string(), "test"); +} + +#[test] +fn test_no_color_disables_output_in_auto_and_always() { + let _guard = TestStateGuard::no_color(ColorMode::Always); + assert_eq!("test".red().to_string(), "test"); + assert_eq!("test".blue().italic().on_yellow().to_string(), "test"); +} + +#[test] +#[allow(deprecated)] +fn test_set_terminal_check_compatibility_mapping() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Never); + ColorizeConfig::set_terminal_check(false); + assert_eq!(ColorizeConfig::color_mode(), ColorMode::Always); + + ColorizeConfig::set_terminal_check(true); + assert_eq!(ColorizeConfig::color_mode(), ColorMode::Auto); +} + +#[test] +fn test_raw_colorize_codes_still_render() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!("test".colorize("31;1").to_string(), "\x1b[31;1mtest\x1b[0m"); + assert_eq!( + "test".colorize("31").green().to_string(), + "\x1b[31;32mtest\x1b[0m" + ); +} + +#[rstest] +#[case(NamedColor::BrightRed, "101")] +#[case(NamedColor::BrightGreen, "102")] +#[case(NamedColor::BrightYellow, "103")] +#[case(NamedColor::BrightBlue, "104")] +#[case(NamedColor::BrightMagenta, "105")] +#[case(NamedColor::BrightCyan, "106")] +#[case(NamedColor::BrightWhite, "107")] +fn test_bright_background_color_codes(#[case] color: NamedColor, #[case] expected: &str) { + assert_eq!(ColorSpec::Named(color).background_code(), expected); +} + +#[test] +fn test_from_styled_text_to_string() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let rendered: String = "test".red().bold().into(); + assert_eq!(rendered, "\x1b[1;31mtest\x1b[0m"); +} + +#[test] +#[should_panic(expected = "Invalid ANSI color sequence")] +fn test_assert_rgb_approx_eq_invalid_sequence() { + assert_rgb_approx_eq("invalid", "also invalid"); +} + +#[test] +#[should_panic(expected = "RGB values differ by more than 1: (255, 0, 0) vs (252, 0, 0)")] +fn test_assert_rgb_approx_eq_large_diff() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + let color1 = "test".rgb(255, 0, 0).to_string(); + let color2 = "test".rgb(252, 0, 0).to_string(); + assert_rgb_approx_eq(&color1, &color2); +}