Skip to content

Badges, size bars, formatters and redaction

Four small tools for status lines, reports and logs:

  • Badges (rich_ext::badge): compact chips for statuses, labels, links and metadata. They still make sense without colour.
  • Size bars (rich_ext::size_bar): a size against a total or a limit.
  • Formatters (rich_ext::format): sizes, rates, durations, times, percentages and counts written the way people read them.
  • Redaction (rich_ext::redact): masks secrets in strings, terminal output, rendered segments and exports.

The examples come from guide_badges.rs. None of these modules needs a feature:

cargo run -p rs-rich-ext --example guide_badges

The styles are theme keys (badge.*, size_bar.*). Pass rich_ext::extended_theme() to the console builder to get them, or define them in your own theme. Without them the same defaults are used.

Badges

A Badge is one of four kinds:

Constructor Colour Plain
Badge::status(Status::Ok, "build") ✔ build on badge.ok [OK build]
Badge::status(Status::Error, "") ✖ error on badge.error [ERROR]
Badge::label("beta") beta on badge.label [beta]
Badge::link("docs", url) docs, an OSC 8 link [docs <url>]
Badge::meta("version", "1.2.0") version 1.2.0 on badge.key and badge.value [version: 1.2.0]

Badges puts several in a row. When the row is too long it wraps between badges, never inside one:

fn release_badges() -> Badges {
    Badges::new([
        Badge::status(Status::Ok, "build"),
        Badge::status(Status::Error, "tests"),
        Badge::status(Status::Warning, "lint"),
        Badge::status(Status::Pending, "deploy"),
        Badge::label("beta"),
        Badge::meta("version", "0.0.11"),
        Badge::link("docs", "https://example.com/docs"),
    ])
}

fn show_badges(console: &Console) {
    console.print(&release_badges());
}

A row of badges

Without colour

Colour is never the only signal. When the console has no colour system, or colour is turned off (NO_COLOR, no_color(true)), each badge gets its plain form. The text then carries the meaning:

fn show_plain(console: &Console) {
    // No colour system, NO_COLOR or no_color(true): brackets and tags
    // carry the meaning, and a link spells out its URL.
    let plain = Console::builder().width(60).color_system(None).build();
    console.print(&Text::new(plain.render_to_string(&release_badges())));
    // The same text for screen readers and logs.
    assert!(release_badges()
        .plain()
        .starts_with("[OK build] [ERROR tests]"));
}

The same badges without colour

  • Status chips use the markers of a11y::Status: a glyph with colour, an ASCII tag (OK, WARN, ERROR) without it or on an ASCII-only console. .symbols(SymbolSet::Words) gives [ok: build].
  • A link is an OSC 8 hyperlink wherever the console renders styles. This includes NO_COLOR, which removes only the colours. Where there are no styles at all, the URL is written after the text. .show_url(true) or .show_url(false) overrides this.
  • .style(..) replaces a badge's theme key with another key ("badge.warning") or with a style definition ("black on magenta").
  • badge.plain() and AccessibleText return the plain form, for logs and screen readers.

A Badge or Badges measures to its width, so it can go in a table cell (Cell::Renderable). See the table below.

Size bars

SizeBar::new(used, total) shows a part of a total, such as a file in a bundle. SizeBar::limit(used, limit) shows a size that should stay under a limit, such as a package against a registry's cap. It switches to the size_bar.high style from 90% (.warn_at(ratio) changes that).

fn show_sizes(console: &Console) {
    let limit = 10_000_000;
    for (name, size) in [
        ("rs-rich", 2_400_000),
        ("rs-rich-ext", 9_300_000),
        ("rs-rich-art", 12_600_000),
    ] {
        console.print(&SizeBar::limit(size, limit).label(format!("{name:<12}")));
    }
    console.print(
        &SizeBar::new(3 << 30, 8 << 30)
            .label("disk        ")
            .units(Units::Binary),
    );
}

Size bars against a limit and a total

Going over a limit is never shown by colour alone:

  • the part of the bar past the limit uses its own glyph, ▓;
  • the percentage is over 100%;
  • the line ends with over by ….

