rusty-pv 0.1.0

Pipe viewer — a Rust port of Andrew Wood's `pv(1)` with progress bar, ETA, rate display, token-bucket rate limiting, IEC/SI unit math, SIGWINCH-aware terminal redraw, SIGUSR1 size refresh, multi-instance cursor coordination, and a typed library API.
Documentation
//! IEC binary + SI decimal unit-suffix math (FR-026/FR-027, AD-015).

/// Selector for unit-suffix system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UnitSystem {
    /// IEC binary prefixes (KiB = 1024, MiB = 1024², ...). Default.
    #[default]
    Iec,
    /// SI decimal prefixes (kB = 1000, MB = 10⁶, ...).
    Si,
}

const IEC_SUFFIXES: &[(u64, &str)] = &[
    (1, "B"),
    (1 << 10, "KiB"),
    (1 << 20, "MiB"),
    (1 << 30, "GiB"),
    (1u64 << 40, "TiB"),
];

const SI_SUFFIXES: &[(u64, &str)] = &[
    (1, "B"),
    (1_000, "kB"),
    (1_000_000, "MB"),
    (1_000_000_000, "GB"),
    (1_000_000_000_000, "TB"),
];

impl UnitSystem {
    fn suffixes(self) -> &'static [(u64, &'static str)] {
        match self {
            UnitSystem::Iec => IEC_SUFFIXES,
            UnitSystem::Si => SI_SUFFIXES,
        }
    }

    /// Format a byte count with the largest suffix `<=` value. Returns a
    /// string like `"45.2MiB"` or `"500B"`. 3 significant digits when the
    /// value is `<1000` of the chosen unit; integer when `<10`.
    #[must_use]
    pub fn format_bytes(self, value: u64) -> String {
        let table = self.suffixes();
        let mut best = &table[0];
        for entry in table {
            if value >= entry.0 {
                best = entry;
            }
        }
        let scaled = value as f64 / best.0 as f64;
        if best.0 == 1 {
            format!("{}{}", value, best.1)
        } else if scaled >= 100.0 {
            format!("{scaled:.0}{}", best.1)
        } else if scaled >= 10.0 {
            format!("{scaled:.1}{}", best.1)
        } else {
            format!("{scaled:.2}{}", best.1)
        }
    }

    /// Format a rate value (bytes per second) by appending `/s` to the byte
    /// format.
    #[must_use]
    pub fn format_rate(self, bytes_per_sec: f64) -> String {
        let v = bytes_per_sec.max(0.0) as u64;
        format!("{}/s", self.format_bytes(v))
    }
}

/// Parse a size string with optional `K`/`M`/`G`/`T` suffix (IEC by default;
/// SI when `unit_system == Si`). Accepts both integer and fractional values.
/// Returns the byte count.
///
/// # Errors
///
/// Returns `None` for malformed input or out-of-range values.
pub fn parse_size(s: &str, unit_system: UnitSystem) -> Option<u64> {
    let s = s.trim();
    if s.is_empty() {
        return None;
    }
    let table = unit_system.suffixes();
    // Find the suffix (case-insensitive single-letter K/M/G/T or full IEC/SI suffix).
    let (num_part, mult): (&str, u64) = if let Some(pos) = s.find(|c: char| c.is_ascii_alphabetic())
    {
        let (num, suffix) = s.split_at(pos);
        let m = match suffix.chars().next()?.to_ascii_uppercase() {
            'B' => 1,
            'K' => table[1].0,
            'M' => table[2].0,
            'G' => table[3].0,
            'T' => table[4].0,
            _ => return None,
        };
        (num, m)
    } else {
        (s, 1)
    };
    let n: f64 = num_part.trim().parse().ok()?;
    if n < 0.0 || !n.is_finite() {
        return None;
    }
    Some((n * mult as f64) as u64)
}

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

    #[test]
    fn format_iec_bytes() {
        assert_eq!(UnitSystem::Iec.format_bytes(0), "0B");
        assert_eq!(UnitSystem::Iec.format_bytes(512), "512B");
        assert_eq!(UnitSystem::Iec.format_bytes(1024), "1.00KiB");
        assert_eq!(UnitSystem::Iec.format_bytes(1_048_576), "1.00MiB");
        assert_eq!(UnitSystem::Iec.format_bytes(47_185_920), "45.0MiB");
    }

    #[test]
    fn format_si_bytes() {
        assert_eq!(UnitSystem::Si.format_bytes(1000), "1.00kB");
        assert_eq!(UnitSystem::Si.format_bytes(1_000_000), "1.00MB");
    }

    #[test]
    fn parse_iec_default() {
        assert_eq!(parse_size("1024", UnitSystem::Iec), Some(1024));
        assert_eq!(parse_size("1K", UnitSystem::Iec), Some(1024));
        assert_eq!(parse_size("1M", UnitSystem::Iec), Some(1_048_576));
        assert_eq!(parse_size("1.5M", UnitSystem::Iec), Some(1_572_864));
    }

    #[test]
    fn parse_si() {
        assert_eq!(parse_size("1k", UnitSystem::Si), Some(1000));
        assert_eq!(parse_size("1M", UnitSystem::Si), Some(1_000_000));
    }

    #[test]
    fn parse_rejects_negative() {
        assert_eq!(parse_size("-5M", UnitSystem::Iec), None);
    }

    #[test]
    fn parse_rejects_garbage() {
        assert_eq!(parse_size("abc", UnitSystem::Iec), None);
        assert_eq!(parse_size("", UnitSystem::Iec), None);
    }

    #[test]
    fn format_rate_appends_per_second() {
        assert_eq!(UnitSystem::Iec.format_rate(1024.0), "1.00KiB/s");
    }
}