Skip to content

feat: modernize color composition and crate internals#5

Merged
seapagan merged 11 commits intomainfrom
feat/modernize-color-model
Apr 4, 2026
Merged

feat: modernize color composition and crate internals#5
seapagan merged 11 commits intomainfrom
feat/modernize-color-model

Conversation

@seapagan
Copy link
Copy Markdown
Owner

@seapagan seapagan commented Apr 4, 2026

Summary

This PR modernizes colored_text and fixes a few bugs

It replaces nested ANSI string wrapping with a composed StyledText model, adds explicit runtime color modes with compatibility for the previous terminal-check API, and fixes the behavior of clear() and invalid hex fallbacks so they return plain text.

It also splits the crate into smaller modules, improves public rustdoc, tightens API ergonomics with #[must_use] on StyledText, and adds targeted regression and coverage tests so the remaining uncovered paths are confined to test helpers rather than production behavior.

Summary by CodeRabbit

  • New Features

    • Runtime colour modes (Auto / Always / Never) and thread-local config to control when styling is applied.
    • Fluent styling API with named, bright, RGB, HSL and hex colours, plus .clear() to remove styles; composed/chained styling with predictable precedence.
  • Documentation

    • Updated examples showing composed chaining, .clear(), direct rendering, and clarified invalid-hex behaviour (renders plain unstyled text).
  • Tests

    • Extensive test suite covering colours, styles, hex/HSL/RGB parsing, chaining, clear, and runtime colour mode behaviour.
  • Chores

    • Added TODO outlining writer-aware auto-detection, MSRV consideration, and 3-char hex support.

seapagan added 6 commits April 4, 2026 12:42
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8d3f1158-4499-44d2-9739-aeed50ed7e38

📥 Commits

Reviewing files that changed from the base of the PR and between 8561c33 and a15a7eb.

📒 Files selected for processing (1)
  • src/config.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/config.rs

📝 Walkthrough

Walkthrough

The PR splits functionality into modules: color.rs for color conversions and specs, config.rs for per-thread ColorMode and terminal/NO_COLOR logic, and style.rs for an immutable StyledText builder and Colorize trait; README, example and tests updated to the new APIs and behaviour.

Changes