On an ASCII-only console the bar is drawn with # (used), . (free) and ! (over). Sizes use format::bytes, or format::bytes_binary with .units(Units::Binary). The bar is 20 cells wide by default (.bar_width(n)). It shrinks to 4 cells when the line does not fit. If even that is too wide, the line is cut with an ellipsis rather than wrapped. .show_sizes(false) and .show_percent(false) leave those parts out.

Bars and badges both work in table cells:

fn show_table(console: &Console) {
    let total = 6_000_000;
    let mut table = Table::new().title("Bundle");
    table.add_column("File");
    table.add_column("Size");
    table.add_column("Checks");
    for (file, size, ok) in [
        ("app.wasm", 3_900_000, true),
        ("vendor.js", 1_500_000, false),
        ("styles.css", 120_000, true),
    ] {
        let status = if ok { Status::Ok } else { Status::Warning };
        table.add_row_cells(vec![
            Cell::Markup(file.into()),
            Cell::Renderable(Arc::new(SizeBar::new(size, total).bar_width(12))),
            Cell::Renderable(Arc::new(Badge::status(status, "size"))),
        ]);
    }
    console.print(&table);
}

A table with size bars and status badges

Formatters

rich_ext::format holds pure functions that return strings, so the results work in any cell, label or log line. They use . as the decimal point, , between thousands, English words and UTC.

fn show_format(console: &Console) {
    let then = UNIX_EPOCH + Duration::from_secs(1_790_000_000);
    let rows = [
        ("bytes(1_500_000)", format::bytes(1_500_000)),
        ("bytes_binary(1_572_864)", format::bytes_binary(1_572_864)),
        ("rate(2_400_000.0)", format::rate(2_400_000.0)),
        (
            "duration(3723 s)",
            format::duration(Duration::from_secs(3723)),
        ),
        ("clock(3723 s)", format::clock(Duration::from_secs(3723))),
        (
            "relative(then, then + 3 h)",
            format::relative(then, then + Duration::from_secs(3 * 3600)),
        ),
        ("timestamp(then)", format::timestamp(then)),
        ("percent(0.4251, 1)", format::percent(0.4251, 1)),
        ("number(1234567)", format::number(1_234_567)),
        ("compact(1_250_000.0)", format::compact(1_250_000.0)),
    ];
    let mut table = Table::new();
    table.add_column("Call");
    table.add_column("Result");
    for (call, result) in rows {
        table.add_row(&[call, &result]);
    }
    console.print(&table);
}

Each formatter and its output

Function Gives
bytes(n) Decimal units, as upstream's filesize.decimal: 1.5 MB
bytes_binary(n) Binary units: 1.5 MiB
rate(bytes_per_second) 2.4 MB/s; negative or non-finite rates read 0 bytes/s
duration(d) / duration_with(d, ascii) Two largest units: 850µs, 4.2s, 3m 07s, 2d 04h (us when ASCII)
clock(d) H:MM:SS, as progress columns show it
relative(then, now) just now, 3 hours ago, in 2 days
timestamp(t) ISO 8601 UTC to the second
percent(ratio, decimals) 42.5%; non-finite reads -
number(n) 1,234,567
compact(n) 1.2k, 3.4M, truncated so 1999 is never 2.0k

Redaction

Experimental: check the output yourself

rich_ext::redact and rich capture --redact are experimental. The detectors, masks and API may change in any release.

Redaction is best effort, not a guarantee. It only knows the secret shapes listed below, matches within one line, and cannot tell a secret from ordinary text when it looks like ordinary text. Always read redacted output before you share, publish or store it.

If a secret gets through, or anything else does not work as expected, please report a bug with an example (with the real secret replaced).

A Redactor masks secrets before they reach the terminal, a log, an export or a recording. Redactor::secrets() turns on the built-in detectors:

Detector Masks
KeyValue The value in key=value, key: value, "key": "value" or --key=value when the key looks secret. A quoted value is masked up to its closing quote, spaces and commas included
Bearer The token after Bearer (12+ characters with a digit)
TokenPrefix GitHub (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_), GitLab (glpat-), Slack (xox?-), Stripe secret keys (sk_live_, rk_test_, …), npm (npm_) and sk- API keys
AwsAccessKey AKIA… / ASIA… access key ids
Jwt eyJ….eyJ….… JSON Web Tokens
UrlCredentials The password in scheme://user:password@host, up to the last @ before the host. A bare user:password@host without a scheme is not matched

