Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-humanize/src/lib.rs

//! # nornir-humanize
//!
//! The ONE shared **human-readable formatting** primitive for the nordisk
//! constellation — turn raw `u64`/`f64`/[`Duration`](std::time::Duration) values
//! into the short strings a report line, a table cell, or a UI label shows.
//!
//! ## Why it exists (LAW #5 — DRY)
//! Eight-plus near-identical `human_bytes` / `human_count` / `fmt_elapsed`
//! helpers grew independently across the constellation and quietly **drifted on
//! units**:
//!
//! | site | what | divisor | labels | separator |
//! |------|------|---------|--------|-----------|
//! | `nornir` `bin/nornir.rs` `human_bytes` | bytes | 1024 | `KiB…GiB` | space |
//! | `facett-pipeline` `human_bytes` (pub) | bytes | 1024 | `KiB…PiB` | space |
//! | `facett-plandag` `human_bytes` | bytes | 1024 | `K…T` (1-letter) | **none** |
//! | `facett-file` `human_size` | bytes | 1024 | `KB…GB` (**SI labels**) | space |
//! | `facett-pipeline` `human_count` (pub) | count | 1000 | `k M B` | none |
//! | `facett-plandag` `human_count` | count | 1000 | `K M` (**no B**) | none |
//! | `facett-jobview` `fmt_elapsed` | duration | — | `ms / s / m` | — |
//! | `facett-cicd` `fmt_dur` | duration | — | `s / m` | — |
//!
//! Every one round-trips a size or a span into a label — pure, dep-free, no
//! domain state. This leaf owns the **canon** so the whole constellation reads
//! the same, and a third unrelated consumer gets it as-is.
//!
//! ## The canonical convention (majority wins, dissent documented)
//! * **Bytes → IEC binary** (÷1024, `B / KiB / MiB / GiB / TiB / PiB / EiB`,
//!   one decimal, a space): [`bytes`]. This is what the fullest public twin
//!   (`facett-pipeline`) and `nornir` already do. The dissenters —
//!   `facett-plandag` (1-letter, no space) and `facett-file` (SI labels on a
//!   binary divisor, a latent mislabel) — repoint onto [`bytes`]; a caller that
//!   genuinely wants SI decimal (÷1000, `KB/MB/GB`) uses [`bytes_si`].
//! * **Counts → compact SI** (÷1000, `k / M / B / T`, no space): [`count`],
//!   matching the public `facett-pipeline` twin. Grouped digits
//!   (`1,234,567`) are [`thousands`].
//! * **Durations →** `340ms` / `1.5s` / `2m 03s` / `1h 02m 03s`: [`duration`]
//!   (+ [`duration_ms`], [`duration_secs`]), the pure core the `fmt_elapsed` /
//!   `fmt_dur` twins share — their `—` / `…` / `JobStatus` sentinels are domain
//!   glue and STAY in their conductor (L5a).
//! * **Throughput → binary** (`3.5 MiB/s`): [`throughput`].
//! * **Ratios →** `12.3%`: [`percent`] / [`percent_of`].
//!
//! ## The ladders round BEFORE they pick a step
//! Every step is chosen from the value as it will be *rendered*, not the raw one,
//! so a label never contradicts its own unit: `bytes(1048575)` is `"1.0 MiB"`, not
//! `"1024.0 KiB"`; `count(999_999)` is `"1.0M"`, not `"1000.0k"`;
//! `duration_ms(59_999)` is `"1m 00s"`, not `"60.0s"`. The sole exception is the
//! top of a ladder, which has nowhere to climb — `bytes(u64::MAX)` is `"16.0 EiB"`,
//! and that is the correct reading.
//!
//! ## Dep-light (L5a)
//! std-only — **zero** runtime dependencies (the sole `Cargo.toml` entry,
//! `nornir-testmatrix`, is optional and compiled out of any default build). Pure
//! functions, no I/O, no domain coupling: a primitive.
//!
//! ```
//! use nornir_humanize::{bytes, count, duration, percent};
//! use std::time::Duration;
//! assert_eq!(bytes(1536), "1.5 KiB");
//! assert_eq!(count(3_400_000), "3.4M");
//! assert_eq!(duration(Duration::from_millis(123_000)), "2m 03s");
//! assert_eq!(percent(0.123), "12.3%");
//! ```

use std::time::Duration;

pub mod selftest;

