From 4b2289a1988739c356f3b38589f6ad29a0e39ccb Mon Sep 17 00:00:00 2001 From: Louis Thiery Date: Tue, 7 Jul 2026 19:00:42 +0000 Subject: [PATCH] Port fixes and features from upstream bytesize Bug fixes (from bytesize-rs/bytesize): - parse: use exact integer arithmetic when the number has no fractional part; f64's 53-bit mantissa silently rounded large byte counts ("9007199254740993B" parsed to ...992). Overflow now errors instead of wrapping (bytesize#171, adapted: checked_mul + ParseError instead of saturating) - display/parse round-trip: the display prefix table already emitted "16.0 EiB" for u64::MAX but the parser had no exa arms, so humanbyte couldn't parse back its own output Features: - exabyte support: EB/EIB constants, Unit::ExaByte/ExbiByte, eb()/eib() constructors - iter::Sum for derived types (owned and by-ref) - as_kb()/as_mib()/... float accessors on derived types - Display now honors formatter precision as decimals ({:.2} gives "1.50 KiB"); previously precision truncated the string ({:.2} gave "1."). Width/alignment/fill preserved - Unit gains Debug/Clone/Copy/PartialEq/Eq and #[non_exhaustive] Not ported: their ln()-based unit-selection fix (bytesize#175) - our integer-log display is already exact at boundaries --- bytescale/src/lib.rs | 36 +++++++++++++++++++++++ humanbyte-derive/src/lib.rs | 57 ++++++++++++++++++++++++++++++++++++- humanbyte/src/lib.rs | 48 +++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/bytescale/src/lib.rs b/bytescale/src/lib.rs index 15d5b40..0fbe0df 100644 --- a/bytescale/src/lib.rs +++ b/bytescale/src/lib.rs @@ -193,6 +193,42 @@ mod tests { assert_eq!(humanbyte::parse("1.5 KiB"), Ok(1536)); } + #[test] + fn test_sum() { + let sizes = [ByteScale::kib(1), ByteScale::kib(2), ByteScale::kib(3)]; + assert_eq!(sizes.iter().sum::(), ByteScale::kib(6)); + assert_eq!(sizes.into_iter().sum::(), ByteScale::kib(6)); + } + + #[test] + fn test_exabytes() { + assert_eq!(ByteScale::eib(1).as_u64(), 1_152_921_504_606_846_976); + assert_eq!(ByteScale::eb(1).as_u64(), 1_000_000_000_000_000_000); + // the display/parse roundtrip works at the top of the u64 range + let max = ByteScale(u64::MAX); + assert_display!("16.0 EiB", max); + assert_eq!("15 EiB".parse::().unwrap(), ByteScale::eib(15)); + // 16 EiB is exactly 2^64: one past u64::MAX, so it must error + assert!("16 EiB".parse::().is_err()); + } + + #[test] + fn test_float_accessors() { + assert_eq!(ByteScale::kib(1).as_kib(), 1.0); + assert_eq!(ByteScale::kib(1).as_kb(), 1.024); + assert_eq!(ByteScale::gib(3).as_mib(), 3072.0); + } + + #[test] + fn test_display_precision() { + let x = ByteScale::kib(1) + 512u64; + assert_eq!(format!("{x:.2}"), "1.50 KiB"); + assert_eq!(format!("{x:.0}"), "2 KiB"); + assert_eq!(format!("{x}"), "1.5 KiB"); + // precision composes with width/alignment + assert_eq!(format!("|{x:>10.2}|"), "| 1.50 KiB|"); + } + #[test] #[should_panic(expected = "byte size overflows u64")] fn test_constructor_overflow() { diff --git a/humanbyte-derive/src/lib.rs b/humanbyte-derive/src/lib.rs index 55bce6b..808d07b 100644 --- a/humanbyte-derive/src/lib.rs +++ b/humanbyte-derive/src/lib.rs @@ -42,6 +42,8 @@ fn constructor_tokens(name: &syn::Ident) -> proc_macro2::TokenStream { ("tib", "::humanbyte::TIB", "tebibytes"), ("pb", "::humanbyte::PB", "petabytes"), ("pib", "::humanbyte::PIB", "pebibytes"), + ("eb", "::humanbyte::EB", "exabytes"), + ("eib", "::humanbyte::EIB", "exbibytes"), ]; // Generate methods @@ -71,9 +73,25 @@ fn constructor_tokens(name: &syn::Ident) -> proc_macro2::TokenStream { } }); + // Generate float accessors (skipping bytes, which has as_u64 in the parse derive) + let accessors = units[1..].iter().map(|(unit, multiplier, description)| { + let method_name = syn::Ident::new(&format!("as_{}", unit), Span::call_site()); + let multiplier_expr: syn::Expr = syn::parse_str(multiplier).unwrap(); + let doc_comment = format!("Returns the size in {} as a float.", description); + + quote! { + #[doc = #doc_comment] + #[inline(always)] + pub fn #method_name(&self) -> f64 { + self.0 as f64 / #multiplier_expr as f64 + } + } + }); + quote! { impl #name { #(#methods)* + #(#accessors)* } impl From for #name { @@ -229,6 +247,18 @@ fn ops_tokens(name: &syn::Ident) -> proc_macro2::TokenStream { } } + impl core::iter::Sum<#name> for #name { + fn sum>(iter: I) -> #name { + iter.fold(#name(0), |acc, x| #name(acc.0 + x.0)) + } + } + + impl<'a> core::iter::Sum<&'a #name> for #name { + fn sum>(iter: I) -> #name { + iter.fold(#name(0), |acc, x| #name(acc.0 + x.0)) + } + } + impl core::ops::Add<#name> for u64 { type Output = #name; #[inline(always)] @@ -350,7 +380,32 @@ fn display_tokens(name: &syn::Ident) -> proc_macro2::TokenStream { quote! { impl core::fmt::Display for #name { fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { - f.pad(&::humanbyte::to_string(self.0, ::humanbyte::Format::IEC)) + use core::fmt::Write as _; + + // Interpret formatter precision as decimals: "{:.2}" is "1.50 KiB". + // Width/alignment are handled manually because Formatter::pad + // would reinterpret the precision as string truncation. + let precision = f.precision().unwrap_or(1); + let s = ::humanbyte::to_string_with_precision( + self.0, + ::humanbyte::Format::IEC, + precision, + ); + let pad = f.width().unwrap_or(0).saturating_sub(s.len()); + let (left, right) = match f.align() { + Some(core::fmt::Alignment::Right) => (pad, 0), + Some(core::fmt::Alignment::Center) => (pad / 2, pad - pad / 2), + // strings left-align by default + _ => (0, pad), + }; + for _ in 0..left { + f.write_char(f.fill())?; + } + f.write_str(&s)?; + for _ in 0..right { + f.write_char(f.fill())?; + } + Ok(()) } } diff --git a/humanbyte/src/lib.rs b/humanbyte/src/lib.rs index 132caec..c750fe2 100644 --- a/humanbyte/src/lib.rs +++ b/humanbyte/src/lib.rs @@ -39,6 +39,8 @@ pub const GB: u64 = 1_000_000_000; pub const TB: u64 = 1_000_000_000_000; /// bytes size for 1 petabyte pub const PB: u64 = 1_000_000_000_000_000; +/// bytes size for 1 exabyte +pub const EB: u64 = 1_000_000_000_000_000_000; /// bytes size for 1 kibibyte pub const KIB: u64 = 1_024; @@ -50,6 +52,8 @@ pub const GIB: u64 = 1_073_741_824; pub const TIB: u64 = 1_099_511_627_776; /// bytes size for 1 pebibyte pub const PIB: u64 = 1_125_899_906_842_624; +/// bytes size for 1 exbibyte +pub const EIB: u64 = 1_152_921_504_606_846_976; /// IEC (binary) units. /// @@ -121,6 +125,16 @@ pub fn parse(value: &str) -> Result { Ok(v) => { let suffix = skip_while(&value[number.len()..], char::is_whitespace); match suffix.parse::() { + // Use exact integer arithmetic when the number has no fractional + // part: f64 only has a 53-bit mantissa, so byte counts at or above + // 2^53 would otherwise be silently rounded (e.g. + // "9007199254740993B" to ...992). + Ok(u) if !number.contains('.') => match number.parse::() { + Ok(n) => n.checked_mul(u64::from(u)).ok_or_else(|| { + ParseError(format!("{:?} overflows a u64 byte count", value)) + }), + Err(_) => Ok((v * u64::from(u) as f64) as u64), + }, Ok(u) => Ok((v * u64::from(u) as f64) as u64), Err(error) => Err(ParseError(format!( "couldn't parse {:?} into a known SI unit, {}", @@ -171,6 +185,8 @@ where &s[(s.len() - offset)..] } +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Unit { Byte, // power of tens @@ -179,12 +195,14 @@ pub enum Unit { GigaByte, TeraByte, PetaByte, + ExaByte, // power of twos KibiByte, MebiByte, GibiByte, TebiByte, PebiByte, + ExbiByte, } impl From for u64 { @@ -197,12 +215,14 @@ impl From for u64 { Unit::GigaByte => GB, Unit::TeraByte => TB, Unit::PetaByte => PB, + Unit::ExaByte => EB, // power of twos Unit::KibiByte => KIB, Unit::MebiByte => MIB, Unit::GibiByte => GIB, Unit::TebiByte => TIB, Unit::PebiByte => PIB, + Unit::ExbiByte => EIB, } } } @@ -219,12 +239,14 @@ impl FromStr for Unit { "g" | "gb" => Ok(Self::GigaByte), "t" | "tb" => Ok(Self::TeraByte), "p" | "pb" => Ok(Self::PetaByte), + "e" | "eb" => Ok(Self::ExaByte), // power of twos "ki" | "kib" => Ok(Self::KibiByte), "mi" | "mib" => Ok(Self::MebiByte), "gi" | "gib" => Ok(Self::GibiByte), "ti" | "tib" => Ok(Self::TebiByte), "pi" | "pib" => Ok(Self::PebiByte), + "ei" | "eib" => Ok(Self::ExbiByte), _ => Err(ParseError(format!("couldn't parse unit of {:?}", unit))), } } @@ -420,10 +442,36 @@ mod tests { assert_eq!(parse("1.5KiB"), Ok(1536)); assert_eq!(parse("2 mb"), Ok(2_000_000)); assert_eq!(parse("3 g"), Ok(3_000_000_000)); + assert_eq!(parse("1 EiB"), Ok(EIB)); + assert_eq!(parse("2 eb"), Ok(2 * EB)); assert!(parse("").is_err()); assert!(parse("1.5 XB").is_err()); } + #[test] + fn test_parse_integer_exactness() { + // 2^53 + 1 is not representable as f64; the suffix path must not + // round it (upstream bytesize#171) + assert_eq!(parse("9007199254740993B"), Ok(9_007_199_254_740_993)); + assert_eq!(parse("9007199254740993 B"), Ok(9_007_199_254_740_993)); + assert_eq!(parse("18446744073709551615B"), Ok(u64::MAX)); + // integer overflow errors instead of silently wrapping/saturating + assert!(parse("20000 PB").is_err()); + // fractional values still take the f64 path + assert_eq!(parse("1.5 KiB"), Ok(1536)); + } + + #[test] + fn test_display_parse_roundtrip() { + // everything we can display must parse back (u64::MAX shows as EiB) + for bytes in [0, 1, 999, 1024, KIB, MIB, GIB, TIB, PIB, EIB, u64::MAX] { + for format in [Format::IEC, Format::SI] { + let s = to_string(bytes, format); + assert!(parse(&s).is_ok(), "couldn't parse back {s:?}"); + } + } + } + #[test] fn test_precision() { assert_eq!(to_string_with_precision(TIB, Format::IEC, 2), "1.00 TiB");