A key looks secret when redact::is_secret_key matches it against redact::SECRET_KEYS: password, secret, token, api_key, private_key, credential and the like anywhere in the key, case-insensitive, with - and _ interchangeable (so api-key and API-KEY match), plus the whole-key globs *auth, auth_* and authorization. This is the same list that data::Redaction::secrets() uses for structured documents (where everything under a secret key, including XML element text, is masked; see Structured data), so author is never masked. The set is kept small to avoid false positives. It is pattern matching, so it can miss a secret that looks like an ordinary word.

Add your own rules with .pattern(regex) or .named_pattern(name, regex). If the pattern has a group named secret, only that group is masked. The mask is ******** by default. .mask("[{kind}]") writes the rule's name instead, for example [jwt]. Matching fails closed: if a pattern gives up at run time (too much backtracking), the rest of that line is masked.

fn show_redact(console: &Console) {
    let log = Text::new(
        "GET /api?token=abc123 200\n\
         Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln\n\
         push with ghp_0123456789abcdefghijklmnopqrstuvwxyzAB\n\
         DATABASE_URL=postgres://app:s3cret@db/app\n\
         order 1234-5678 shipped",
    );
    let redactor = Redactor::secrets()
        .named_pattern("order", r"order (?P<secret>\d{4})")
        .expect("valid pattern");
    // Masks keep the cells they replace, so the border stays put.
    console.print(&Redacted::new(
        Panel::new(Box::new(log)).title("server.log"),
        redactor,
    ));
}

A log panel with its secrets masked

Where it applies

Input Method
A string or log line redact_str
Text with ANSI escapes redact_ansi: rules see the visible text, the escapes stay
A stream of chunks (a recording) redact_chunks: a secret split across chunks is still found
Raw bytes from a pipe redact_byte_chunks: a character split between reads stays whole, invalid bytes are kept
A command line redact_args: also masks the value of a secret-named flag (--token X)
Rendered segments redact_segments, or wrap a renderable in Redacted
A recording to export capture, export_text, export_html, export_html_classes, export_svg

In segments, a secret can span several segments with different styles. Rules match on each line's text. Each part of the mask keeps the style of the cells it covers, and the line keeps its width, so borders and columns stay aligned. To get the same in strings, use .preserve_width(true).

Hidden text is searched too. With ANSI text, that is the body of each escape string: an OSC 8 hyperlink's URL, a window title, DCS and APC payloads. With segments, it is each style's link target. There the plain mask is used, with any control characters in it turned into *, so the escape stays well formed.

The export helpers record what the closure prints, redact it, and export it. The secret never reaches the file:

fn redacted_exports(console: &Console) -> (String, String) {
    let redactor = Redactor::secrets();
    let print = |c: &Console| c.print(&Text::new("password=hunter2"));
    // Record, redact, export: the secret never reaches the file.
    let svg = redactor.export_svg(console, "Redacted", "redacted", print);
    let html = redactor.export_html(console, print);
    // Or plain strings, such as log lines.
    assert_eq!(redactor.redact_str("api_key: abc"), "api_key: ********");
    (svg, html)
}

For another terminal theme, call console.record_output(f), then redactor.redact_segments(..), then the core rich::export or rich::svg function.

What redaction cannot see

Rules match within one line. If a renderable wraps a long secret onto two lines, neither half matches. When that can happen, redact the input with redact_str before you render it. Masks that keep their width also reveal the secret's length. A mask inside a binary escape payload, such as an inline image, can spoil the image. Eight-bit C1 controls are read as text, not as escapes.

From the command line

rich capture takes the same detectors. Masking happens before the output is shown, exported or recorded:

rich capture --redact --export-svg run.svg --cast run.cast -- ./deploy.sh
rich capture --redact-pattern 'order (?P<secret>\d{4})' -- ./report.sh

See Using the CLI.