Colored Terminal Output
20 min read
How to add color and styling to a command-line tool’s output in Rust, the way you would with chalk or picocolors in Node.js — and, crucially, how to make that color disappear cleanly when output is piped to a file or when the user sets NO_COLOR.
Quick Overview
Section titled “Quick Overview”Color in a terminal is just ANSI escape codes — short byte sequences like \x1b[32m (green on) and \x1b[39m (default foreground) wrapped around your text. In Node.js you reach for chalk, picocolors, or kleur; in Rust the go-to crates are owo-colors (zero-cost styling extension methods), anstream (a smart stdout/stderr that strips color when it shouldn’t be there), anstyle (the style vocabulary clap and Cargo speak), and console (batteries-included, auto-detecting). The single most important thing this page teaches: respect NO_COLOR and don’t emit escape codes into pipes — getting this right is the difference between a polished tool and one that dumps \x1b[32m garbage into log files.
The current stable toolchain is Rust 1.96.0 on the 2024 edition; cargo new selects it automatically.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In Node.js, chalk is the household name, though the tiny picocolors has largely won on size and speed. Both auto-detect whether output is a terminal and honor the NO_COLOR / FORCE_COLOR conventions for you.
// npm install chalk (chalk v5 is ESM-only)import chalk from "chalk";
// Basic colors and stylesconsole.log(chalk.green("Success!"));console.log(chalk.yellow("Warning!"));console.log(chalk.red.bold("Error!"));console.log(`${chalk.blue("INFO")} Server started`);
// Background colors, combinations, truecolorconsole.log(chalk.white.bgRed.bold("Alert"));console.log(chalk.dim("dimmed note"));console.log(chalk.underline("underlined"));console.log(chalk.rgb(255, 128, 0)("custom rgb"));
// A cargo-style diagnosticfunction report(level: "info" | "warning" | "error", message: string) { const tag = { info: chalk.green.bold("info"), warning: chalk.yellow.bold("warning"), error: chalk.red.bold("error"), }[level]; const line = `${tag}: ${message}`; if (level === "error") console.error(line); else console.log(line);}
report("info", "compiling 12 modules");report("error", "could not build");Chalk decides at runtime whether to emit codes. If you run node app.js | cat, or set NO_COLOR=1, chalk produces plain text automatically. That auto-detection is the behavior we want to reproduce in Rust — and as you’ll see, not every Rust crate does it for you by default.
Rust Equivalent
Section titled “Rust Equivalent”The idiomatic modern stack is owo-colors for the styling syntax plus anstream for a terminal-aware output stream. owo-colors gives you chalk-like extension methods; anstream’s println! is a drop-in that strips the codes when the destination is not a real terminal or when NO_COLOR is set.
Add the dependencies (in a fresh cargo new project):
cargo add owo-colorscargo add anstreamuse anstream::println; // terminal-aware: strips ANSI when not a TTY / NO_COLOR setuse owo_colors::OwoColorize;
fn main() { // Basic colors and styles — same vocabulary as chalk println!("{}", "Success!".green()); println!("{}", "Warning!".yellow()); println!("{}", "Error!".red().bold()); println!("{} {}", "INFO".blue(), "Server started");
// Background colors, combinations, truecolor println!("{}", "Alert".white().on_red().bold()); println!("{}", "dimmed note".dimmed()); println!("{}", "underlined".underline()); println!("{}", "custom rgb".truecolor(255, 128, 0));}Tip: The split is deliberate. owo-colors only describes the style; anstream decides whether to keep it. You can swap either piece independently — e.g. use owo-colors with a plain
std::println!and your own detection, or use anstream withanstyleinstead of owo-colors.
Here is the same report helper, written so color is automatically disabled in pipes and under NO_COLOR:
use anstream::{eprintln, println};use owo_colors::OwoColorize;use std::fmt::Display;
enum Level { Info, Warn, Error,}
impl Level { fn label(&self) -> String { match self { Level::Info => "info".green().bold().to_string(), Level::Warn => "warning".yellow().bold().to_string(), Level::Error => "error".red().bold().to_string(), } }}
fn report(level: Level, message: impl Display) { let line = format!("{}: {}", level.label(), message); match level { Level::Error => eprintln!("{line}"), _ => println!("{line}"), }}
fn main() { report(Level::Info, "compiling 12 crates"); report(Level::Warn, "unused import: `std::env`"); report(Level::Error, "could not compile `app` (bin \"app\")");}Run through a pipe, the ANSI codes are gone:
cargo run -q | catinfo: compiling 12 crateswarning: unused import: `std::env`(The error line goes to stderr, so it doesn’t show in the piped stdout — exactly like Cargo.)
Detailed Explanation
Section titled “Detailed Explanation”owo-colors: zero-cost styling via an extension trait
Section titled “owo-colors: zero-cost styling via an extension trait”use owo_colors::OwoColorize; brings a trait into scope that adds methods like .green(), .bold(), and .on_red() to every type that implements Display (and Debug). This is the same ergonomic trick TypeScript devs know from prototype extension, but checked at compile time.
The key subtlety: .green() does not return a String. It returns a tiny wrapper struct — FgColorDisplay<'_, Red, &str> (here Red is shorthand for owo_colors::colors::Red, the fully-qualified path the compiler prints in the Pitfall 1 error below) — that holds a reference to your value and remembers the style. No allocation, no escape codes are produced until the value is actually formatted. When you eventually println!("{}", x.green()), the wrapper’s Display implementation writes \x1b[32m, then your text, then \x1b[39m. This laziness is why chaining (.red().bold()) is free and why owo-colors advertises itself as zero-allocation.
Piping the basic example to a file and inspecting the raw bytes makes the codes visible (here shown as Rust byte-string escapes):
"\x1b[32mSuccess!\x1b[39m\n" // .green()"\x1b[1m\x1b[31mError!\x1b[39m\x1b[0m\n" // .red().bold()"\x1b[1m\x1b[37;41mAlert\x1b[0m\x1b[0m\n" // .white().on_red().bold()"\x1b[38;2;255;128;0mcustom rgb\x1b[39m\n" // .truecolor(255,128,0)\x1b[32m turns the foreground green; \x1b[39m resets just the foreground; \x1b[1m is bold and \x1b[0m is a full reset; \x1b[38;2;R;G;Bm is 24-bit truecolor.
The catch: owo-colors styles unconditionally
Section titled “The catch: owo-colors styles unconditionally”By default, "x".green() always emits the codes — it does no terminal detection. That is the opposite of chalk’s default. If you println! it straight to a redirected stdout, the escape bytes land in the file. That’s why we pair it with anstream.
anstream: a terminal-aware println!
Section titled “anstream: a terminal-aware println!”anstream provides anstream::stdout() / anstream::stderr() and the macros anstream::println! / anstream::eprintln!. They wrap the real stream in an adapter that:
- Keeps ANSI codes when the stream is a real terminal that supports them.
- Strips ANSI codes when the stream is redirected to a file or pipe.
- Strips codes when
NO_COLORis set (to a non-empty value). - On legacy Windows consoles without ANSI support, translates codes into the appropriate console API calls.
So you keep styling unconditionally with owo-colors and let anstream make the keep-or-strip decision once, at the output boundary. Running the build-reporter example four ways shows the decision matrix (raw bytes shown):
# Piped (stdout is not a terminal) — codes stripped:"info: compiling 12 crates\n"
# Real terminal, NO_COLOR unset — codes kept:"\x1b[1m\x1b[32minfo\x1b[39m\x1b[0m: compiling 12 crates\n"
# Real terminal, NO_COLOR=1 — codes stripped:"info: compiling 12 crates\n"
# Piped but CLICOLOR_FORCE=1 — codes kept (forced on):"\x1b[1m\x1b[32minfo\x1b[39m\x1b[0m: compiling 12 crates\n"These four behaviors are the entire job of a well-behaved CLI’s color handling, and anstream gives them to you for free.
owo-colors’ built-in detection: if_supports_color
Section titled “owo-colors’ built-in detection: if_supports_color”If you’d rather not route output through anstream, owo-colors can do the detection itself via if_supports_color. Enable the feature first:
cargo add owo-colors --features supports-colorsuse owo_colors::{OwoColorize, Stream};
fn main() { // Styles ONLY when the chosen stream supports color // (TTY + NO_COLOR honored + terminal capability check). println!( "{}", "conditional".if_supports_color(Stream::Stdout, |t| t.green()) );}The closure runs (applying the style) only when the stream supports color. Verified across environments (raw bytes):
# Piped (not a TTY): "conditional\n"# Real terminal, NO_COLOR unset: "\x1b[32mconditional\x1b[39m\n"# Real terminal, NO_COLOR=1: "conditional\n"Under the hood this uses the supports-color crate, which is also what powers the optional feature’s checks for NO_COLOR, CLICOLOR, CLICOLOR_FORCE, and TERM=dumb.
console: the batteries-included, auto-detecting option
Section titled “console: the batteries-included, auto-detecting option”The console crate (from the indicatif family — see progress-bars.md) takes the chalk approach: console::style(..) returns a StyledObject that auto-detects by default, so you don’t need a separate stream.
cargo add consoleuse console::style;
fn main() { // Auto-detects TTY + NO_COLOR/CLICOLOR; only emits ANSI when appropriate. println!("{}", style("Deploy succeeded").green().bold()); println!("{}", style("Retrying...").yellow());
// Override the decision explicitly when you must: println!("{}", style("always red").red().force_styling(true));}Piped, the auto-detecting styles vanish but the forced one survives (raw bytes):
"Deploy succeeded\nRetrying...\n\x1b[31malways red\x1b[0m\n"This is the closest one-crate analog to chalk’s behavior.
anstyle: the shared style vocabulary
Section titled “anstyle: the shared style vocabulary”anstyle is a tiny, dependency-free crate that defines Style, Color, and AnsiColor. It is what clap, Cargo, and anstream use to talk about styles in a neutral way. You rarely reach for it directly unless you’re integrating with clap’s help coloring (see clap-derive.md), but it’s worth recognizing:
use anstyle::{AnsiColor, Color, Style};
fn main() { let heading = Style::new() .bold() .fg_color(Some(Color::Ansi(AnsiColor::Green)));
// `{heading}` writes the opening sequence; `{heading:#}` writes the reset. println!("{heading}Section{heading:#}"); // Equivalent, spelled out: println!("{}Section{}", heading.render(), heading.render_reset());}Both lines produce the same bytes — \x1b[1m\x1b[32mSection\x1b[0m. anstyle is just the codes; it has no opinion about detection, which is precisely why higher-level crates build on it.
Key Differences
Section titled “Key Differences”| Concern | TypeScript (chalk / picocolors) | Rust |
|---|---|---|
| How styling attaches | Functions wrapping strings | Trait methods (OwoColorize) on any Display type, or style(..) wrappers |
Return value of .green() | A new string | A lazy zero-cost wrapper (owo-colors) or StyledObject (console) — not a String |
| Auto terminal detection | Built in, on by default | owo-colors: off by default; console: on; anstream: on at the stream |
NO_COLOR honored | Yes, automatically | Yes — via anstream, console, or owo-colors’ supports-colors feature; not by raw owo-colors |
| Force color | FORCE_COLOR=1 | CLICOLOR_FORCE=1 (anstream/console), or explicit force_styling(true) / set_override(true) |
| Output target awareness | One console.log | Choose: terminal-aware anstream::println! vs raw std::println! |
The headline conceptual difference: chalk couples styling and detection; the Rust ecosystem deliberately decouples them. owo-colors answers “what does green look like?” while anstream answers “should this stream show green right now?”. This separation is more verbose for a hello-world, but it scales: you set the policy once at the output boundary and never branch on NO_COLOR in business logic. If you want the chalk-style all-in-one feel, reach for console.
Note:
NO_COLOR(see https://no-color.org) is a cross-language convention: if the variable is present and non-empty, applications should not emit color.CLICOLOR_FORCE(non-empty) forces color on even when piped. These are the same conventions Cargo, ripgrep, and most modern CLIs follow.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Treating a styled value like a String
Section titled “Pitfall 1: Treating a styled value like a String”A TypeScript dev expects "error".red() to be a string and tries to +-concatenate it like JavaScript:
use owo_colors::OwoColorize;
fn main() { let msg = "error".red() + ": something broke"; // does not compile (error[E0369]) println!("{}", msg);}The real compiler error:
error[E0369]: cannot add `&str` to `FgColorDisplay<'_, owo_colors::colors::Red, &str>` --> src/main.rs:6:29 |6 | let msg = "error".red() + ": something broke"; | ------------- ^ ------------------- &str | | | FgColorDisplay<'_, owo_colors::colors::Red, &str> |note: the foreign item type `FgColorDisplay<'_, owo_colors::colors::Red, &str>` doesn't implement `Add<&str>`.red() returns a lazy display wrapper, not a String. The fix is to format the pieces together rather than add them:
use owo_colors::OwoColorize;
fn main() { let msg = format!("{}: something broke", "error".red()); println!("{msg}");}Pitfall 2: Using std::println! with owo-colors and leaking codes
Section titled “Pitfall 2: Using std::println! with owo-colors and leaking codes”Because raw owo-colors never detects the terminal, this writes escape bytes into a redirected file:
use owo_colors::OwoColorize;
fn main() { // std::println! does NO detection; owo-colors does NO detection. println!("{}", "result".green()); // leaks \x1b[32m...\x1b[39m into pipes/files}Run cargo run | cat and you’ll see literal \x1b[32m in the output — the exact garbage-in-logs problem. Fix: import anstream::println (or use console::style, or gate with if_supports_color). One-line change:
use anstream::println; // ← decides keep-or-strip per streamuse owo_colors::OwoColorize;
fn main() { println!("{}", "result".green()); // stripped when piped, kept on a TTY}Pitfall 3: Padding a value that already contains ANSI bytes
Section titled “Pitfall 3: Padding a value that already contains ANSI bytes”Alignment specifiers count bytes, and ANSI escape sequences are bytes. owo-colors’ lazy wrapper is smart — it forwards the format spec to the inner text, so {:<10} pads correctly. But the moment you render a styled value into a String (with .to_string() or format!) and then try to pad it, the width counts the invisible escape bytes:
use owo_colors::OwoColorize;
fn main() { // Pre-rendered into a String — width now counts the escape bytes: let pre_rendered = "OK".green().to_string(); println!("[{:<10}]", pre_rendered); // NOT padded to 10 visible columns}Raw bytes: [\x1b[32mOK\x1b[39m] — no padding at all, because the formatter saw a 10-byte-ish string of mostly escape characters. Fix: pad the plain text first, then style the padded result:
use owo_colors::OwoColorize;
fn main() { let padded = format!("{:<10}", "OK"); println!("[{}]", padded.green()); // visible "OK" + 8 spaces, then colored}Pitfall 4: Forgetting to enable the supports-colors feature
Section titled “Pitfall 4: Forgetting to enable the supports-colors feature”If you write use owo_colors::{OwoColorize, Stream}; and call if_supports_color without the feature, you actually get two errors. First, the unresolved import of Stream (E0432) — and this is the one that carries the “configured out / gated behind supports-colors” note. Second, a separate “method not found” (E0599) for if_supports_color, with no note attached:
error[E0432]: unresolved import `owo_colors::Stream` --> src/main.rs:1:31 |1 | use owo_colors::{OwoColorize, Stream}; | ^^^^^^ no `Stream` in the root |note: found an item that was configured out | | --------------------------- the item is gated behind the `supports-colors` feature... | supports_colors::{Stream, SupportsColorsDisplay}, | ^^^^^^
error[E0599]: no method named `if_supports_color` found for reference `&'static str` in the current scope --> src/main.rs:6:23 |6 | "conditional".if_supports_color(Stream::Stdout, |t| t.green()) | ^^^^^^^^^^^^^^^^^ method not found in `&'static str`The lesson: the telltale “gated behind the supports-colors feature” note hangs off the Stream import error, not the method error. Fix: cargo add owo-colors --features supports-colors. (Or just use anstream/console, which need no extra feature flags.)
Best Practices
Section titled “Best Practices”-
Decide color once, at the output boundary. Route all user-facing output through
anstream::stdout()/stderr()(or their macros). Keep your styling code unconditional and free ofif NO_COLORchecks. -
Respect
NO_COLORandCLICOLOR_FORCE. Don’t roll your own env parsing if you can avoid it — anstream, console, and owo-colors’supports-colorsfeature all implement the conventions correctly. The check is:NO_COLORpresent and non-empty disables;CLICOLOR_FORCEpresent and non-empty forces on. -
Send errors and diagnostics to stderr, normal output to stdout — and detect each independently. anstream’s
stdout()andstderr()evaluate their own stream, so a tool whose stdout is piped but whose stderr is a terminal still colorizes errors. -
Prefer named ANSI colors over truecolor for portability.
.green()works on virtually every terminal;.truecolor(...)only renders correctly on 24-bit-capable terminals and silently degrades elsewhere. -
Give users an explicit
--color <auto|always|never>flag, mapping to anstream/owo-colors overrides. This is what Cargo and ripgrep do, and power users expect it. With owo-colors,owo_colors::set_override(true|false)sets a process-wide decision thatif_supports_colorhonors:use owo_colors::{OwoColorize, Stream};fn main() {owo_colors::set_override(false); // e.g. from `--color never`println!("{}","forced off".if_supports_color(Stream::Stdout, |t| t.red())); // prints plain, even on a TTY} -
Don’t hand-write escape codes like
"\x1b[32m". They’re error-prone (easy to forget the reset), don’t get stripped automatically, and break on non-ANSI consoles. Let a crate own the bytes.
Warning: A subtle correctness issue: a manual or pre-rendered ANSI string that you later pass through a width/truncation formatter (
{:.10},{:<10}) will miscount, because escape bytes are counted but not displayed. Style after you’ve finished sizing the plain text. See Pitfall 3.
Real-World Example
Section titled “Real-World Example”A small cargo-style diagnostic reporter — the kind of output a linter or build tool emits — that colorizes on a terminal, goes plain in pipes and under NO_COLOR, and sends errors to stderr. This is compile-verified end to end.
Note: This example uses the unconditional-styling pattern (owo-colors always emits codes;
anstreamstrips them at the boundary). With this pattern the user-facing color knobs are the environment variablesanstreamreads —NO_COLORandCLICOLOR_FORCE— notowo_colors::set_override.set_overrideonly governsif_supports_colorandStyle-based rendering (see Exercise 2), neither of which this code uses, so adding aset_overridebranch here would be a dead no-op. A real--color <auto|always|never>flag must instead be wired intoanstream’sColorChoice(e.g. viaanstream::AutoStreamconstructed with an explicit choice). Exercise 2 shows the override path that does respond toset_override.
Cargo.toml:
[dependencies]anstream = "1.0.0"owo-colors = "4.3.0"src/main.rs:
use anstream::{eprintln, println};use owo_colors::OwoColorize;use std::fmt::Display;
/// A diagnostic severity, like a compiler or linter would emit.enum Level { Info, Warn, Error,}
impl Level { /// The styled label. We always apply the style here; `anstream` strips it /// later if the destination is not a color-capable terminal. fn label(&self) -> String { match self { Level::Info => "info".green().bold().to_string(), Level::Warn => "warning".yellow().bold().to_string(), Level::Error => "error".red().bold().to_string(), } }}
/// Print a `level: message` line, routing errors to stderr.fn report(level: Level, message: impl Display) { let line = format!("{}: {}", level.label(), message); match level { Level::Error => eprintln!("{line}"), _ => println!("{line}"), }}
fn main() { // No per-call color branching: styling is unconditional and `anstream` // strips it when stdout/stderr isn't a color-capable terminal. The user // controls color through the environment `anstream` reads — `NO_COLOR` // disables it, `CLICOLOR_FORCE` forces it on. report(Level::Info, "compiling 12 crates"); report( Level::Warn, format!("unused variable: `{}`", "count".cyan()), ); report(Level::Error, "could not compile `app` (bin \"app\")");}Behavior, verified by inspecting raw bytes:
-
Piped (
cargo run -q | cat): every line is plain text; theerrorline is on stderr.info: compiling 12 crateswarning: unused variable: `count` -
On a real terminal (
NO_COLORunset): labels are colored,countis cyan. Raw stdout bytes:"\x1b[1m\x1b[32minfo\x1b[39m\x1b[0m: compiling 12 crates\n""\x1b[1m\x1b[33mwarning\x1b[39m\x1b[0m: unused variable: `\x1b[36mcount\x1b[39m`\n" -
NO_COLOR=1on a terminal: anstream strips everything; output is identical to the piped case.
This is the whole discipline in one screen: style freely, decide once, route errors correctly, and the conventions take care of themselves.
Further Reading
Section titled “Further Reading”Official Documentation
Section titled “Official Documentation”- owo-colors on docs.rs — the
OwoColorizetrait,if_supports_color,set_override. - anstream on docs.rs — terminal-aware streams and the strip/keep logic.
- anstyle on docs.rs — the shared
Style/Colorvocabulary. - console on docs.rs — auto-detecting
style()and terminal utilities. - supports-color on docs.rs — the detection crate behind the feature.
- The NO_COLOR convention and the CLICOLOR spec.
- std::io::IsTerminal — for rolling your own detection.
Related Sections in This Guide
Section titled “Related Sections in This Guide”- clap-derive.md and clap-basics.md —
clapusesanstyleto color its help output; wire a--colorflag here. - subcommands.md — git-like subcommands that share a coloring policy.
- progress-bars.md —
indicatifbuilds onconsolefor spinners and bars. - terminal-ui.md — full-screen TUIs with
ratatui(a different rendering model entirely). - environment-vars.md — reading
NO_COLOR/CLICOLOR_FORCEand other config via the environment. - cross-platform.md — Windows console quirks and ANSI support.
- distribution.md — shipping the finished tool.
- Foundations: Section 02 — Output and Formatting covers
println!,format!, and format specifiers used throughout this page. New to the toolchain? See Section 01 — Getting Started and Section 00 — Introduction. - Building a colorized WebAssembly logger for the browser console instead of a terminal? See Section 19 — WebAssembly.
Exercises
Section titled “Exercises”Exercise 1: A leak-proof status line
Section titled “Exercise 1: A leak-proof status line”Difficulty: Easy
Objective: Print a green [ OK ] prefix followed by a message, such that the color disappears when output is piped.
Instructions:
- Create a project and
cargo add owo-colors anstream. - Write
status("Database connected")that prints[ OK ] Database connectedwith[ OK ]in bold green. - Verify that
cargo run -q | catshows no escape codes, but a real terminal shows green.
use anstream::println;use owo_colors::OwoColorize;
fn status(message: &str) { // TODO: print a bold-green "[ OK ]" prefix, then the message}
fn main() { status("Database connected");}Solution
use anstream::println;use owo_colors::OwoColorize;
fn status(message: &str) { // Style freely; anstream's println! strips codes when not a TTY / NO_COLOR set. println!("{} {message}", "[ OK ]".green().bold());}
fn main() { status("Database connected");}Piped, this prints exactly [ OK ] Database connected with no escape bytes; on a terminal the prefix is bold green.
Exercise 2: Honor --color=<auto|always|never>
Section titled “Exercise 2: Honor --color=<auto|always|never>”Difficulty: Medium
Objective: Add a color policy flag that overrides auto-detection, mirroring Cargo and ripgrep.
Instructions:
- Read the first CLI argument. Map
--color=always,--color=never, and anything else (auto) to a decision. - Use
owo_colors::set_override(..)foralways/never; leave it untouched for auto. Enable thesupports-colorsfeature. - Print a line styled via
if_supports_colorand confirm:--color=alwayscolors even when piped;--color=neveris plain even on a TTY; with no flag, it follows the terminal andNO_COLOR.
use owo_colors::{OwoColorize, Stream};
fn main() { // TODO: parse args[1], set the override, then print a conditional-styled line}Solution
// Cargo.toml:// owo-colors = { version = "4.3.0", features = ["supports-colors"] }use owo_colors::{OwoColorize, Stream, Style};
fn main() { match std::env::args().nth(1).as_deref() { Some("--color=always") => owo_colors::set_override(true), Some("--color=never") => owo_colors::set_override(false), _ => {} // auto: if_supports_color does TTY + NO_COLOR detection }
// Build the combined style up front so the closure can apply it in one call. // (Chaining two methods inside the closure, like `|t| t.green().bold()`, // fails to compile — the second method would borrow a temporary.) let style = Style::new().green().bold(); println!( "{}", "build finished".if_supports_color(Stream::Stdout, |t| t.style(style)) );}set_override(true) forces the closure to run (color on) regardless of TTY/NO_COLOR; set_override(false) forces it off; with neither, if_supports_color consults the stream and the NO_COLOR/CLICOLOR environment.
Exercise 3: A padded, colorized two-column report
Section titled “Exercise 3: A padded, colorized two-column report”Difficulty: Hard
Objective: Print an aligned two-column table (label left, value right) with colored labels — without the alignment breaking on the escape bytes.
Instructions:
- Given pairs like
("Status", "online")and("Latency", "12ms"), print each aslabelleft-padded to 12 columns, then the value. - Color labels cyan and the
"online"value green. The columns must line up visually. - The catch: you must pad the plain label to width before styling it (recall Pitfall 3). Verify alignment by piping to
cat(codes stripped) — columns should still line up.
use anstream::println;use owo_colors::OwoColorize;
fn row(label: &str, value: &str) { // TODO: left-pad `label` to 12 columns of PLAIN text, then color it, // then print the value (green if "online", else default)}
fn main() { row("Status", "online"); row("Latency", "12ms"); row("Region", "us-east-1");}Solution
use anstream::println;use owo_colors::OwoColorize;
fn row(label: &str, value: &str) { // 1) Size the PLAIN text first so the width counts visible columns only. let padded = format!("{label:<12}"); // 2) Style the already-padded label. let styled_label = padded.cyan(); // 3) Conditionally color the value. if value == "online" { println!("{styled_label}{}", value.green().bold()); } else { println!("{styled_label}{value}"); }}
fn main() { row("Status", "online"); row("Latency", "12ms"); row("Region", "us-east-1");}Piped output (codes stripped by anstream) lines up correctly:
Status onlineLatency 12msRegion us-east-1On a terminal the labels are cyan and online is bold green, with the same alignment. Had we written format!("{:<12}", "Status".cyan()), the lazy owo-colors wrapper would actually still align (it forwards the spec) — but the moment you pre-render with .to_string(), alignment breaks. Padding the plain text first is the robust habit.