/// IEC binary unit ladder — `1 KiB = 1024 B`. Runs to `EiB` so the full `u64`
/// range (max ≈ 16 EiB) never overflows the ladder.
const IEC_UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
/// SI decimal unit ladder — `1 KB = 1000 B`. The documented minority convention.
const SI_UNITS: [&str; 7] = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];

/// Human-readable byte size in **IEC binary** units (the canon): `512 B`,
/// `1.5 KiB`, `3.5 MiB`, `2.0 GiB`, …
///
/// Under 1024 the exact integer count is shown with a `B` suffix and no decimal
/// (`0 B`, `1023 B`); at/above 1024 the value is divided by 1024 up the ladder
/// and shown with **one decimal** and a space (`1.0 KiB`). This matches the
/// fullest existing twin (`facett-pipeline::human_bytes`).
///
/// ```
/// # use nornir_humanize::bytes;
/// assert_eq!(bytes(0), "0 B");
/// assert_eq!(bytes(1023), "1023 B");
/// assert_eq!(bytes(1024), "1.0 KiB");
/// assert_eq!(bytes(1536), "1.5 KiB");
/// ```
pub fn bytes(n: u64) -> String {
    scale_bytes(n, 1024.0, &IEC_UNITS)
}

/// Human-readable byte size in **SI decimal** units (`1 KB = 1000 B`): `512 B`,
/// `1.5 KB`, `3.5 MB`, … The documented minority convention, for a caller that
/// truly wants powers of ten. Prefer [`bytes`] unless SI is required.
///
/// ```
/// # use nornir_humanize::bytes_si;
/// assert_eq!(bytes_si(999), "999 B");
/// assert_eq!(bytes_si(1000), "1.0 KB");
/// assert_eq!(bytes_si(1_500_000), "1.5 MB");
/// ```
pub fn bytes_si(n: u64) -> String {
    scale_bytes(n, 1000.0, &SI_UNITS)
}

/// Round to the one decimal the ladders render with. The climb decides on THIS
/// value, not on the raw one, so a step never displays its own successor's base.
fn round1(v: f64) -> f64 {
    (v * 10.0).round() / 10.0
}

/// Climb `v` up a `base`-ratio ladder of `len` steps, returning the scaled value
/// and its step index.
///
/// The loop tests the **rounded** value: `1048575 B` scales to `1023.99902 KiB`,
/// which `{:.1}` would render as the self-contradictory `"1024.0 KiB"`, so it has
/// to climb one more step and read `1.0 MiB`. Stops at the top step regardless —
/// `u64::MAX` legitimately reads `16.0 EiB`.
fn climb(mut v: f64, base: f64, len: usize) -> (f64, usize) {
    let mut i = 0;
    while i < len - 1 && round1(v) >= base {
        v /= base;
        i += 1;
    }
    (v, i)
}

/// Shared byte-ladder: below `base` show the exact integer + `" B"`, otherwise
/// divide up the ladder and show one decimal + a space.
fn scale_bytes(n: u64, base: f64, units: &[&str]) -> String {
    if (n as f64) < base {
        return format!("{n} B");
    }
    let (v, i) = climb(n as f64, base, units.len());
    format!("{v:.1} {}", units[i])
}

/// Compact **SI** count: below 1000 the exact number, otherwise one decimal with
/// a `k` / `M` / `B` / `T` suffix and **no space** (`999` → `"999"`, `1200` →
/// `"1.2k"`, `3_400_000` → `"3.4M"`). Matches the public `facett-pipeline`
/// twin (lowercase `k`, `B` = billions).
///
/// ```
/// # use nornir_humanize::count;
/// assert_eq!(count(42), "42");
/// assert_eq!(count(1_200), "1.2k");
/// assert_eq!(count(3_400_000), "3.4M");
/// assert_eq!(count(9_900_000_000), "9.9B");
/// ```
pub fn count(n: u64) -> String {
    const STEPS: [(u64, &str); 4] = [
        (1_000_000_000_000, "T"),
        (1_000_000_000, "B"),
        (1_000_000, "M"),
        (1_000, "k"),
    ];
    if n < 1_000 {
        return n.to_string();
    }
    for (idx, &(threshold, suffix)) in STEPS.iter().enumerate() {
        if n >= threshold {
            let v = n as f64 / threshold as f64;
            // 999_999 sits on the `k` step but rounds to "1000.0k" — one whole
            // step's worth. Promote it so it reads "1.0M". STEPS runs largest
            // first, so `idx > 0` means a bigger suffix exists to promote into.
            if idx > 0 && round1(v) >= 1_000.0 {
                let (next, next_suffix) = STEPS[idx - 1];
                return format!("{:.1}{next_suffix}", n as f64 / next as f64);
            }
            return format!("{v:.1}{suffix}");
        }
    }
    // Unreachable (n >= 1000 always hits the `k` step), but total by construction.
    n.to_string()
}

/// Group a number's digits in threes with commas: `1_234_567` → `"1,234,567"`.
///
/// A *display* helper for prose and UI — NOT for bench output (L3 keeps bench
/// numbers plain, `1234567`).
///
/// ```
/// # use nornir_humanize::thousands;
/// assert_eq!(thousands(0), "0");
/// assert_eq!(thousands(999), "999");
/// assert_eq!(thousands(1_234_567), "1,234,567");
/// ```
pub fn thousands(n: u64) -> String {
    let digits = n.to_string();
    let bytes = digits.as_bytes();
    let len = bytes.len();
    let mut out = String::with_capacity(len + (len - 1) / 3);
    for (i, &b) in bytes.iter().enumerate() {
        // A comma precedes any digit whose count-from-the-right is a multiple of
        // three (and it is not the very first digit).
        if i != 0 && (len - i).is_multiple_of(3) {
            out.push(',');
        }
        out.push(b as char);
    }
    out
}

/// Human-readable [`Duration`]: `0ms`, `340ms`, `1.5s`, `2m 03s`, `1h 02m 03s`.
///
/// The pure core the `fmt_elapsed` / `fmt_dur` twins share, with zero-padded
/// two-digit seconds/minutes on the multi-unit forms (so `123_000 ms` →
/// `"2m 03s"`, exactly like `facett-jobview`). Sub-second spans read in `ms`;
/// under a minute in `{:.1}s`; then `m`/`h` forms.
///
/// ```
/// # use nornir_humanize::duration;
/// # use std::time::Duration;
/// assert_eq!(duration(Duration::ZERO), "0ms");
/// assert_eq!(duration(Duration::from_millis(340)), "340ms");
/// assert_eq!(duration(Duration::from_millis(1500)), "1.5s");
/// assert_eq!(duration(Duration::from_secs(63)), "1m 03s");
/// assert_eq!(duration(Duration::from_secs(3723)), "1h 02m 03s");
/// ```
pub fn duration(d: Duration) -> String {
    let ms = d.as_millis();
    if ms == 0 {
        return "0ms".to_string();
    }
    if ms < 1_000 {
        return format!("{ms}ms");
    }
    let mut total = d.as_secs();
    if ms < 60_000 {
        let secs = ms as f64 / 1_000.0;
        if round1(secs) < 60.0 {
            return format!("{secs:.1}s");
        }
        // 59_950..60_000 ms would render as "60.0s" — a whole minute spelled in
        // seconds. It IS a minute at this precision, so use the `m`/`s` form.
        total = 60;
    }
    let (h, m, s) = (total / 3_600, (total % 3_600) / 60, total % 60);
    if h == 0 {
        format!("{m}m {s:02}s")
    } else {
        format!("{h}h {m:02}m {s:02}s")
    }
}

/// [`duration`] from a whole-millisecond count.
///
/// ```
/// # use nornir_humanize::duration_ms;
/// assert_eq!(duration_ms(0), "0ms");
/// assert_eq!(duration_ms(123_000), "2m 03s");
/// ```
pub fn duration_ms(ms: u64) -> String {
    duration(Duration::from_millis(ms))
}

/// [`duration`] from a fractional-seconds count. Non-finite or non-positive
/// input renders as `"0ms"` (the pure leaf has no `—` sentinel — that domain
/// glue stays in the conductor).
///
/// ```
/// # use nornir_humanize::duration_secs;
/// assert_eq!(duration_secs(0.0), "0ms");
/// assert_eq!(duration_secs(1.5), "1.5s");
/// assert_eq!(duration_secs(-3.0), "0ms");
/// ```
pub fn duration_secs(secs: f64) -> String {
    if !secs.is_finite() || secs <= 0.0 {
        return "0ms".to_string();
    }
    duration(Duration::from_secs_f64(secs))
}