Cohort / File(s) Summary
Documentation & Examples
README.md, TODO.md, examples/basic.rs
Docs updated to describe "composed style chaining", clear() behaviour, ColorMode and ColorizeConfig::set_color_mode(...). Added TODO.md (writer-aware Auto, MSRV note, 3‑char hex shorthand). Examples demonstrate Always/Never/Auto and inline rendering.
Colour Utilities
src/color.rs
New module: hsl_to_rgb(...) and hex_to_rgb(...). Adds NamedColor and ColorSpec enums and methods producing ANSI foreground/background codes (including 24‑bit 38;2/48;2).
Runtime Configuration
src/config.rs
New module: pub enum ColorMode { Auto, Always, Never } and ColorizeConfig with thread-local mode, set_color_mode/color_mode, deprecated set_terminal_check(bool), and should_colorize() that respects NO_COLOR and detects TTY via stdout().is_terminal() (test overrides available).
Styling Implementation
src/style.rs
New module: immutable StyledText builder, StyleFlags, optional foreground/background ColorSpec, raw SGR codes, hex/on_hex (clears styles on invalid input), clear(). Implements Colorize trait for Display types, Display rendering honoring should_colorize(), and From<StyledText> for String.
Library Re-exports
src/lib.rs
Refactored to delegate to submodules and re-export ColorMode, ColorizeConfig, Colorize, and StyledText. Crate docs updated; prior inlined implementations removed.
Test Suite
src/tests.rs
New comprehensive tests using TEST_LOCK and TestStateGuard to snapshot/restore env and thread-local config. Tests cover named/bright/background colours, styles, RGB/hex/HSL conversion (including optional #), invalid‑hex behaviour (plain text), chaining precedence, clearing, rendering conversions, and ColorMode/TTY interactions.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant App as Client/App
    participant Config as ColorizeConfig
    participant Env as NO_COLOR
    participant TTY as TerminalDetector
    participant Renderer as StyledText/Renderer

    App->>Config: set_color_mode(mode)
    App->>Renderer: create StyledText (text + styles/colours)
    Renderer->>Config: should_colorize()?
    Config->>Env: check NO_COLOR
    alt NO_COLOR set
        Env-->>Config: present
        Config-->>Renderer: return false
    else NO_COLOR not set
        Config->>TTY: stdout.is_terminal()?
        TTY-->>Config: true/false
        Config-->>Renderer: return true/false (based on mode & TTY)
    end
    Renderer-->>App: render/Display (ANSI sequences or plain text)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰
I nudge the hues and stitch the code,
Threads agree the modes bestowed,
Builders hop with styles in tow,
Hex and HSL in tidy row,
Tests applaud with every glow.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: modernizing the color composition model from nested ANSI wrapping to composed StyledText, refactoring crate internals into modules (color, config, style), and updating runtime color configuration.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/modernize-color-model

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@seapagan seapagan self-assigned this Apr 4, 2026
@seapagan seapagan added the refactor Code refactoring or improvement label Apr 4, 2026
@seapagan seapagan marked this pull request as ready for review April 4, 2026 12:37
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/color.rs (1)

31-42: Consider supporting 3-character shorthand hex codes.

The implementation correctly parses 6-character hex codes, but does not support the common 3-character shorthand (e.g., #f80 expanding to #ff8800). This is a minor usability gap that could be addressed in a follow-up.

💡 Optional enhancement to support 3-char hex
 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 hex = match hex.len() {
+        3 => {
+            let chars: Vec<char> = hex.chars().collect();
+            format!("{0}{0}{1}{1}{2}{2}", chars[0], chars[1], chars[2])
+        }
+        6 => hex.to_string(),
+        _ => 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()?;
+    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))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/color.rs` around lines 31 - 42, The hex_to_rgb function currently only
handles 6-character hex strings; update it to also accept 3-character shorthand
by detecting when hex.len() == 3 and expanding it to 6 characters (duplicate
each nibble, e.g., "f80" -> "ff8800") before calling u8::from_str_radix on the
r/g/b slices; keep the existing trim_start_matches('#') behavior and reuse the
same parsing logic (r/g/b via from_str_radix) so both 3- and 6-char inputs are
supported.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config.rs`:
- Around line 26-30: The TERMI NAL_OVERRIDE thread-local uses `const {
RefCell::new(None) }` which requires Rust 1.79+; update to be
backwards-compatible by replacing the const initializer with a non-const
initializer (i.e., use `RefCell::new(None)`) or alternatively declare the MSRV
by adding `rust-version = "1.79"` to Cargo.toml; edit the thread_local block
where `TERMINAL_OVERRIDE: RefCell<Option<bool>>` is defined (near `CONFIG` and
`ColorizeConfig::default`) to apply one of these two fixes so compilation works
on older toolchains.

In `@src/tests.rs`:
- Around line 40-56: The constructor currently hardcodes
previous_terminal_override = None instead of snapshotting the current
TERMINAL_OVERRIDE; add a test-only getter in src/config.rs (e.g.,
get_terminal_override_for_tests) that returns TERMINAL_OVERRIDE.with(|v|
*v.borrow()), then change the initialization in src/tests.rs to call that getter
so previous_terminal_override captures the real current value (mirror how
previous_mode and previous_no_color are captured) and keep
set_terminal_override_for_tests(terminal_override) unchanged.

---

Nitpick comments:
In `@src/color.rs`:
- Around line 31-42: The hex_to_rgb function currently only handles 6-character
hex strings; update it to also accept 3-character shorthand by detecting when
hex.len() == 3 and expanding it to 6 characters (duplicate each nibble, e.g.,
"f80" -> "ff8800") before calling u8::from_str_radix on the r/g/b slices; keep
the existing trim_start_matches('#') behavior and reuse the same parsing logic
(r/g/b via from_str_radix) so both 3- and 6-char inputs are supported.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2ee8bfff-43f2-4dfb-b16e-0f15b744b071

📥 Commits

Reviewing files that changed from the base of the PR and between 3959ef1 and e4b395a.

📒 Files selected for processing (8)
  • README.md
  • TODO.md
  • examples/basic.rs
  • src/color.rs
  • src/config.rs
  • src/lib.rs
  • src/style.rs
  • src/tests.rs

Comment thread src/config.rs
Comment thread src/tests.rs
seapagan added 3 commits April 4, 2026 13:48
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/tests.rs (1)

73-97: Comprehensive parametrised tests for colour and style rendering.

Good use of rstest for data-driven tests covering all named colours, bright variants, and background colours. The pattern of matching on string case names works but could be simplified.

♻️ Optional: Simplify with macro-generated tests

If the match arms become unwieldy, consider using a macro or mapping the colour name string directly to a method via a helper, though the current approach is perfectly readable.

Also applies to: 99-121, 123-147

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests.rs` around lines 73 - 97, The test_basic_colors function currently
matches on string names to call methods like text.red(), text.green(), etc.;
simplify and reduce repetition by creating a helper that maps the color name to
the corresponding coloring function (or use a macro) and call it in the test,
e.g., add a helper fn apply_color(s: &str, text: &str) -> String used by
test_basic_colors instead of the long match; keep the
TestStateGuard::colors_enabled(ColorMode::Always) setup and the existing rstest
cases and assert_eq!(actual, expected) unchanged, only replace the match block
with a single call to the helper/macro that selects and applies the correct
method (red/green/yellow/blue/magenta/cyan/white/black).
src/config.rs (1)

70-76: NO_COLOR handling is correct but consider documenting the precedence.

The implementation correctly respects the NO_COLOR environment variable per the no-color.org specification. However, it's worth noting that NO_COLOR takes precedence even when ColorMode::Always is set—this might surprise users who expect "Always" to mean unconditional output.

Consider adding a brief doc comment to should_colorize() or ColorMode::Always clarifying that NO_COLOR still disables output in that mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 70 - 76, Add a brief doc comment clarifying
precedence of NO_COLOR: update the documentation on should_colorize() (or
alternatively on the ColorMode::Always variant) to state that the NO_COLOR
environment variable is respected and will disable color output even when
ColorMode::Always is selected; reference ColorizeConfig::color_mode() and the
should_colorize() behavior in the comment so readers know the implementation
intentionally checks std::env::var_os("NO_COLOR") first.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/tests.rs`:
- Around line 46-49: The calls to env::set_var and env::remove_var in the match
on no_color must be wrapped in unsafe blocks to satisfy Rust 2024 rules; update
the match in tests.rs (where no_color is handled) so that both branches call
env::set_var(...) and env::remove_var(...) inside unsafe { ... }, keeping the
same values and behavior and ensuring any surrounding mutex/serialisation
remains intact.

---

Nitpick comments:
In `@src/config.rs`:
- Around line 70-76: Add a brief doc comment clarifying precedence of NO_COLOR:
update the documentation on should_colorize() (or alternatively on the
ColorMode::Always variant) to state that the NO_COLOR environment variable is
respected and will disable color output even when ColorMode::Always is selected;
reference ColorizeConfig::color_mode() and the should_colorize() behavior in the
comment so readers know the implementation intentionally checks
std::env::var_os("NO_COLOR") first.

In `@src/tests.rs`:
- Around line 73-97: The test_basic_colors function currently matches on string
names to call methods like text.red(), text.green(), etc.; simplify and reduce
repetition by creating a helper that maps the color name to the corresponding
coloring function (or use a macro) and call it in the test, e.g., add a helper
fn apply_color(s: &str, text: &str) -> String used by test_basic_colors instead
of the long match; keep the TestStateGuard::colors_enabled(ColorMode::Always)
setup and the existing rstest cases and assert_eq!(actual, expected) unchanged,
only replace the match block with a single call to the helper/macro that selects
and applies the correct method (red/green/yellow/blue/magenta/cyan/white/black).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2bce24af-c6a6-4ea0-986b-53c7fde58367

📥 Commits

Reviewing files that changed from the base of the PR and between e4b395a and 8561c33.

📒 Files selected for processing (4)
  • README.md
  • TODO.md
  • src/config.rs
  • src/tests.rs
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • TODO.md

Comment thread src/tests.rs
Comment on lines +46 to +49
match no_color {
Some(value) => env::set_var("NO_COLOR", value),
None => env::remove_var("NO_COLOR"),
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

When did Rust mark env::set_var as unsafe and which edition requires unsafe blocks?

💡 Result:

Rust marked std::env::set_var as unsafe starting with the 2024 edition. In the 2024 edition, calls to set_var require an unsafe block, while previous editions (up to 2021) allow it without unsafe. This change was implemented via pull request #124636, merged around May 2024, to address soundness issues in multi-threaded programs on Unix-like systems due to POSIX setenv being MT-unsafe.

Citations:


Prepare for Rust 2024 edition: unsafe blocks required for env::set_var and env::remove_var

These functions require unsafe blocks in the 2024 edition due to soundness issues in multi-threaded contexts. Although the mutex serialisation in this code makes the operations safe in practice, you'll need to wrap these calls in unsafe blocks when adopting the 2024 edition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tests.rs` around lines 46 - 49, The calls to env::set_var and
env::remove_var in the match on no_color must be wrapped in unsafe blocks to
satisfy Rust 2024 rules; update the match in tests.rs (where no_color is
handled) so that both branches call env::set_var(...) and env::remove_var(...)
inside unsafe { ... }, keeping the same values and behavior and ensuring any
surrounding mutex/serialisation remains intact.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont plan to adopt 2024 yet. I have already looked at the upgrade requirements and pros/cons - for now, it does not hurt this small library to stay on 2021

Signed-off-by: Grant Ramsay <seapagan@gmail.com>
@seapagan seapagan merged commit 53a0b5a into main Apr 4, 2026
2 checks passed
@seapagan seapagan deleted the feat/modernize-color-model branch April 4, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor Code refactoring or improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant