From 3a002e697db7b166d11ab8e9711dbcf31ea44fc8 Mon Sep 17 00:00:00 2001 From: tison Date: Fri, 31 Jul 2026 23:20:58 +0800 Subject: [PATCH 1/7] docs: define parser rounding contract --- README.md | 2 +- bsize/src/lib.rs | 32 +++++++++++++++++++++++++++++++- bsize/src/parse.rs | 28 ++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5e6d607..73fb4ad 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This crate provides multiple semantic wrappers and utilities for byte size repre * `#![no_std]`-capable, no heap allocation, and no runtime dependencies by default. * `ByteSize` wrappers over supported unsigned integer base types, with `BSize` as the `usize` alias and `BSize8`, `BSize16`, `BSize32`, and `BSize64` aliases for fixed-width base types. -* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". +* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". Fractional values are rounded once to the nearest whole byte, with ties rounded toward the larger byte count. * Exact `Display` impl for `ByteSize`, rendering the underlying byte count in base bytes (e.g., "1572864 B"). * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and decimal (e.g., "1.6 MB") styles. * Optional `serde` support for binary and human-readable format. diff --git a/bsize/src/lib.rs b/bsize/src/lib.rs index 61d4e16..04d209a 100644 --- a/bsize/src/lib.rs +++ b/bsize/src/lib.rs @@ -28,7 +28,8 @@ //! the `usize` alias and [`BSize8`], [`BSize16`], [`BSize32`], and [`BSize64`] as shorter aliases //! for fixed-width base types. //! * `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" -//! and "521 TB". +//! and "521 TB". Fractional values are rounded once to the nearest whole byte, with ties rounded +//! toward the larger byte count. //! * Exact [`core::fmt::Display`] impl for [`ByteSize`], rendering the underlying byte count in //! base bytes (e.g., "1572864 B"). //! * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and @@ -147,6 +148,35 @@ pub use self::traits::TeraByteSize; /// the exact underlying byte count. Its standard [`core::fmt::Display`] implementation renders /// that exact count in base bytes. Use [`ByteSize::display`] for configurable, approximate /// human-readable formatting. +/// +/// # Parsing and rounding +/// +/// Parsing applies the unit multiplier before rounding the resulting value once to a whole number +/// of bytes. It uses half-expand rounding: the nearest whole byte is selected, and a value exactly +/// halfway between two byte counts is rounded away from zero. Since byte sizes are non-negative, +/// this means that a tie is rounded toward the larger byte count. +/// +/// Decimal fractions are evaluated exactly without first converting them to floating point. +/// Overflow is checked after rounding, both against `u64` and against the integer type backing the +/// parsed [`ByteSize`]. +/// +/// ``` +/// use bsize::BSize8; +/// use bsize::BSize64; +/// use bsize::ParseError; +/// +/// assert_eq!(BSize64::b(0), "0.499 B".parse().unwrap()); +/// assert_eq!(BSize64::b(1), "0.5 B".parse().unwrap()); +/// assert_eq!(BSize64::b(1_235), "1.2345 kB".parse().unwrap()); +/// +/// // The rounded result fits in u8. +/// assert_eq!(BSize8::b(255), "255.4 B".parse().unwrap()); +/// // The rounded result does not. +/// assert_eq!( +/// ParseError::Overflow, +/// "255.5 B".parse::().unwrap_err(), +/// ); +/// ``` #[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ByteSize(T); diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs index 78c8cc8..f9f1f64 100644 --- a/bsize/src/parse.rs +++ b/bsize/src/parse.rs @@ -27,7 +27,7 @@ pub enum ParseError { Empty, /// The input contains malformed bytes. Malformed, - /// The parsed byte count is too large for the target integer type. + /// The resulting byte count is too large for the target integer type. Overflow, } @@ -309,8 +309,32 @@ mod tests { assert_eq!("4GiB".parse::>(), Err(ParseError::Overflow)); } + #[test] + fn fractional_values_round_half_expand() { + for (input, expected) in [ + ("0.499 B", 0), + ("0.5 B", 1), + ("0.501 B", 1), + ("1.499 B", 1), + ("1.5 B", 2), + ("2.5 B", 3), + ("0.0004 kB", 0), + ("0.0005 kB", 1), + ("0.0006 kB", 1), + ("0.00048828125 KiB", 1), + ] { + assert_parse_ok(input, expected); + } + } + + #[test] + fn rounding_precedes_target_range_check() { + assert_eq!("255.4 B".parse::>(), Ok(ByteSize::b(255))); + assert_eq!("255.5 B".parse::>(), Err(ParseError::Overflow),); + } + quickcheck::quickcheck! { - fn parses_eib_fractions_exactly(whole: u8, fraction: u64) -> bool { + fn eib_fractions_use_exact_half_expand_rounding(whole: u8, fraction: u64) -> bool { const MULTIPLIER: u128 = 1 << 60; const SCALE: u128 = 1_000_000_000_000_000_000; From 25222b95179a0f5ec254fad105268ce7bcb15a14 Mon Sep 17 00:00:00 2001 From: tison Date: Fri, 31 Jul 2026 23:38:18 +0800 Subject: [PATCH 2/7] feat: support parser rounding modes --- CHANGELOG.md | 4 + README.md | 2 +- bsize/src/lib.rs | 17 ++- bsize/src/parse.rs | 283 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 285 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bfdaf..d587944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All significant changes to this software be documented in this file. ## Unreleased +### New features + +* Added `RoundMode` and `ByteSize::parse_with_rounding` for selecting how fractional byte counts are rounded to whole bytes. + ### Bug fixes * Parse arbitrarily precise fractional byte sizes without double rounding or false overflow. diff --git a/README.md b/README.md index 73fb4ad..4ed14f8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This crate provides multiple semantic wrappers and utilities for byte size repre * `#![no_std]`-capable, no heap allocation, and no runtime dependencies by default. * `ByteSize` wrappers over supported unsigned integer base types, with `BSize` as the `usize` alias and `BSize8`, `BSize16`, `BSize32`, and `BSize64` aliases for fixed-width base types. -* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". Fractional values are rounded once to the nearest whole byte, with ties rounded toward the larger byte count. +* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". Fractional values default to half-expand rounding, and all rounding modes can be selected explicitly with `ByteSize::parse_with_rounding`. * Exact `Display` impl for `ByteSize`, rendering the underlying byte count in base bytes (e.g., "1572864 B"). * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and decimal (e.g., "1.6 MB") styles. * Optional `serde` support for binary and human-readable format. diff --git a/bsize/src/lib.rs b/bsize/src/lib.rs index 04d209a..bef86bb 100644 --- a/bsize/src/lib.rs +++ b/bsize/src/lib.rs @@ -28,8 +28,8 @@ //! the `usize` alias and [`BSize8`], [`BSize16`], [`BSize32`], and [`BSize64`] as shorter aliases //! for fixed-width base types. //! * `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" -//! and "521 TB". Fractional values are rounded once to the nearest whole byte, with ties rounded -//! toward the larger byte count. +//! and "521 TB". Fractional values default to half-expand rounding, and all [`RoundMode`] +//! variants can be selected explicitly with [`ByteSize::parse_with_rounding`]. //! * Exact [`core::fmt::Display`] impl for [`ByteSize`], rendering the underlying byte count in //! base bytes (e.g., "1572864 B"). //! * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and @@ -134,6 +134,7 @@ pub use self::display::DisplayScale; pub use self::display::DisplayUnitSystem; pub use self::display::display; pub use self::parse::ParseError; +pub use self::parse::RoundMode; pub use self::traits::BaseByteSize; pub use self::traits::ExaByteSize; pub use self::traits::GigaByteSize; @@ -152,9 +153,10 @@ pub use self::traits::TeraByteSize; /// # Parsing and rounding /// /// Parsing applies the unit multiplier before rounding the resulting value once to a whole number -/// of bytes. It uses half-expand rounding: the nearest whole byte is selected, and a value exactly -/// halfway between two byte counts is rounded away from zero. Since byte sizes are non-negative, -/// this means that a tie is rounded toward the larger byte count. +/// of bytes. The standard [`core::str::FromStr`] implementation uses [`RoundMode::HalfExpand`]: the +/// nearest whole byte is selected, and a value exactly halfway between two byte counts is rounded +/// away from zero. Since byte sizes are non-negative, this means that a tie is rounded toward the +/// larger byte count. Use [`ByteSize::parse_with_rounding`] to select another mode. /// /// Decimal fractions are evaluated exactly without first converting them to floating point. /// Overflow is checked after rounding, both against `u64` and against the integer type backing the @@ -164,10 +166,15 @@ pub use self::traits::TeraByteSize; /// use bsize::BSize8; /// use bsize::BSize64; /// use bsize::ParseError; +/// use bsize::RoundMode; /// /// assert_eq!(BSize64::b(0), "0.499 B".parse().unwrap()); /// assert_eq!(BSize64::b(1), "0.5 B".parse().unwrap()); /// assert_eq!(BSize64::b(1_235), "1.2345 kB".parse().unwrap()); +/// assert_eq!( +/// BSize64::b(2), +/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfEven).unwrap(), +/// ); /// /// // The rounded result fits in u8. /// assert_eq!(BSize8::b(255), "255.4 B".parse().unwrap()); diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs index f9f1f64..31d5e19 100644 --- a/bsize/src/parse.rs +++ b/bsize/src/parse.rs @@ -19,6 +19,81 @@ use core::str::FromStr; use crate::BaseByteSize; use crate::ByteSize; +/// The mode used to round a fractional byte count to a whole number of bytes. +/// +/// Parsing only accepts non-negative byte sizes. Consequently, some modes have equivalent behavior: +/// [`Ceil`](RoundMode::Ceil) and [`Expand`](RoundMode::Expand), +/// [`Floor`](RoundMode::Floor) and [`Trunc`](RoundMode::Trunc), +/// [`HalfCeil`](RoundMode::HalfCeil) and [`HalfExpand`](RoundMode::HalfExpand), and +/// [`HalfFloor`](RoundMode::HalfFloor) and [`HalfTrunc`](RoundMode::HalfTrunc). +/// +/// [`RoundMode::HalfExpand`] is the default used by [`core::str::FromStr`]. Use +/// [`ByteSize::parse_with_rounding`] to select another mode. +/// +/// # Examples +/// +/// ``` +/// use bsize::BSize64; +/// use bsize::RoundMode; +/// +/// assert_eq!( +/// BSize64::b(2), +/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfEven).unwrap(), +/// ); +/// assert_eq!( +/// BSize64::b(3), +/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfExpand).unwrap(), +/// ); +/// ``` +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub enum RoundMode { + /// Rounds toward positive infinity. + /// + /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Expand`]. + Ceil, + /// Rounds toward negative infinity. + /// + /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Trunc`]. + Floor, + /// Rounds away from zero. + /// + /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Ceil`]. + Expand, + /// Rounds toward zero, discarding any fractional byte. + /// + /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Floor`]. + Trunc, + /// Rounds to the nearest whole byte, with ties toward positive infinity. + /// + /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::HalfExpand`]. + HalfCeil, + /// Rounds to the nearest whole byte, with ties toward negative infinity. + /// + /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::HalfTrunc`]. + HalfFloor, + /// Rounds to the nearest whole byte, with ties away from zero. + /// + /// Since parsed byte sizes are non-negative, a tie is rounded toward the larger byte count. + /// This is the default used by [`core::str::FromStr`]. + HalfExpand, + /// Rounds to the nearest whole byte, with ties toward zero. + /// + /// Since parsed byte sizes are non-negative, a tie is rounded toward the smaller byte count. + HalfTrunc, + /// Rounds to the nearest whole byte, with ties toward the even byte count. + HalfEven, +} + +impl RoundMode { + const fn needs_trailing_nonzero(self) -> bool { + matches!( + self, + Self::Ceil | Self::Expand | Self::HalfFloor | Self::HalfTrunc | Self::HalfEven + ) + } +} + /// The error returned when parsing a byte size fails. #[derive(Debug, Clone, Eq, PartialEq)] #[non_exhaustive] @@ -43,12 +118,53 @@ impl fmt::Display for ParseError { impl core::error::Error for ParseError {} +impl ByteSize +where + T: BaseByteSize + TryFrom, +{ + /// Parses a byte size using the given rounding mode. + /// + /// The unit multiplier is applied before the resulting value is rounded once to a whole number + /// of bytes. Decimal fractions are evaluated exactly without first converting them to floating + /// point. Overflow is checked after rounding, both against `u64` and against the integer type + /// backing this [`ByteSize`]. + /// + /// Use the standard [`core::str::FromStr`] implementation when the default + /// [`RoundMode::HalfExpand`] behavior is sufficient. + /// + /// # Examples + /// + /// ``` + /// use bsize::BSize8; + /// use bsize::RoundMode; + /// + /// assert_eq!( + /// BSize8::b(1), + /// BSize8::parse_with_rounding("1.9 B", RoundMode::Trunc).unwrap(), + /// ); + /// assert_eq!( + /// BSize8::b(2), + /// BSize8::parse_with_rounding("1.1 B", RoundMode::Ceil).unwrap(), + /// ); + /// ``` + pub fn parse_with_rounding(s: &str, mode: RoundMode) -> Result { + let size = if mode.needs_trailing_nonzero() { + parse_size::(s.as_bytes(), mode)? + } else { + parse_size::(s.as_bytes(), mode)? + }; + bsize_from_u64(size) + } +} + macroweave::repeat!(Ty in [u8, u16, u32, u64, usize] { impl FromStr for ByteSize { type Err = ParseError; fn from_str(s: &str) -> Result { - bsize_from_u64(parse_size(s.as_bytes())?) + // HalfExpand rounds both exact ties and greater-than-half values upward, so the + // default path does not need to track lower discarded digits. + bsize_from_u64(parse_size::(s.as_bytes(), RoundMode::HalfExpand)?) } } }); @@ -62,7 +178,10 @@ where .map_err(|_| ParseError::Overflow) } -fn parse_size(mut src: &[u8]) -> Result { +fn parse_size( + mut src: &[u8], + mode: RoundMode, +) -> Result { // trim starting and trailing spaces while let [b' ', init @ ..] = src { src = init; @@ -155,23 +274,49 @@ fn parse_size(mut src: &[u8]) -> Result { debug_assert!(multiplier <= u64::MAX / 10); let mut carry = 0u64; let mut rounding_digit = 0u64; + let mut trailing_nonzero = false; for b in src[start..].iter().copied().rev() { if b == b'_' { continue; } let product = u64::from(b - b'0') * multiplier + carry; + if TRACK_TRAILING_NONZERO { + trailing_nonzero |= rounding_digit != 0; + } rounding_digit = product % 10; carry = product / 10; } - let fraction = carry + u64::from(rounding_digit >= 5); - bytes = bytes.checked_add(fraction).ok_or(ParseError::Overflow)?; + bytes = bytes.checked_add(carry).ok_or(ParseError::Overflow)?; + let round_up = should_round_up(mode, bytes, rounding_digit, trailing_nonzero); + bytes = bytes + .checked_add(u64::from(round_up)) + .ok_or(ParseError::Overflow)?; } Ok(bytes) } +fn should_round_up( + mode: RoundMode, + lower: u64, + rounding_digit: u64, + trailing_nonzero: bool, +) -> bool { + let has_remainder = rounding_digit != 0 || trailing_nonzero; + let greater_than_half = rounding_digit > 5 || (rounding_digit == 5 && trailing_nonzero); + let tie = rounding_digit == 5 && !trailing_nonzero; + + match mode { + RoundMode::Ceil | RoundMode::Expand => has_remainder, + RoundMode::Floor | RoundMode::Trunc => false, + RoundMode::HalfCeil | RoundMode::HalfExpand => greater_than_half || tie, + RoundMode::HalfFloor | RoundMode::HalfTrunc => greater_than_half, + RoundMode::HalfEven => greater_than_half || (tie && lower % 2 == 1), + } +} + #[cfg(test)] mod tests { use alloc::format; @@ -179,6 +324,18 @@ mod tests { use super::*; + const ROUND_MODES: [RoundMode; 9] = [ + RoundMode::Ceil, + RoundMode::Floor, + RoundMode::Expand, + RoundMode::Trunc, + RoundMode::HalfCeil, + RoundMode::HalfFloor, + RoundMode::HalfExpand, + RoundMode::HalfTrunc, + RoundMode::HalfEven, + ]; + fn assert_parse_ok(input: &str, expected: u64) { let actual = ByteSize::::from_str(input).unwrap(); let expected = ByteSize::::b(expected); @@ -196,6 +353,15 @@ mod tests { ); } + fn assert_rounds(input: &str, mode: RoundMode, expected: u64) { + let actual = ByteSize::::parse_with_rounding(input, mode).unwrap(); + assert_eq!( + actual, + ByteSize::b(expected), + "input: {input:?}, mode: {mode:?}" + ); + } + #[test] fn test_parse_ok() { for (input, expected) in [ @@ -327,32 +493,116 @@ mod tests { } } + #[test] + fn supports_all_rounding_modes() { + for mode in [RoundMode::Ceil, RoundMode::Expand] { + assert_rounds("1 B", mode, 1); + assert_rounds("1.0000000000000000001 B", mode, 2); + assert_rounds("1.9 B", mode, 2); + } + + for mode in [RoundMode::Floor, RoundMode::Trunc] { + assert_rounds("1 B", mode, 1); + assert_rounds("1.1 B", mode, 1); + assert_rounds("1.9999999999999999999 B", mode, 1); + } + + for mode in [RoundMode::HalfCeil, RoundMode::HalfExpand] { + assert_rounds("1.4999999999999999999 B", mode, 1); + assert_rounds("1.5 B", mode, 2); + assert_rounds("1.5000000000000000001 B", mode, 2); + } + + for mode in [RoundMode::HalfFloor, RoundMode::HalfTrunc] { + assert_rounds("1.4999999999999999999 B", mode, 1); + assert_rounds("1.5 B", mode, 1); + assert_rounds("1.5000000000000000001 B", mode, 2); + } + + assert_rounds("0.5 B", RoundMode::HalfEven, 0); + assert_rounds("1.5 B", RoundMode::HalfEven, 2); + assert_rounds("2.5 B", RoundMode::HalfEven, 2); + assert_rounds("3.5 B", RoundMode::HalfEven, 4); + assert_rounds("2.5000000000000000001 B", RoundMode::HalfEven, 3); + } + + #[test] + fn applies_units_before_rounding() { + for input in ["0.0005 kB", "0.00048828125 KiB"] { + assert_rounds(input, RoundMode::HalfExpand, 1); + assert_rounds(input, RoundMode::HalfTrunc, 0); + assert_rounds(input, RoundMode::HalfEven, 0); + } + + assert_rounds("0.0015 kB", RoundMode::HalfEven, 2); + assert_rounds("0.0025 kB", RoundMode::HalfEven, 2); + } + #[test] fn rounding_precedes_target_range_check() { assert_eq!("255.4 B".parse::>(), Ok(ByteSize::b(255))); assert_eq!("255.5 B".parse::>(), Err(ParseError::Overflow),); + + assert_eq!( + ByteSize::::parse_with_rounding("255.9 B", RoundMode::Floor), + Ok(ByteSize::b(255)), + ); + assert_eq!( + ByteSize::::parse_with_rounding("255.1 B", RoundMode::Ceil), + Err(ParseError::Overflow), + ); + assert_eq!( + ByteSize::::parse_with_rounding("255.5 B", RoundMode::HalfTrunc), + Ok(ByteSize::b(255)), + ); + assert_eq!( + ByteSize::::parse_with_rounding("255.5 B", RoundMode::HalfEven), + Err(ParseError::Overflow), + ); } quickcheck::quickcheck! { - fn eib_fractions_use_exact_half_expand_rounding(whole: u8, fraction: u64) -> bool { + fn eib_fractions_follow_each_rounding_mode(whole: u8, fraction: u64) -> bool { const MULTIPLIER: u128 = 1 << 60; const SCALE: u128 = 1_000_000_000_000_000_000; let whole = whole % 16; let fraction = fraction % SCALE as u64; let input = format!("{whole}.{fraction:018} EiB"); - let actual = input.parse::>(); - let expected = u128::from(whole) * MULTIPLIER - + (u128::from(fraction) * MULTIPLIER + SCALE / 2) / SCALE; - - if expected > u128::from(u64::MAX) { - actual == Err(ParseError::Overflow) - } else { - actual == Ok(ByteSize::b(u64::try_from(expected).unwrap())) + let exact = (u128::from(whole) * SCALE + u128::from(fraction)) * MULTIPLIER; + let lower = exact / SCALE; + let remainder = exact % SCALE; + let twice_remainder = remainder * 2; + + for mode in ROUND_MODES { + let round_up = match mode { + RoundMode::Ceil | RoundMode::Expand => remainder != 0, + RoundMode::Floor | RoundMode::Trunc => false, + RoundMode::HalfCeil | RoundMode::HalfExpand => twice_remainder >= SCALE, + RoundMode::HalfFloor | RoundMode::HalfTrunc => twice_remainder > SCALE, + RoundMode::HalfEven => { + twice_remainder > SCALE || (twice_remainder == SCALE && lower % 2 == 1) + } + }; + let expected = lower + u128::from(round_up); + let actual = ByteSize::::parse_with_rounding(&input, mode); + + if expected > u128::from(u64::MAX) { + if actual != Err(ParseError::Overflow) { + return false; + } + } else if actual != Ok(ByteSize::b(u64::try_from(expected).unwrap())) { + return false; + } } + + true } - fn fractional_trailing_zero_preserves_value(whole: u8, fraction: u64) -> bool { + fn fractional_trailing_zero_preserves_value_for_each_mode( + whole: u8, + fraction: u64 + ) -> bool { const SCALE: u64 = 1_000_000_000_000_000_000; let whole = whole % 16; @@ -360,7 +610,10 @@ mod tests { let input = format!("{whole}.{fraction:018} EiB"); let input_with_zero = format!("{whole}.{fraction:018}0 EiB"); - input.parse::>() == input_with_zero.parse::>() + ROUND_MODES.into_iter().all(|mode| { + ByteSize::::parse_with_rounding(&input, mode) + == ByteSize::::parse_with_rounding(&input_with_zero, mode) + }) } } } From 725537104808ea1f0a50348f55f178393a927ee8 Mon Sep 17 00:00:00 2001 From: tison Date: Fri, 31 Jul 2026 23:49:50 +0800 Subject: [PATCH 3/7] refactor: remove redundant rounding modes --- CHANGELOG.md | 2 +- README.md | 2 +- bsize/src/lib.rs | 9 ++-- bsize/src/parse.rs | 119 +++++++++++++++------------------------------ 4 files changed, 45 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d587944..72848af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All significant changes to this software be documented in this file. ### New features -* Added `RoundMode` and `ByteSize::parse_with_rounding` for selecting how fractional byte counts are rounded to whole bytes. +* Added `RoundMode` and `ByteSize::parse_with_rounding` for selecting ceil, floor, half-ceil, half-floor, or half-even rounding of fractional byte counts. Standard parsing uses half-ceil rounding. ### Bug fixes diff --git a/README.md b/README.md index 4ed14f8..4e3e379 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This crate provides multiple semantic wrappers and utilities for byte size repre * `#![no_std]`-capable, no heap allocation, and no runtime dependencies by default. * `ByteSize` wrappers over supported unsigned integer base types, with `BSize` as the `usize` alias and `BSize8`, `BSize16`, `BSize32`, and `BSize64` aliases for fixed-width base types. -* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". Fractional values default to half-expand rounding, and all rounding modes can be selected explicitly with `ByteSize::parse_with_rounding`. +* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". Fractional values default to half-ceil rounding, and the supported rounding modes can be selected explicitly with `ByteSize::parse_with_rounding`. * Exact `Display` impl for `ByteSize`, rendering the underlying byte count in base bytes (e.g., "1572864 B"). * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and decimal (e.g., "1.6 MB") styles. * Optional `serde` support for binary and human-readable format. diff --git a/bsize/src/lib.rs b/bsize/src/lib.rs index bef86bb..f1f4c80 100644 --- a/bsize/src/lib.rs +++ b/bsize/src/lib.rs @@ -28,8 +28,8 @@ //! the `usize` alias and [`BSize8`], [`BSize16`], [`BSize32`], and [`BSize64`] as shorter aliases //! for fixed-width base types. //! * `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" -//! and "521 TB". Fractional values default to half-expand rounding, and all [`RoundMode`] -//! variants can be selected explicitly with [`ByteSize::parse_with_rounding`]. +//! and "521 TB". Fractional values default to half-ceil rounding, and all [`RoundMode`] variants +//! can be selected explicitly with [`ByteSize::parse_with_rounding`]. //! * Exact [`core::fmt::Display`] impl for [`ByteSize`], rendering the underlying byte count in //! base bytes (e.g., "1572864 B"). //! * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and @@ -153,10 +153,9 @@ pub use self::traits::TeraByteSize; /// # Parsing and rounding /// /// Parsing applies the unit multiplier before rounding the resulting value once to a whole number -/// of bytes. The standard [`core::str::FromStr`] implementation uses [`RoundMode::HalfExpand`]: the +/// of bytes. The standard [`core::str::FromStr`] implementation uses [`RoundMode::HalfCeil`]: the /// nearest whole byte is selected, and a value exactly halfway between two byte counts is rounded -/// away from zero. Since byte sizes are non-negative, this means that a tie is rounded toward the -/// larger byte count. Use [`ByteSize::parse_with_rounding`] to select another mode. +/// toward the larger byte count. Use [`ByteSize::parse_with_rounding`] to select another mode. /// /// Decimal fractions are evaluated exactly without first converting them to floating point. /// Overflow is checked after rounding, both against `u64` and against the integer type backing the diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs index 31d5e19..4aa5abd 100644 --- a/bsize/src/parse.rs +++ b/bsize/src/parse.rs @@ -21,13 +21,10 @@ use crate::ByteSize; /// The mode used to round a fractional byte count to a whole number of bytes. /// -/// Parsing only accepts non-negative byte sizes. Consequently, some modes have equivalent behavior: -/// [`Ceil`](RoundMode::Ceil) and [`Expand`](RoundMode::Expand), -/// [`Floor`](RoundMode::Floor) and [`Trunc`](RoundMode::Trunc), -/// [`HalfCeil`](RoundMode::HalfCeil) and [`HalfExpand`](RoundMode::HalfExpand), and -/// [`HalfFloor`](RoundMode::HalfFloor) and [`HalfTrunc`](RoundMode::HalfTrunc). +/// Parsing only accepts non-negative byte sizes, so these variants cover the distinct behaviors +/// relevant here without separate zero-oriented aliases such as truncation or expansion. /// -/// [`RoundMode::HalfExpand`] is the default used by [`core::str::FromStr`]. Use +/// [`RoundMode::HalfCeil`] is the default used by [`core::str::FromStr`]. Use /// [`ByteSize::parse_with_rounding`] to select another mode. /// /// # Examples @@ -42,55 +39,29 @@ use crate::ByteSize; /// ); /// assert_eq!( /// BSize64::b(3), -/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfExpand).unwrap(), +/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfCeil).unwrap(), /// ); /// ``` #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[non_exhaustive] pub enum RoundMode { - /// Rounds toward positive infinity. - /// - /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Expand`]. + /// Rounds toward the larger whole byte count. Ceil, - /// Rounds toward negative infinity. - /// - /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Trunc`]. + /// Rounds toward the smaller whole byte count, discarding any fractional byte. Floor, - /// Rounds away from zero. - /// - /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Ceil`]. - Expand, - /// Rounds toward zero, discarding any fractional byte. - /// - /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::Floor`]. - Trunc, - /// Rounds to the nearest whole byte, with ties toward positive infinity. + /// Rounds to the nearest whole byte, with ties toward the larger byte count. /// - /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::HalfExpand`]. + /// This is the default used by [`core::str::FromStr`]. HalfCeil, - /// Rounds to the nearest whole byte, with ties toward negative infinity. - /// - /// Since parsed byte sizes are non-negative, this is equivalent to [`RoundMode::HalfTrunc`]. + /// Rounds to the nearest whole byte, with ties toward the smaller byte count. HalfFloor, - /// Rounds to the nearest whole byte, with ties away from zero. - /// - /// Since parsed byte sizes are non-negative, a tie is rounded toward the larger byte count. - /// This is the default used by [`core::str::FromStr`]. - HalfExpand, - /// Rounds to the nearest whole byte, with ties toward zero. - /// - /// Since parsed byte sizes are non-negative, a tie is rounded toward the smaller byte count. - HalfTrunc, /// Rounds to the nearest whole byte, with ties toward the even byte count. HalfEven, } impl RoundMode { const fn needs_trailing_nonzero(self) -> bool { - matches!( - self, - Self::Ceil | Self::Expand | Self::HalfFloor | Self::HalfTrunc | Self::HalfEven - ) + matches!(self, Self::Ceil | Self::HalfFloor | Self::HalfEven) } } @@ -130,7 +101,7 @@ where /// backing this [`ByteSize`]. /// /// Use the standard [`core::str::FromStr`] implementation when the default - /// [`RoundMode::HalfExpand`] behavior is sufficient. + /// [`RoundMode::HalfCeil`] behavior is sufficient. /// /// # Examples /// @@ -140,7 +111,7 @@ where /// /// assert_eq!( /// BSize8::b(1), - /// BSize8::parse_with_rounding("1.9 B", RoundMode::Trunc).unwrap(), + /// BSize8::parse_with_rounding("1.9 B", RoundMode::Floor).unwrap(), /// ); /// assert_eq!( /// BSize8::b(2), @@ -162,9 +133,9 @@ macroweave::repeat!(Ty in [u8, u16, u32, u64, usize] { type Err = ParseError; fn from_str(s: &str) -> Result { - // HalfExpand rounds both exact ties and greater-than-half values upward, so the + // HalfCeil rounds both exact ties and greater-than-half values upward, so the // default path does not need to track lower discarded digits. - bsize_from_u64(parse_size::(s.as_bytes(), RoundMode::HalfExpand)?) + bsize_from_u64(parse_size::(s.as_bytes(), RoundMode::HalfCeil)?) } } }); @@ -309,10 +280,10 @@ fn should_round_up( let tie = rounding_digit == 5 && !trailing_nonzero; match mode { - RoundMode::Ceil | RoundMode::Expand => has_remainder, - RoundMode::Floor | RoundMode::Trunc => false, - RoundMode::HalfCeil | RoundMode::HalfExpand => greater_than_half || tie, - RoundMode::HalfFloor | RoundMode::HalfTrunc => greater_than_half, + RoundMode::Ceil => has_remainder, + RoundMode::Floor => false, + RoundMode::HalfCeil => greater_than_half || tie, + RoundMode::HalfFloor => greater_than_half, RoundMode::HalfEven => greater_than_half || (tie && lower % 2 == 1), } } @@ -324,15 +295,11 @@ mod tests { use super::*; - const ROUND_MODES: [RoundMode; 9] = [ + const ROUND_MODES: [RoundMode; 5] = [ RoundMode::Ceil, RoundMode::Floor, - RoundMode::Expand, - RoundMode::Trunc, RoundMode::HalfCeil, RoundMode::HalfFloor, - RoundMode::HalfExpand, - RoundMode::HalfTrunc, RoundMode::HalfEven, ]; @@ -476,7 +443,7 @@ mod tests { } #[test] - fn fractional_values_round_half_expand() { + fn fractional_values_round_half_ceil() { for (input, expected) in [ ("0.499 B", 0), ("0.5 B", 1), @@ -495,29 +462,21 @@ mod tests { #[test] fn supports_all_rounding_modes() { - for mode in [RoundMode::Ceil, RoundMode::Expand] { - assert_rounds("1 B", mode, 1); - assert_rounds("1.0000000000000000001 B", mode, 2); - assert_rounds("1.9 B", mode, 2); - } + assert_rounds("1 B", RoundMode::Ceil, 1); + assert_rounds("1.0000000000000000001 B", RoundMode::Ceil, 2); + assert_rounds("1.9 B", RoundMode::Ceil, 2); - for mode in [RoundMode::Floor, RoundMode::Trunc] { - assert_rounds("1 B", mode, 1); - assert_rounds("1.1 B", mode, 1); - assert_rounds("1.9999999999999999999 B", mode, 1); - } + assert_rounds("1 B", RoundMode::Floor, 1); + assert_rounds("1.1 B", RoundMode::Floor, 1); + assert_rounds("1.9999999999999999999 B", RoundMode::Floor, 1); - for mode in [RoundMode::HalfCeil, RoundMode::HalfExpand] { - assert_rounds("1.4999999999999999999 B", mode, 1); - assert_rounds("1.5 B", mode, 2); - assert_rounds("1.5000000000000000001 B", mode, 2); - } + assert_rounds("1.4999999999999999999 B", RoundMode::HalfCeil, 1); + assert_rounds("1.5 B", RoundMode::HalfCeil, 2); + assert_rounds("1.5000000000000000001 B", RoundMode::HalfCeil, 2); - for mode in [RoundMode::HalfFloor, RoundMode::HalfTrunc] { - assert_rounds("1.4999999999999999999 B", mode, 1); - assert_rounds("1.5 B", mode, 1); - assert_rounds("1.5000000000000000001 B", mode, 2); - } + assert_rounds("1.4999999999999999999 B", RoundMode::HalfFloor, 1); + assert_rounds("1.5 B", RoundMode::HalfFloor, 1); + assert_rounds("1.5000000000000000001 B", RoundMode::HalfFloor, 2); assert_rounds("0.5 B", RoundMode::HalfEven, 0); assert_rounds("1.5 B", RoundMode::HalfEven, 2); @@ -529,8 +488,8 @@ mod tests { #[test] fn applies_units_before_rounding() { for input in ["0.0005 kB", "0.00048828125 KiB"] { - assert_rounds(input, RoundMode::HalfExpand, 1); - assert_rounds(input, RoundMode::HalfTrunc, 0); + assert_rounds(input, RoundMode::HalfCeil, 1); + assert_rounds(input, RoundMode::HalfFloor, 0); assert_rounds(input, RoundMode::HalfEven, 0); } @@ -552,7 +511,7 @@ mod tests { Err(ParseError::Overflow), ); assert_eq!( - ByteSize::::parse_with_rounding("255.5 B", RoundMode::HalfTrunc), + ByteSize::::parse_with_rounding("255.5 B", RoundMode::HalfFloor), Ok(ByteSize::b(255)), ); assert_eq!( @@ -576,10 +535,10 @@ mod tests { for mode in ROUND_MODES { let round_up = match mode { - RoundMode::Ceil | RoundMode::Expand => remainder != 0, - RoundMode::Floor | RoundMode::Trunc => false, - RoundMode::HalfCeil | RoundMode::HalfExpand => twice_remainder >= SCALE, - RoundMode::HalfFloor | RoundMode::HalfTrunc => twice_remainder > SCALE, + RoundMode::Ceil => remainder != 0, + RoundMode::Floor => false, + RoundMode::HalfCeil => twice_remainder >= SCALE, + RoundMode::HalfFloor => twice_remainder > SCALE, RoundMode::HalfEven => { twice_remainder > SCALE || (twice_remainder == SCALE && lower % 2 == 1) } From c98a631add3abb9d305393d76ef39491f785bce7 Mon Sep 17 00:00:00 2001 From: tison Date: Fri, 31 Jul 2026 23:58:21 +0800 Subject: [PATCH 4/7] refactor: configure parsing with options --- CHANGELOG.md | 2 +- README.md | 2 +- bsize/src/lib.rs | 11 +++-- bsize/src/parse.rs | 101 +++++++++++++++++++++++++++++++++------------ 4 files changed, 84 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72848af..5e904bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All significant changes to this software be documented in this file. ### New features -* Added `RoundMode` and `ByteSize::parse_with_rounding` for selecting ceil, floor, half-ceil, half-floor, or half-even rounding of fractional byte counts. Standard parsing uses half-ceil rounding. +* Added `RoundMode`, `ParseOptions`, and `ByteSize::parse_with` for selecting ceil, floor, half-ceil, half-floor, or half-even rounding of fractional byte counts. Standard parsing uses the default options with half-ceil rounding. ### Bug fixes diff --git a/README.md b/README.md index 4e3e379..9b26bb4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This crate provides multiple semantic wrappers and utilities for byte size repre * `#![no_std]`-capable, no heap allocation, and no runtime dependencies by default. * `ByteSize` wrappers over supported unsigned integer base types, with `BSize` as the `usize` alias and `BSize8`, `BSize16`, `BSize32`, and `BSize64` aliases for fixed-width base types. -* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". Fractional values default to half-ceil rounding, and the supported rounding modes can be selected explicitly with `ByteSize::parse_with_rounding`. +* `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" and "521 TB". Fractional values default to half-ceil rounding, and the supported rounding modes can be selected explicitly with `ByteSize::parse_with` and `ParseOptions`. * Exact `Display` impl for `ByteSize`, rendering the underlying byte count in base bytes (e.g., "1572864 B"). * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and decimal (e.g., "1.6 MB") styles. * Optional `serde` support for binary and human-readable format. diff --git a/bsize/src/lib.rs b/bsize/src/lib.rs index f1f4c80..daf7292 100644 --- a/bsize/src/lib.rs +++ b/bsize/src/lib.rs @@ -29,7 +29,7 @@ //! for fixed-width base types. //! * `FromStr` impl for `ByteSize`, allowing for parsing string size representations like "1.5 KiB" //! and "521 TB". Fractional values default to half-ceil rounding, and all [`RoundMode`] variants -//! can be selected explicitly with [`ByteSize::parse_with_rounding`]. +//! can be selected explicitly with [`ByteSize::parse_with`] and [`ParseOptions`]. //! * Exact [`core::fmt::Display`] impl for [`ByteSize`], rendering the underlying byte count in //! base bytes (e.g., "1572864 B"). //! * Configurable, approximate human-readable formatting in both binary (e.g., "1.5 MiB") and @@ -134,6 +134,7 @@ pub use self::display::DisplayScale; pub use self::display::DisplayUnitSystem; pub use self::display::display; pub use self::parse::ParseError; +pub use self::parse::ParseOptions; pub use self::parse::RoundMode; pub use self::traits::BaseByteSize; pub use self::traits::ExaByteSize; @@ -155,7 +156,8 @@ pub use self::traits::TeraByteSize; /// Parsing applies the unit multiplier before rounding the resulting value once to a whole number /// of bytes. The standard [`core::str::FromStr`] implementation uses [`RoundMode::HalfCeil`]: the /// nearest whole byte is selected, and a value exactly halfway between two byte counts is rounded -/// toward the larger byte count. Use [`ByteSize::parse_with_rounding`] to select another mode. +/// toward the larger byte count. Use [`ByteSize::parse_with`] and [`ParseOptions`] to select +/// another mode. /// /// Decimal fractions are evaluated exactly without first converting them to floating point. /// Overflow is checked after rounding, both against `u64` and against the integer type backing the @@ -165,14 +167,17 @@ pub use self::traits::TeraByteSize; /// use bsize::BSize8; /// use bsize::BSize64; /// use bsize::ParseError; +/// use bsize::ParseOptions; /// use bsize::RoundMode; /// /// assert_eq!(BSize64::b(0), "0.499 B".parse().unwrap()); /// assert_eq!(BSize64::b(1), "0.5 B".parse().unwrap()); /// assert_eq!(BSize64::b(1_235), "1.2345 kB".parse().unwrap()); +/// let mut options = ParseOptions::default(); +/// options.round_mode = RoundMode::HalfEven; /// assert_eq!( /// BSize64::b(2), -/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfEven).unwrap(), +/// BSize64::parse_with("2.5 B", options).unwrap(), /// ); /// /// // The rounded result fits in u8. diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs index 4aa5abd..52c2009 100644 --- a/bsize/src/parse.rs +++ b/bsize/src/parse.rs @@ -24,22 +24,26 @@ use crate::ByteSize; /// Parsing only accepts non-negative byte sizes, so these variants cover the distinct behaviors /// relevant here without separate zero-oriented aliases such as truncation or expansion. /// -/// [`RoundMode::HalfCeil`] is the default used by [`core::str::FromStr`]. Use -/// [`ByteSize::parse_with_rounding`] to select another mode. +/// [`RoundMode::HalfCeil`] is the default used by [`ParseOptions`] and [`core::str::FromStr`]. Use +/// [`ByteSize::parse_with`] to select another mode. /// /// # Examples /// /// ``` /// use bsize::BSize64; +/// use bsize::ParseOptions; /// use bsize::RoundMode; /// +/// let mut options = ParseOptions::default(); +/// options.round_mode = RoundMode::HalfEven; /// assert_eq!( /// BSize64::b(2), -/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfEven).unwrap(), +/// BSize64::parse_with("2.5 B", options).unwrap(), /// ); +/// options.round_mode = RoundMode::HalfCeil; /// assert_eq!( /// BSize64::b(3), -/// BSize64::parse_with_rounding("2.5 B", RoundMode::HalfCeil).unwrap(), +/// BSize64::parse_with("2.5 B", options).unwrap(), /// ); /// ``` #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -65,6 +69,43 @@ impl RoundMode { } } +/// Options that control byte size parsing. +/// +/// Use [`ParseOptions::default`] for the standard parsing behavior, then update fields to select +/// different behavior. Pass the options to [`ByteSize::parse_with`]. +/// +/// # Examples +/// +/// ``` +/// use bsize::BSize64; +/// use bsize::ParseOptions; +/// use bsize::RoundMode; +/// +/// let mut options = ParseOptions::default(); +/// options.round_mode = RoundMode::Floor; +/// +/// assert_eq!( +/// BSize64::b(1), +/// BSize64::parse_with("1.9 B", options).unwrap() +/// ); +/// ``` +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[non_exhaustive] +pub struct ParseOptions { + /// The mode used to round a fractional byte count to a whole number of bytes. + /// + /// Defaults to [`RoundMode::HalfCeil`]. + pub round_mode: RoundMode, +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { + round_mode: RoundMode::HalfCeil, + } + } +} + /// The error returned when parsing a byte size fails. #[derive(Debug, Clone, Eq, PartialEq)] #[non_exhaustive] @@ -93,32 +134,31 @@ impl ByteSize where T: BaseByteSize + TryFrom, { - /// Parses a byte size using the given rounding mode. + /// Parses a byte size using the given options. /// /// The unit multiplier is applied before the resulting value is rounded once to a whole number /// of bytes. Decimal fractions are evaluated exactly without first converting them to floating /// point. Overflow is checked after rounding, both against `u64` and against the integer type /// backing this [`ByteSize`]. /// - /// Use the standard [`core::str::FromStr`] implementation when the default - /// [`RoundMode::HalfCeil`] behavior is sufficient. + /// Use the standard [`core::str::FromStr`] implementation when [`ParseOptions::default`] is + /// sufficient. /// /// # Examples /// /// ``` /// use bsize::BSize8; + /// use bsize::ParseOptions; /// use bsize::RoundMode; /// - /// assert_eq!( - /// BSize8::b(1), - /// BSize8::parse_with_rounding("1.9 B", RoundMode::Floor).unwrap(), - /// ); - /// assert_eq!( - /// BSize8::b(2), - /// BSize8::parse_with_rounding("1.1 B", RoundMode::Ceil).unwrap(), - /// ); + /// let mut options = ParseOptions::default(); + /// options.round_mode = RoundMode::Floor; + /// assert_eq!(BSize8::b(1), BSize8::parse_with("1.9 B", options).unwrap()); + /// options.round_mode = RoundMode::Ceil; + /// assert_eq!(BSize8::b(2), BSize8::parse_with("1.1 B", options).unwrap()); /// ``` - pub fn parse_with_rounding(s: &str, mode: RoundMode) -> Result { + pub fn parse_with(s: &str, options: ParseOptions) -> Result { + let mode = options.round_mode; let size = if mode.needs_trailing_nonzero() { parse_size::(s.as_bytes(), mode)? } else { @@ -133,9 +173,7 @@ macroweave::repeat!(Ty in [u8, u16, u32, u64, usize] { type Err = ParseError; fn from_str(s: &str) -> Result { - // HalfCeil rounds both exact ties and greater-than-half values upward, so the - // default path does not need to track lower discarded digits. - bsize_from_u64(parse_size::(s.as_bytes(), RoundMode::HalfCeil)?) + Self::parse_with(s, ParseOptions::default()) } } }); @@ -321,7 +359,8 @@ mod tests { } fn assert_rounds(input: &str, mode: RoundMode, expected: u64) { - let actual = ByteSize::::parse_with_rounding(input, mode).unwrap(); + let options = ParseOptions { round_mode: mode }; + let actual = ByteSize::::parse_with(input, options).unwrap(); assert_eq!( actual, ByteSize::b(expected), @@ -502,20 +541,26 @@ mod tests { assert_eq!("255.4 B".parse::>(), Ok(ByteSize::b(255))); assert_eq!("255.5 B".parse::>(), Err(ParseError::Overflow),); + let mut options = ParseOptions { + round_mode: RoundMode::Floor, + }; assert_eq!( - ByteSize::::parse_with_rounding("255.9 B", RoundMode::Floor), + ByteSize::::parse_with("255.9 B", options), Ok(ByteSize::b(255)), ); + options.round_mode = RoundMode::Ceil; assert_eq!( - ByteSize::::parse_with_rounding("255.1 B", RoundMode::Ceil), + ByteSize::::parse_with("255.1 B", options), Err(ParseError::Overflow), ); + options.round_mode = RoundMode::HalfFloor; assert_eq!( - ByteSize::::parse_with_rounding("255.5 B", RoundMode::HalfFloor), + ByteSize::::parse_with("255.5 B", options), Ok(ByteSize::b(255)), ); + options.round_mode = RoundMode::HalfEven; assert_eq!( - ByteSize::::parse_with_rounding("255.5 B", RoundMode::HalfEven), + ByteSize::::parse_with("255.5 B", options), Err(ParseError::Overflow), ); } @@ -544,7 +589,8 @@ mod tests { } }; let expected = lower + u128::from(round_up); - let actual = ByteSize::::parse_with_rounding(&input, mode); + let options = ParseOptions { round_mode: mode }; + let actual = ByteSize::::parse_with(&input, options); if expected > u128::from(u64::MAX) { if actual != Err(ParseError::Overflow) { @@ -570,8 +616,9 @@ mod tests { let input_with_zero = format!("{whole}.{fraction:018}0 EiB"); ROUND_MODES.into_iter().all(|mode| { - ByteSize::::parse_with_rounding(&input, mode) - == ByteSize::::parse_with_rounding(&input_with_zero, mode) + let options = ParseOptions { round_mode: mode }; + ByteSize::::parse_with(&input, options) + == ByteSize::::parse_with(&input_with_zero, options) }) } } From 7d199007700fb30bf0cdd27c4f35b391a3eb848b Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 1 Aug 2026 00:15:49 +0800 Subject: [PATCH 5/7] refactor: simplify sticky bit tracking --- bsize/src/parse.rs | 42 ++++++++++++------------------------------ 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs index 52c2009..65da95b 100644 --- a/bsize/src/parse.rs +++ b/bsize/src/parse.rs @@ -63,12 +63,6 @@ pub enum RoundMode { HalfEven, } -impl RoundMode { - const fn needs_trailing_nonzero(self) -> bool { - matches!(self, Self::Ceil | Self::HalfFloor | Self::HalfEven) - } -} - /// Options that control byte size parsing. /// /// Use [`ParseOptions::default`] for the standard parsing behavior, then update fields to select @@ -159,11 +153,7 @@ where /// ``` pub fn parse_with(s: &str, options: ParseOptions) -> Result { let mode = options.round_mode; - let size = if mode.needs_trailing_nonzero() { - parse_size::(s.as_bytes(), mode)? - } else { - parse_size::(s.as_bytes(), mode)? - }; + let size = parse_size(s.as_bytes(), mode)?; bsize_from_u64(size) } } @@ -187,10 +177,7 @@ where .map_err(|_| ParseError::Overflow) } -fn parse_size( - mut src: &[u8], - mode: RoundMode, -) -> Result { +fn parse_size(mut src: &[u8], mode: RoundMode) -> Result { // trim starting and trailing spaces while let [b' ', init @ ..] = src { src = init; @@ -279,26 +266,26 @@ fn parse_size( if let Some(start) = fraction_start { // Multiply the fraction by the unit multiplier from right to left in base 10. // Once all fractional digits are consumed, carry is the integral byte count and - // the last remainder digit determines rounding to the nearest byte. + // the last remainder digit is the first discarded decimal digit. The sticky bit records + // whether any lower discarded digit is nonzero, which distinguishes an exact tie from a + // value just above it. debug_assert!(multiplier <= u64::MAX / 10); let mut carry = 0u64; let mut rounding_digit = 0u64; - let mut trailing_nonzero = false; + let mut sticky_bit = false; for b in src[start..].iter().copied().rev() { if b == b'_' { continue; } let product = u64::from(b - b'0') * multiplier + carry; - if TRACK_TRAILING_NONZERO { - trailing_nonzero |= rounding_digit != 0; - } + sticky_bit |= rounding_digit != 0; rounding_digit = product % 10; carry = product / 10; } bytes = bytes.checked_add(carry).ok_or(ParseError::Overflow)?; - let round_up = should_round_up(mode, bytes, rounding_digit, trailing_nonzero); + let round_up = should_round_up(mode, bytes, rounding_digit, sticky_bit); bytes = bytes .checked_add(u64::from(round_up)) .ok_or(ParseError::Overflow)?; @@ -307,15 +294,10 @@ fn parse_size( Ok(bytes) } -fn should_round_up( - mode: RoundMode, - lower: u64, - rounding_digit: u64, - trailing_nonzero: bool, -) -> bool { - let has_remainder = rounding_digit != 0 || trailing_nonzero; - let greater_than_half = rounding_digit > 5 || (rounding_digit == 5 && trailing_nonzero); - let tie = rounding_digit == 5 && !trailing_nonzero; +fn should_round_up(mode: RoundMode, lower: u64, rounding_digit: u64, sticky_bit: bool) -> bool { + let has_remainder = rounding_digit != 0 || sticky_bit; + let greater_than_half = rounding_digit > 5 || (rounding_digit == 5 && sticky_bit); + let tie = rounding_digit == 5 && !sticky_bit; match mode { RoundMode::Ceil => has_remainder, From 5a016613215ee1cc7f8f1b29387f2e8bbd643d00 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 1 Aug 2026 00:22:03 +0800 Subject: [PATCH 6/7] refactor: clarify parser rounding state --- bsize/src/parse.rs | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs index 65da95b..06dda7c 100644 --- a/bsize/src/parse.rs +++ b/bsize/src/parse.rs @@ -266,26 +266,26 @@ fn parse_size(mut src: &[u8], mode: RoundMode) -> Result { if let Some(start) = fraction_start { // Multiply the fraction by the unit multiplier from right to left in base 10. // Once all fractional digits are consumed, carry is the integral byte count and - // the last remainder digit is the first discarded decimal digit. The sticky bit records - // whether any lower discarded digit is nonzero, which distinguishes an exact tie from a - // value just above it. + // the last remainder digit is the first discarded decimal digit. A later nonzero digit + // determines whether a leading 5 is exactly half or greater than half. debug_assert!(multiplier <= u64::MAX / 10); let mut carry = 0u64; - let mut rounding_digit = 0u64; - let mut sticky_bit = false; + let mut first_discarded_digit = 0u64; + let mut has_nonzero_later_digits = false; for b in src[start..].iter().copied().rev() { if b == b'_' { continue; } let product = u64::from(b - b'0') * multiplier + carry; - sticky_bit |= rounding_digit != 0; - rounding_digit = product % 10; + has_nonzero_later_digits |= first_discarded_digit != 0; + first_discarded_digit = product % 10; carry = product / 10; } bytes = bytes.checked_add(carry).ok_or(ParseError::Overflow)?; - let round_up = should_round_up(mode, bytes, rounding_digit, sticky_bit); + let round_up = + should_round_up(mode, bytes, first_discarded_digit, has_nonzero_later_digits); bytes = bytes .checked_add(u64::from(round_up)) .ok_or(ParseError::Overflow)?; @@ -294,17 +294,23 @@ fn parse_size(mut src: &[u8], mode: RoundMode) -> Result { Ok(bytes) } -fn should_round_up(mode: RoundMode, lower: u64, rounding_digit: u64, sticky_bit: bool) -> bool { - let has_remainder = rounding_digit != 0 || sticky_bit; - let greater_than_half = rounding_digit > 5 || (rounding_digit == 5 && sticky_bit); - let tie = rounding_digit == 5 && !sticky_bit; +fn should_round_up( + mode: RoundMode, + whole_bytes: u64, + first_discarded_digit: u64, + has_nonzero_later_digits: bool, +) -> bool { + let has_fractional_remainder = first_discarded_digit != 0 || has_nonzero_later_digits; + let is_greater_than_half = + first_discarded_digit > 5 || (first_discarded_digit == 5 && has_nonzero_later_digits); + let is_exactly_half = first_discarded_digit == 5 && !has_nonzero_later_digits; match mode { - RoundMode::Ceil => has_remainder, + RoundMode::Ceil => has_fractional_remainder, RoundMode::Floor => false, - RoundMode::HalfCeil => greater_than_half || tie, - RoundMode::HalfFloor => greater_than_half, - RoundMode::HalfEven => greater_than_half || (tie && lower % 2 == 1), + RoundMode::HalfCeil => is_greater_than_half || is_exactly_half, + RoundMode::HalfFloor => is_greater_than_half, + RoundMode::HalfEven => is_greater_than_half || (is_exactly_half && whole_bytes % 2 == 1), } } From 68f75c486f9b4e0087e5b8e292ead4bfab05b779 Mon Sep 17 00:00:00 2001 From: tison Date: Mon, 10 Aug 2026 12:11:23 +0800 Subject: [PATCH 7/7] perf: avoid parser rounding overhead --- bsize/Cargo.toml | 4 + bsize/benches/parse_with.rs | 69 +++++++++++ bsize/src/parse.rs | 230 +++++++++++++++++++++++++++++++----- 3 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 bsize/benches/parse_with.rs diff --git a/bsize/Cargo.toml b/bsize/Cargo.toml index 22a0a2a..bbb6fdc 100644 --- a/bsize/Cargo.toml +++ b/bsize/Cargo.toml @@ -53,5 +53,9 @@ toml = { version = "1.1.2" } harness = false name = "parse" +[[bench]] +harness = false +name = "parse_with" + [lints] workspace = true diff --git a/bsize/benches/parse_with.rs b/bsize/benches/parse_with.rs new file mode 100644 index 0000000..e460993 --- /dev/null +++ b/bsize/benches/parse_with.rs @@ -0,0 +1,69 @@ +// Copyright 2026 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt; + +use bsize::BSize64; +use bsize::ParseError; +use bsize::ParseOptions; +use bsize::RoundMode; + +#[derive(Clone, Copy)] +struct ParseWithCase { + name: &'static str, + mode: RoundMode, +} + +impl fmt::Display for ParseWithCase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name) + } +} + +const CASES: [ParseWithCase; 5] = [ + ParseWithCase { + name: "ceil", + mode: RoundMode::Ceil, + }, + ParseWithCase { + name: "floor", + mode: RoundMode::Floor, + }, + ParseWithCase { + name: "half-ceil", + mode: RoundMode::HalfCeil, + }, + ParseWithCase { + name: "half-floor", + mode: RoundMode::HalfFloor, + }, + ParseWithCase { + name: "half-even", + mode: RoundMode::HalfEven, + }, +]; + +const HIGH_PRECISION_BINARY: &str = + "0.0000000000000000004336808689942017736029811203479766845703125 EiB"; + +fn main() { + divan::main(); +} + +#[divan::bench(args = CASES, sample_size = 1024)] +fn parse_with(case: ParseWithCase) -> Result { + let mut options = ParseOptions::default(); + options.round_mode = divan::black_box(case.mode); + BSize64::parse_with(divan::black_box(HIGH_PRECISION_BINARY), options) +} diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs index 92e8b5b..9ea7fba 100644 --- a/bsize/src/parse.rs +++ b/bsize/src/parse.rs @@ -163,7 +163,8 @@ macroweave::repeat!(Ty in [u8, u16, u32, u64, usize] { type Err = ParseError; fn from_str(s: &str) -> Result { - Self::parse_with(s, ParseOptions::default()) + let size = parse_size(s.as_bytes(), HalfCeilRounding)?; + bsize_from_u64(size) } } }); @@ -177,7 +178,69 @@ where .map_err(|_| ParseError::Overflow) } -fn parse_size(mut src: &[u8], mode: RoundMode) -> Result { +// `FromStr` uses a zero-sized strategy so the default rounding mode does not occupy a register +// while the parser scans the integer. `parse_with` uses `RoundMode` as the runtime strategy. +trait RoundingStrategy: Copy { + fn tracks_later_digits(self) -> bool; + + fn should_round_up( + self, + whole_bytes: u64, + first_discarded_digit: u64, + has_nonzero_later_digits: bool, + ) -> bool; +} + +#[derive(Clone, Copy)] +struct HalfCeilRounding; + +// Decimal multipliers can absorb fractional digits directly. For shorter inputs, the dispatch +// costs more than it saves, so they stay on the general multiplication path. +const DECIMAL_FAST_PATH_MIN_LEN: usize = 20; + +impl RoundingStrategy for HalfCeilRounding { + #[inline] + fn tracks_later_digits(self) -> bool { + false + } + + #[inline] + fn should_round_up( + self, + _whole_bytes: u64, + first_discarded_digit: u64, + _has_nonzero_later_digits: bool, + ) -> bool { + first_discarded_digit >= 5 + } +} + +impl RoundingStrategy for RoundMode { + #[inline] + fn tracks_later_digits(self) -> bool { + matches!( + self, + RoundMode::Ceil | RoundMode::HalfFloor | RoundMode::HalfEven + ) + } + + #[inline] + fn should_round_up( + self, + whole_bytes: u64, + first_discarded_digit: u64, + has_nonzero_later_digits: bool, + ) -> bool { + should_round_up( + self, + whole_bytes, + first_discarded_digit, + has_nonzero_later_digits, + ) + } +} + +fn parse_size(mut src: &[u8], rounding: R) -> Result { // trim starting and trailing spaces while let [b' ', init @ ..] = src { src = init; @@ -234,24 +297,26 @@ fn parse_size(mut src: &[u8], mode: RoundMode) -> Result { let mut integer = 0u64; let mut saw_digit = false; - let mut fraction_start = None; + let mut fraction = None; + let mut number = src; - for (index, b) in src.iter().copied().enumerate() { - match b { + while let [b, rest @ ..] = number { + match *b { b'0'..=b'9' => { saw_digit = true; integer = integer .checked_mul(10) - .and_then(|v| v.checked_add(u64::from(b - b'0'))) + .and_then(|v| v.checked_add(u64::from(*b - b'0'))) .ok_or(ParseError::Overflow)?; } b'_' => {} b'.' if saw_digit => { - fraction_start = Some(index + 1); + fraction = Some(rest); break; } _ => return Err(ParseError::Malformed), } + number = rest; } if !saw_digit { @@ -260,32 +325,47 @@ fn parse_size(mut src: &[u8], mode: RoundMode) -> Result { let integer_bytes = integer.checked_mul(multiplier); - if let Some(start) = fraction_start { - // Multiply the fraction by the unit multiplier from right to left in base 10. - // Once all fractional digits are consumed, carry is the integral byte count and - // the last remainder digit is the first discarded decimal digit. A later nonzero digit - // determines whether a leading 5 is exactly half or greater than half. - debug_assert!(multiplier <= u64::MAX / 10); - let mut carry = 0u64; - let mut first_discarded_digit = 0u64; - let mut has_nonzero_later_digits = false; - for b in src[start..].iter().copied().rev() { - match b { - b'0'..=b'9' => { - let product = u64::from(b - b'0') * multiplier + carry; - has_nonzero_later_digits |= first_discarded_digit != 0; - first_discarded_digit = product % 10; - carry = product / 10; + if let Some(fraction) = fraction { + let track_later_digits = rounding.tracks_later_digits(); + let decimal_places = if fraction.len() >= DECIMAL_FAST_PATH_MIN_LEN { + decimal_places(multiplier) + } else { + None + }; + let (carry, first_discarded_digit, has_nonzero_later_digits) = if let Some(decimal_places) = + decimal_places + { + if track_later_digits { + scale_decimal_fraction::(fraction, decimal_places)? + } else { + scale_decimal_fraction::(fraction, decimal_places)? + } + } else if track_later_digits { + multiply_fraction_with_later_digits(fraction, multiplier)? + } else { + // These modes only need the first discarded digit. Keep this common path free from the + // additional dependency needed to distinguish exact ties in other modes. + debug_assert!(multiplier <= u64::MAX / 10); + let mut carry = 0u64; + let mut first_discarded_digit = 0u64; + for b in fraction.iter().copied().rev() { + match b { + b'0'..=b'9' => { + let product = u64::from(b - b'0') * multiplier + carry; + first_discarded_digit = product % 10; + carry = product / 10; + } + b'_' => {} + _ => return Err(ParseError::Malformed), } - b'_' => {} - _ => return Err(ParseError::Malformed), } - } + (carry, first_discarded_digit, false) + }; let mut bytes = integer_bytes.ok_or(ParseError::Overflow)?; bytes = bytes.checked_add(carry).ok_or(ParseError::Overflow)?; let round_up = - should_round_up(mode, bytes, first_discarded_digit, has_nonzero_later_digits); + rounding.should_round_up(bytes, first_discarded_digit, has_nonzero_later_digits); bytes = bytes .checked_add(u64::from(round_up)) .ok_or(ParseError::Overflow)?; @@ -296,6 +376,86 @@ fn parse_size(mut src: &[u8], mode: RoundMode) -> Result { integer_bytes.ok_or(ParseError::Overflow) } +#[inline] +fn decimal_places(multiplier: u64) -> Option { + match multiplier { + 1 => Some(0), + 1_000 => Some(3), + 1_000_000 => Some(6), + 1_000_000_000 => Some(9), + 1_000_000_000_000 => Some(12), + 1_000_000_000_000_000 => Some(15), + 1_000_000_000_000_000_000 => Some(18), + _ => None, + } +} + +fn scale_decimal_fraction( + src: &[u8], + decimal_places: usize, +) -> Result<(u64, u64, bool), ParseError> { + // Multiplication by 10^n moves the first n fractional digits directly into whole bytes. + let mut carry = 0u64; + let mut digit_index = 0usize; + let mut first_discarded_digit = 0u64; + let mut has_nonzero_later_digits = false; + + for b in src.iter().copied() { + match b { + b'0'..=b'9' => { + let digit = u64::from(b - b'0'); + if digit_index < decimal_places { + carry = carry * 10 + digit; + } else if digit_index == decimal_places { + first_discarded_digit = digit; + } else if TRACK_LATER_DIGITS { + has_nonzero_later_digits |= digit != 0; + } + digit_index += 1; + } + b'_' => {} + _ => return Err(ParseError::Malformed), + } + } + + for _ in digit_index..decimal_places { + carry *= 10; + } + + Ok((carry, first_discarded_digit, has_nonzero_later_digits)) +} + +#[inline(never)] +fn multiply_fraction_with_later_digits( + src: &[u8], + multiplier: u64, +) -> Result<(u64, u64, bool), ParseError> { + // Keep the extra loop-carried state needed by three modes out of the common path. + // Multiplication proceeds from right to left in base 10; after all digits are consumed, + // carry is the integral byte count and the last remainder digit is the first discarded + // decimal digit. + debug_assert!(multiplier <= u64::MAX / 10); + let mut carry = 0u64; + let mut first_discarded_digit = 0u64; + let mut later_digits = 0u64; + + for b in src.iter().copied().rev() { + match b { + b'0'..=b'9' => { + let product = u64::from(b - b'0') * multiplier + carry; + // The OR is nonzero exactly when any later discarded digit was nonzero. + later_digits |= first_discarded_digit; + first_discarded_digit = product % 10; + carry = product / 10; + } + b'_' => {} + _ => return Err(ParseError::Malformed), + } + } + + Ok((carry, first_discarded_digit, later_digits != 0)) +} + fn should_round_up( mode: RoundMode, whole_bytes: u64, @@ -336,6 +496,9 @@ mod tests { let expected = ByteSize::::b(expected); assert_eq!(actual, expected, "input: {input:?}"); + let configured = ByteSize::::parse_with(input, ParseOptions::default()).unwrap(); + assert_eq!(configured, actual, "input: {input:?}"); + let round_trip = actual.to_string().parse::>().unwrap(); assert_eq!(round_trip, expected, "input: {input:?}"); } @@ -343,6 +506,11 @@ mod tests { fn assert_parse_err(input: &str, expected: ParseError) { assert_eq!( input.parse::>(), + Err(expected.clone()), + "input: {input:?}", + ); + assert_eq!( + ByteSize::::parse_with(input, ParseOptions::default()), Err(expected), "input: {input:?}", ); @@ -513,6 +681,14 @@ mod tests { assert_rounds("2.5 B", RoundMode::HalfEven, 2); assert_rounds("3.5 B", RoundMode::HalfEven, 4); assert_rounds("2.5000000000000000001 B", RoundMode::HalfEven, 3); + + assert_rounds("1.00000000000000000001 B", RoundMode::Ceil, 2); + assert_rounds("1.99999999999999999999 B", RoundMode::Floor, 1); + assert_rounds("1.50000000000000000000 B", RoundMode::HalfCeil, 2); + assert_rounds("1.50000000000000000000 B", RoundMode::HalfFloor, 1); + assert_rounds("1.50000000000000000001 B", RoundMode::HalfFloor, 2); + assert_rounds("2.50000000000000000000 B", RoundMode::HalfEven, 2); + assert_rounds("2.50000000000000000001 B", RoundMode::HalfEven, 3); } #[test]