From c1058cc4c6f7b7ffb856a6cdd338bfe1fb30928b Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Sat, 4 Apr 2026 14:35:56 +0100 Subject: [PATCH] feat: add writer-aware rendering Signed-off-by: Grant Ramsay --- README.md | 16 ++++++++++++ TODO.md | 2 -- src/config.rs | 57 +++++++++++++++++++++++++++++++++++++---- src/lib.rs | 5 +++- src/style.rs | 28 +++++++++++++++----- src/tests.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 163 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 58411d4..93feb95 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Rust. - 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 in `Auto` mode +- Supports explicit target-aware rendering for stdout, stderr, or custom + terminal-aware destinations - Complete documentation and examples ## Installation @@ -198,6 +200,20 @@ applications that want to force color on or off for a specific execution path. `NO_COLOR` still takes precedence in `Auto` and `Always` mode. If `NO_COLOR` is set, output is plain text. +For non-stdout destinations, use `StyledText::render` with a `RenderTarget` so +`Auto` mode evaluates the real output target: + +```rust +use colored_text::{Colorize, RenderTarget}; + +let warning = "Warning".yellow().bold(); + +eprintln!("{}", warning.render(RenderTarget::Stderr)); + +let captured = warning.render(RenderTarget::Terminal(false)); +assert_eq!(captured, "Warning"); +``` + ## Terminal Compatibility This library uses ANSI escape codes for coloring and styling text. Most modern diff --git a/TODO.md b/TODO.md index 83f661e..d242581 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,5 @@ # 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 diff --git a/src/config.rs b/src/config.rs index 269281c..1383479 100644 --- a/src/config.rs +++ b/src/config.rs @@ -15,6 +15,17 @@ pub enum ColorMode { Never, } +/// Output target used when rendering styled text explicitly. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RenderTarget { + /// Resolve terminal capability from stdout. + Stdout, + /// Resolve terminal capability from stderr. + Stderr, + /// Use an explicit terminal capability for a custom destination. + Terminal(bool), +} + /// Configuration for controlling runtime color behavior. /// /// The active configuration is stored per thread. This makes it straightforward @@ -28,7 +39,11 @@ pub struct ColorizeConfig { thread_local! { static CONFIG: RefCell = RefCell::new(ColorizeConfig::default()); #[cfg(test)] - static TERMINAL_OVERRIDE: RefCell> = RefCell::new(None); + #[allow(clippy::missing_const_for_thread_local)] + static STDOUT_TERMINAL_OVERRIDE: RefCell> = RefCell::new(None); + #[cfg(test)] + #[allow(clippy::missing_const_for_thread_local)] + static STDERR_TERMINAL_OVERRIDE: RefCell> = RefCell::new(None); } impl Default for ColorizeConfig { @@ -74,28 +89,60 @@ impl ColorizeConfig { /// This respects [`ColorizeConfig::color_mode()`], and `NO_COLOR` takes /// precedence over both [`ColorMode::Auto`] and [`ColorMode::Always`]. pub(crate) fn should_colorize() -> bool { + should_colorize_for(RenderTarget::Stdout) +} + +/// Evaluate the current runtime color policy for a specific render target. +pub(crate) fn should_colorize_for(target: RenderTarget) -> 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(), + ColorMode::Auto => std::env::var_os("NO_COLOR").is_none() && target_is_terminal(target), + } +} + +fn target_is_terminal(target: RenderTarget) -> bool { + match target { + RenderTarget::Stdout => stdout_is_terminal(), + RenderTarget::Stderr => stderr_is_terminal(), + RenderTarget::Terminal(value) => value, } } fn stdout_is_terminal() -> bool { #[cfg(test)] - if let Some(value) = TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) { + if let Some(value) = STDOUT_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) { return value; } std::io::stdout().is_terminal() } +fn stderr_is_terminal() -> bool { + #[cfg(test)] + if let Some(value) = STDERR_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) { + return value; + } + + std::io::stderr().is_terminal() +} + #[cfg(test)] pub(crate) fn set_terminal_override_for_tests(value: Option) { - TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow_mut() = value); + STDOUT_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()) + STDOUT_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) +} + +#[cfg(test)] +pub(crate) fn set_stderr_terminal_override_for_tests(value: Option) { + STDERR_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow_mut() = value); +} + +#[cfg(test)] +pub(crate) fn get_stderr_terminal_override_for_tests() -> Option { + STDERR_TERMINAL_OVERRIDE.with(|override_value| *override_value.borrow()) } diff --git a/src/lib.rs b/src/lib.rs index ddafd4e..c1743de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -86,6 +86,9 @@ //! println!("{}", "Never colored".red()); //! ``` //! +//! When you need `Auto` mode to follow a destination other than stdout, use +//! [`StyledText::render`] with a [`RenderTarget`]. +//! //! # Note //! //! Colors and styles are implemented using ANSI escape codes, which are @@ -100,5 +103,5 @@ mod style; #[cfg(test)] mod tests; -pub use config::{ColorMode, ColorizeConfig}; +pub use config::{ColorMode, ColorizeConfig, RenderTarget}; pub use style::{Colorize, StyledText}; diff --git a/src/style.rs b/src/style.rs index 816e704..b5eb2f4 100644 --- a/src/style.rs +++ b/src/style.rs @@ -1,7 +1,7 @@ use std::fmt::{self, Display}; use crate::color::{hex_to_rgb, hsl_to_rgb, ColorSpec, NamedColor}; -use crate::config::should_colorize; +use crate::config::{should_colorize, should_colorize_for, RenderTarget}; #[derive(Clone, Debug, Default, Eq, PartialEq)] struct StyleFlags { @@ -307,16 +307,30 @@ impl StyledText { self.raw_codes.clear(); self } -} -impl Display for StyledText { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + /// Render the styled value for a specific output target. + /// + /// This is useful when the caller knows the real destination is stderr or a + /// custom writer and wants [`crate::ColorMode::Auto`] to evaluate that + /// destination instead of the default stdout-based behavior used by + /// [`Display`]. + pub fn render(&self, target: RenderTarget) -> String { + self.render_with_color_policy(should_colorize_for(target)) + } + + fn render_with_color_policy(&self, colorize: bool) -> String { let codes = self.active_codes(); - if !should_colorize() || codes.is_empty() { - return f.write_str(&self.text); + if !colorize || codes.is_empty() { + return self.text.clone(); } - write!(f, "\x1b[{}m{}\x1b[0m", codes.join(";"), self.text) + format!("\x1b[{}m{}\x1b[0m", codes.join(";"), self.text) + } +} + +impl Display for StyledText { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.render_with_color_policy(should_colorize())) } } diff --git a/src/tests.rs b/src/tests.rs index 0cc330d..a6fb60c 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,6 +1,7 @@ use crate::color::{ColorSpec, NamedColor}; use crate::config::{ - get_terminal_override_for_tests, set_terminal_override_for_tests, should_colorize, + get_stderr_terminal_override_for_tests, get_terminal_override_for_tests, + set_stderr_terminal_override_for_tests, set_terminal_override_for_tests, should_colorize, }; use crate::*; use rstest::*; @@ -16,6 +17,7 @@ struct TestStateGuard { previous_mode: ColorMode, previous_no_color: Option, previous_terminal_override: Option, + previous_stderr_terminal_override: Option, } impl TestStateGuard { @@ -42,6 +44,7 @@ impl TestStateGuard { let previous_mode = ColorizeConfig::color_mode(); let previous_no_color = env::var_os("NO_COLOR"); let previous_terminal_override = get_terminal_override_for_tests(); + let previous_stderr_terminal_override = get_stderr_terminal_override_for_tests(); match no_color { Some(value) => env::set_var("NO_COLOR", value), @@ -55,6 +58,7 @@ impl TestStateGuard { previous_mode, previous_no_color, previous_terminal_override, + previous_stderr_terminal_override, } } } @@ -63,6 +67,7 @@ impl Drop for TestStateGuard { fn drop(&mut self) { ColorizeConfig::set_color_mode(self.previous_mode); set_terminal_override_for_tests(self.previous_terminal_override); + set_stderr_terminal_override_for_tests(self.previous_stderr_terminal_override); match self.previous_no_color.as_ref() { Some(value) => env::set_var("NO_COLOR", value), None => env::remove_var("NO_COLOR"), @@ -409,12 +414,76 @@ fn test_color_mode_auto_uses_real_stdout_terminal_state_without_override() { assert_eq!(should_colorize(), std::io::stdout().is_terminal()); } +#[test] +fn test_render_auto_uses_real_stderr_terminal_state_without_override() { + let _guard = TestStateGuard::with_state(ColorMode::Auto, None, None); + set_stderr_terminal_override_for_tests(None); + + let expected = if std::io::stderr().is_terminal() { + "\x1b[31mtest\x1b[0m" + } else { + "test" + }; + + assert_eq!("test".red().render(RenderTarget::Stderr), expected); +} + #[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_render_auto_uses_stderr_terminal_state() { + let _guard = TestStateGuard::with_state(ColorMode::Auto, None, Some(false)); + set_stderr_terminal_override_for_tests(Some(true)); + assert_eq!( + "test".red().render(RenderTarget::Stderr), + "\x1b[31mtest\x1b[0m" + ); +} + +#[test] +fn test_render_auto_uses_custom_terminal_state() { + let _guard = TestStateGuard::with_state(ColorMode::Auto, None, Some(true)); + assert_eq!("test".red().render(RenderTarget::Terminal(false)), "test"); + assert_eq!( + "test".red().render(RenderTarget::Terminal(true)), + "\x1b[31mtest\x1b[0m" + ); +} + +#[test] +fn test_render_always_ignores_target_terminal_state() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Always); + assert_eq!( + "test".red().render(RenderTarget::Terminal(false)), + "\x1b[31mtest\x1b[0m" + ); +} + +#[test] +fn test_render_never_disables_color_for_all_targets() { + let _guard = TestStateGuard::colors_enabled(ColorMode::Never); + assert_eq!("test".red().render(RenderTarget::Stdout), "test"); + assert_eq!("test".red().render(RenderTarget::Stderr), "test"); + assert_eq!("test".red().render(RenderTarget::Terminal(true)), "test"); +} + +#[test] +fn test_render_respects_no_color_in_always_mode() { + let _guard = TestStateGuard::no_color(ColorMode::Always); + assert_eq!("test".red().render(RenderTarget::Terminal(true)), "test"); +} + +#[test] +fn test_display_remains_stdout_based_in_auto_mode() { + let _guard = TestStateGuard::with_state(ColorMode::Auto, None, Some(false)); + set_stderr_terminal_override_for_tests(Some(true)); + assert_eq!("test".red().to_string(), "test"); +} + #[test] fn test_color_mode_never_disables_color() { let _guard = TestStateGuard::colors_enabled(ColorMode::Never);