use core::fmt::Write as _;
use super::Rate;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum ByteUnits {
#[default]
Iec,
Si,
}
impl ByteUnits {
const fn divisor(self) -> u64 {
match self {
Self::Iec => 1024,
Self::Si => 1000,
}
}
const fn suffixes(self) -> &'static [&'static str] {
match self {
Self::Iec => &["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"],
Self::Si => &["B", "kB", "MB", "GB", "TB", "PB", "EB"],
}
}
const fn short_suffixes(self) -> &'static [&'static str] {
match self {
Self::Iec | Self::Si => &["B", "K", "M", "G", "T", "P", "E"],
}
}
}
fn scale(bytes: u64, units: ByteUnits, max_index: usize) -> (f64, usize) {
let divisor = units.divisor();
let mut value = bytes as f64;
let mut index = 0usize;
let divisor_f = divisor as f64;
while value >= divisor_f && index < max_index {
value /= divisor_f;
index += 1;
}
(value, index)
}
#[must_use]
pub fn format_bytes(bytes: u64, units: ByteUnits) -> String {
let suffixes = units.suffixes();
let (value, index) = scale(bytes, units, suffixes.len().saturating_sub(1));
let suffix = suffixes.get(index).copied().unwrap_or("B");
let mut out = String::with_capacity(10);
if index == 0 {
let _ = write!(out, "{bytes} {suffix}");
} else if value < 10.0 {
let _ = write!(out, "{value:.1} {suffix}");
} else {
let _ = write!(out, "{value:.0} {suffix}");
}
out
}
#[must_use]
pub fn format_bytes_compact(bytes: u64, units: ByteUnits) -> String {
let suffixes = units.short_suffixes();
let (value, index) = scale(bytes, units, suffixes.len().saturating_sub(1));
let suffix = suffixes.get(index).copied().unwrap_or("B");
let mut out = String::with_capacity(6);
if index == 0 {
let _ = write!(out, "{bytes}{suffix}");
} else if value < 10.0 {
let _ = write!(out, "{value:.1}{suffix}");
} else {
let _ = write!(out, "{value:.0}{suffix}");
}
out
}
#[must_use]
pub fn format_byte_rate(rate: Rate, units: ByteUnits) -> String {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let whole = rate.per_second().min(u64::MAX as f64) as u64;
let mut out = format_bytes_compact(whole, units);
out.push_str("/s");
out
}
pub const MAX_COMPACT_BYTES_WIDTH: u16 = 5;
pub const MAX_BYTE_RATE_WIDTH: u16 = MAX_COMPACT_BYTES_WIDTH + 2;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_bytes_have_no_decimal() {
assert_eq!(format_bytes(0, ByteUnits::Iec), "0 B");
assert_eq!(format_bytes(512, ByteUnits::Iec), "512 B");
}
#[test]
fn iec_and_si_boundaries_differ_as_documented() {
assert_eq!(format_bytes(1024, ByteUnits::Iec), "1.0 KiB");
assert_eq!(format_bytes(1024, ByteUnits::Si), "1.0 kB");
assert_eq!(format_bytes(1000, ByteUnits::Si), "1.0 kB");
assert_eq!(format_bytes(1000, ByteUnits::Iec), "1000 B");
}
#[test]
fn crossing_a_unit_boundary_does_not_widen_the_column() {
for bytes in [1023u64, 1024, 1025, 1_048_575, 1_048_576] {
let rendered = format_bytes_compact(bytes, ByteUnits::Iec);
assert!(
rendered.chars().count() <= MAX_COMPACT_BYTES_WIDTH as usize,
"{bytes} rendered as {rendered:?}, wider than the reserved width"
);
}
}
#[test]
fn the_largest_counter_still_fits_the_reserved_width() {
let rendered = format_bytes_compact(u64::MAX, ByteUnits::Iec);
assert!(
rendered.chars().count() <= MAX_COMPACT_BYTES_WIDTH as usize,
"u64::MAX rendered as {rendered:?}"
);
assert!(
rendered.ends_with('E'),
"expected exbibytes, got {rendered:?}"
);
}
#[test]
fn rates_carry_the_consistent_per_second_suffix() {
let rate = Rate::new(42.0 * 1024.0 * 1024.0).expect("valid");
assert_eq!(format_byte_rate(rate, ByteUnits::Iec), "42M/s");
assert_eq!(format_byte_rate(Rate::ZERO, ByteUnits::Iec), "0B/s");
}
#[test]
fn every_byte_rate_fits_the_reserved_width() {
for per_second in [0.0, 1.0, 999.0, 1024.0, 1.5e9, 9.9e18] {
let rate = Rate::new(per_second).expect("valid");
let rendered = format_byte_rate(rate, ByteUnits::Iec);
assert!(
rendered.chars().count() <= MAX_BYTE_RATE_WIDTH as usize,
"{per_second} rendered as {rendered:?}"
);
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum ByteSizeParseError {
#[error("expected a number followed by a unit (B, KiB, MiB, GiB, kB, MB, GB), got {input:?}")]
Empty {
input: String,
},
#[error("{value:?} is not a whole number in {input:?}")]
NotANumber {
input: String,
value: String,
},
#[error(
"unknown size unit {unit:?} in {input:?}; expected one of B, KiB, MiB, GiB, kB, MB, GB"
)]
UnknownUnit {
input: String,
unit: String,
},
#[error("size {input:?} is too large")]
Overflow {
input: String,
},
}
pub fn parse_bytes(input: &str) -> Result<u64, ByteSizeParseError> {
let trimmed = input.trim();
let split = trimmed
.char_indices()
.find(|(_, c)| !c.is_ascii_digit() && *c != '_' && *c != '.')
.map_or(trimmed.len(), |(index, _)| index);
let (number, unit) = trimmed.split_at(split);
let number: String = number.chars().filter(|c| *c != '_').collect();
let (whole_text, fraction_text) = match number.split_once('.') {
Some((whole, fraction)) => (whole, fraction),
None => (number.as_str(), ""),
};
if whole_text.is_empty() || fraction_text.contains('.') {
return Err(ByteSizeParseError::Empty {
input: trimmed.to_owned(),
});
}
let amount: u64 = whole_text
.parse()
.map_err(|_| ByteSizeParseError::NotANumber {
input: trimmed.to_owned(),
value: number.clone(),
})?;
let fraction: u64 = if fraction_text.is_empty() {
0
} else {
fraction_text
.parse()
.map_err(|_| ByteSizeParseError::NotANumber {
input: trimmed.to_owned(),
value: number.clone(),
})?
};
let fraction_scale = 10_u64
.checked_pow(u32::try_from(fraction_text.len()).unwrap_or(u32::MAX))
.ok_or(ByteSizeParseError::Overflow {
input: trimmed.to_owned(),
})?;
let unit = unit.trim();
let multiplier: u64 = match unit {
"" | "B" | "b" => 1,
"KiB" | "kib" | "KIB" | "K" | "k" => 1024,
"MiB" | "mib" | "MIB" | "M" | "m" => 1024 * 1024,
"GiB" | "gib" | "GIB" | "G" | "g" => 1024 * 1024 * 1024,
"TiB" | "tib" | "TIB" | "T" | "t" => 1024_u64.pow(4),
"kB" | "KB" | "kb" => 1_000,
"MB" | "mB" | "mb" => 1_000_000,
"GB" | "gB" | "gb" => 1_000_000_000,
"TB" | "tB" | "tb" => 1_000_000_000_000,
other => {
return Err(ByteSizeParseError::UnknownUnit {
input: trimmed.to_owned(),
unit: other.to_owned(),
});
}
};
let total = u128::from(amount) * u128::from(multiplier)
+ u128::from(fraction) * u128::from(multiplier) / u128::from(fraction_scale);
u64::try_from(total).map_err(|_| ByteSizeParseError::Overflow {
input: trimmed.to_owned(),
})
}
#[cfg(test)]
mod parse_tests {
use super::*;
#[test]
fn parses_both_unit_families() {
assert_eq!(parse_bytes("32MiB"), Ok(32 * 1024 * 1024));
assert_eq!(parse_bytes("32MB"), Ok(32_000_000));
assert_eq!(parse_bytes("1KiB"), Ok(1024));
assert_eq!(parse_bytes("1kB"), Ok(1_000));
assert_eq!(parse_bytes("1GiB"), Ok(1024 * 1024 * 1024));
}
#[test]
fn a_single_decimal_fraction_is_exact_integer_arithmetic() {
assert_eq!(parse_bytes("1.5GiB"), Ok(1_610_612_736));
assert_eq!(parse_bytes("1.0KiB"), Ok(1024));
assert_eq!(parse_bytes("0.5MiB"), Ok(512 * 1024));
assert_eq!(parse_bytes("2.25GiB"), Ok(2_415_919_104));
assert_eq!(parse_bytes("10.9"), Ok(10));
}
#[test]
fn a_malformed_fraction_is_rejected() {
assert!(parse_bytes("1.2.3MiB").is_err());
assert!(matches!(
parse_bytes(".5MiB"),
Err(ByteSizeParseError::Empty { .. })
));
}
#[test]
fn a_bare_number_is_bytes() {
assert_eq!(parse_bytes("1024"), Ok(1024));
assert_eq!(parse_bytes("0"), Ok(0));
}
#[test]
fn whitespace_and_underscores_are_tolerated() {
assert_eq!(parse_bytes(" 32 MiB "), Ok(32 * 1024 * 1024));
assert_eq!(parse_bytes("1_048_576"), Ok(1_048_576));
}
#[test]
fn the_i_distinguishes_the_families_case_insensitively() {
assert_eq!(parse_bytes("2MiB"), Ok(2 * 1024 * 1024));
assert_eq!(parse_bytes("2mib"), Ok(2 * 1024 * 1024));
assert_eq!(parse_bytes("2MB"), Ok(2_000_000));
assert_eq!(parse_bytes("2mb"), Ok(2_000_000));
}
#[test]
fn errors_quote_the_offending_input() {
let error = parse_bytes("32 gigglebytes").expect_err("bad unit");
assert!(error.to_string().contains("gigglebytes"), "{error}");
assert!(matches!(
parse_bytes("MiB"),
Err(ByteSizeParseError::Empty { .. })
));
assert!(matches!(
parse_bytes("99999999999999999999999"),
Err(ByteSizeParseError::NotANumber { .. })
));
assert!(matches!(
parse_bytes("99999999999999999TiB"),
Err(ByteSizeParseError::Overflow { .. })
));
}
#[test]
fn formatting_round_trips_through_parsing_for_iec_sizes() {
for bytes in [0u64, 512, 1024, 32 * 1024 * 1024, 4 * 1024 * 1024 * 1024] {
let rendered = format_bytes(bytes, ByteUnits::Iec).replace(' ', "");
let reparsed = parse_bytes(&rendered).expect("re-parse");
let drift = reparsed.abs_diff(bytes);
assert!(
drift <= bytes / 100 + 1,
"{bytes} rendered as {rendered} reparsed as {reparsed}"
);
}
}
}