/// Human-readable **binary** throughput from a bytes-per-second rate:
/// `0 B/s`, `512 B/s`, `1.5 KiB/s`, `3.5 MiB/s`. Reuses the [`bytes`] ladder and
/// appends `/s`. Non-positive/non-finite → `"0 B/s"`.
///
/// ```
/// # use nornir_humanize::throughput;
/// assert_eq!(throughput(0.0), "0 B/s");
/// assert_eq!(throughput(512.0), "512 B/s");
/// assert_eq!(throughput(1536.0), "1.5 KiB/s");
/// ```
pub fn throughput(bytes_per_sec: f64) -> String {
    if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
        return "0 B/s".to_string();
    }
    let start = if bytes_per_sec < 1024.0 {
        // The sub-ladder form is whole bytes, so decide on the ROUNDED value:
        // 1023.6 renders as "1024 B/s", which is a KiB and must climb.
        let whole = bytes_per_sec.round();
        if whole < 1024.0 {
            return format!("{whole:.0} B/s");
        }
        whole
    } else {
        bytes_per_sec
    };
    let (v, i) = climb(start, 1024.0, IEC_UNITS.len());
    format!("{v:.1} {}/s", IEC_UNITS[i])
}

/// A ratio (`0.0..=1.0` typically) as a percentage with one decimal:
/// `0.123` → `"12.3%"`, `1.0` → `"100.0%"`. Non-finite → `"0.0%"`. Values
/// outside `0..=1` are formatted as-is (`1.5` → `"150.0%"`).
///
/// ```
/// # use nornir_humanize::percent;
/// assert_eq!(percent(0.0), "0.0%");
/// assert_eq!(percent(0.123), "12.3%");
/// assert_eq!(percent(1.0), "100.0%");
/// ```
pub fn percent(fraction: f64) -> String {
    if !fraction.is_finite() {
        return "0.0%".to_string();
    }
    format!("{:.1}%", fraction * 100.0)
}

/// [`percent`] of `part` out of `whole`. A `whole` of zero (or non-finite input)
/// renders as `"0.0%"` rather than dividing by zero.
///
/// ```
/// # use nornir_humanize::percent_of;
/// assert_eq!(percent_of(1, 4), "25.0%");
/// assert_eq!(percent_of(0, 0), "0.0%");
/// ```
pub fn percent_of(part: u64, whole: u64) -> String {
    if whole == 0 {
        return "0.0%".to_string();
    }
    percent(part as f64 / whole as f64)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn bytes_iec_boundaries_and_ladder() {
        // Boundary cases: the `< 1024` vs `>= 1024` threshold is the RED guard —
        // flip it to `<= 1024` and `bytes(1024)` stops being "1.0 KiB".
        assert_eq!(bytes(0), "0 B");
        assert_eq!(bytes(1), "1 B");
        assert_eq!(bytes(1023), "1023 B");
        assert_eq!(bytes(1024), "1.0 KiB");
        assert_eq!(bytes(1536), "1.5 KiB"); // the mission's canonical vector
        assert_eq!(bytes(1024 * 1024), "1.0 MiB");
        assert_eq!(bytes(3 * 1024 * 1024 + 512 * 1024), "3.5 MiB");
        assert_eq!(bytes(2 * 1024 * 1024 * 1024), "2.0 GiB");
        assert_eq!(bytes(1024u64.pow(4)), "1.0 TiB");
        assert_eq!(bytes(5 * 1024u64.pow(5)), "5.0 PiB");
        // Full-range: u64::MAX lands in EiB and never panics off the ladder.
        assert_eq!(bytes(u64::MAX), "16.0 EiB");
    }

    /// A rendered value must never equal the base of the step above it: the top
    /// 0.05% of every step used to round INTO the next unit's name while keeping
    /// the current unit's label ("1024.0 KiB", "1000.0 KB").
    #[test]
    fn ladder_never_renders_its_successors_base() {
        assert_eq!(bytes(1_048_575), "1.0 MiB");
        assert_eq!(bytes(1_073_741_823), "1.0 GiB");
        assert_eq!(bytes(1024 * 1024 - 1), "1.0 MiB");
        // Just under the rounding cliff still reads in the lower unit.
        assert_eq!(bytes(1_048_000), "1023.4 KiB");
        assert_eq!(bytes_si(999_999), "1.0 MB");
        assert_eq!(bytes_si(999_950), "1.0 MB");
        assert_eq!(bytes_si(999_500), "999.5 KB");
        assert_eq!(bytes_si(999_400), "999.4 KB");
        // The top of the ladder has nowhere to climb: 16 EiB is the true reading.
        assert_eq!(bytes(u64::MAX), "16.0 EiB");
    }

    #[test]
    fn count_promotes_instead_of_printing_1000() {
        assert_eq!(count(999_999), "1.0M");
        assert_eq!(count(999_950), "1.0M");
        assert_eq!(count(999_940), "999.9k");
        assert_eq!(count(999_999_999), "1.0B");
        assert_eq!(count(999_999_999_999), "1.0T");
    }

    #[test]
    fn duration_promotes_instead_of_printing_60s() {
        assert_eq!(duration_ms(59_999), "1m 00s");
        assert_eq!(duration_ms(59_950), "1m 00s");
        assert_eq!(duration_ms(59_949), "59.9s");
        assert_eq!(duration_ms(60_000), "1m 00s");
        assert_eq!(duration_secs(59.99), "1m 00s");
    }

    #[test]
    fn throughput_promotes_instead_of_printing_1024() {
        assert_eq!(throughput(1_048_575.0), "1.0 MiB/s");
        assert_eq!(throughput(1023.6), "1.0 KiB/s");
        assert_eq!(throughput(1023.4), "1023 B/s");
    }

    #[test]
    fn bytes_si_uses_powers_of_ten() {
        assert_eq!(bytes_si(999), "999 B");
        assert_eq!(bytes_si(1000), "1.0 KB");
        assert_eq!(bytes_si(1_500_000), "1.5 MB");
        assert_eq!(bytes_si(2_000_000_000), "2.0 GB");
        // Contrast with the binary canon at the same input.
        assert_eq!(bytes(1000), "1000 B");
    }

    #[test]
    fn count_compacts_with_si_suffixes() {
        assert_eq!(count(0), "0");
        assert_eq!(count(42), "42");
        assert_eq!(count(999), "999");
        assert_eq!(count(1_000), "1.0k");
        assert_eq!(count(1_200), "1.2k");
        assert_eq!(count(12_000), "12.0k");
        assert_eq!(count(3_400_000), "3.4M");
        assert_eq!(count(9_900_000_000), "9.9B");
        assert_eq!(count(2_500_000_000_000), "2.5T");
    }

    #[test]
    fn thousands_groups_in_threes() {
        assert_eq!(thousands(0), "0");
        assert_eq!(thousands(7), "7");
        assert_eq!(thousands(999), "999");
        assert_eq!(thousands(1_000), "1,000");
        assert_eq!(thousands(12_345), "12,345");
        assert_eq!(thousands(1_234_567), "1,234,567");
        assert_eq!(thousands(1_000_000_000), "1,000,000,000");
    }

    #[test]
    fn duration_scales_across_units() {
        assert_eq!(duration(Duration::ZERO), "0ms");
        assert_eq!(duration(Duration::from_millis(1)), "1ms");
        assert_eq!(duration(Duration::from_millis(340)), "340ms");
        assert_eq!(duration(Duration::from_millis(999)), "999ms");
        assert_eq!(duration(Duration::from_millis(1000)), "1.0s");
        assert_eq!(duration(Duration::from_millis(1500)), "1.5s");
        // Multi-unit forms zero-pad — parity with facett-jobview's "2m 03s".
        assert_eq!(duration(Duration::from_secs(63)), "1m 03s");
        assert_eq!(duration(Duration::from_millis(123_000)), "2m 03s");
        assert_eq!(duration(Duration::from_secs(3600)), "1h 00m 00s");
        assert_eq!(duration(Duration::from_secs(3723)), "1h 02m 03s");
    }

    #[test]
    fn duration_helpers_agree_with_core() {
        assert_eq!(duration_ms(123_000), "2m 03s");
        assert_eq!(duration_secs(1.5), "1.5s");
        // Degenerate input has no sentinel in the pure leaf.
        assert_eq!(duration_secs(0.0), "0ms");
        assert_eq!(duration_secs(-9.0), "0ms");
        assert_eq!(duration_secs(f64::NAN), "0ms");
    }

    #[test]
    fn throughput_is_binary_per_second() {
        assert_eq!(throughput(0.0), "0 B/s");
        assert_eq!(throughput(-1.0), "0 B/s");
        assert_eq!(throughput(512.0), "512 B/s");
        assert_eq!(throughput(1536.0), "1.5 KiB/s");
        assert_eq!(throughput(3.5 * 1024.0 * 1024.0), "3.5 MiB/s");
    }

    #[test]
    fn percent_and_percent_of() {
        assert_eq!(percent(0.0), "0.0%");
        assert_eq!(percent(0.123), "12.3%");
        assert_eq!(percent(1.0), "100.0%");
        assert_eq!(percent(1.5), "150.0%");
        assert_eq!(percent(f64::INFINITY), "0.0%");
        assert_eq!(percent_of(1, 4), "25.0%");
        assert_eq!(percent_of(3, 3), "100.0%");
        assert_eq!(percent_of(5, 0), "0.0%");
    }
}