diff --git a/bsize/src/display.rs b/bsize/src/display.rs new file mode 100644 index 0000000..c05f6fb --- /dev/null +++ b/bsize/src/display.rs @@ -0,0 +1,135 @@ +// 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 core::fmt; + +use crate::BSize; + +/// Display wrapper for [`BSize`]. +/// +/// Supports various styles, see methods. By default, the [`binary`] style is used. +/// +/// [`binary`]: Display::binary +/// +/// # Examples +/// +/// ``` +/// # use bsize::BSize; +/// assert_eq!( +/// "1.0 MiB", +/// BSize::::mib(1).display().binary().to_string(), +/// ); +/// +/// assert_eq!( +/// "42.0 kB", +/// BSize::::kb(42).display().decimal().to_string(), +/// ); +/// ``` +#[derive(Debug, Clone)] +pub struct Display { + size: u64, + mode: DisplayMode, +} + +#[derive(Debug, Clone)] +enum DisplayMode { + Binary, + Decimal, +} + +impl Display { + /// Format using binary units (e.g., `11.8MiB`) + pub fn binary(mut self) -> Self { + self.mode = DisplayMode::Binary; + self + } + + /// Format using decimal units (e.g., `11.8MB`) + pub fn decimal(mut self) -> Self { + self.mode = DisplayMode::Decimal; + self + } + + fn new(size: u64) -> Self { + Self { + size, + mode: DisplayMode::Binary, + } + } +} + +impl fmt::Display for Display { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let bytes = self.size; + + let unit = match self.mode { + DisplayMode::Binary => 1024, + DisplayMode::Decimal => 1000, + }; + + let unit_prefixes = match self.mode { + DisplayMode::Binary => b"KMGTPE", + DisplayMode::Decimal => b"kMGTPE", + }; + let unit_suffix = match self.mode { + DisplayMode::Binary => "iB", + DisplayMode::Decimal => "B", + }; + let unit_separator = " "; + let precision = f.precision().unwrap_or(1); + + if bytes < unit { + write!(f, "{bytes}{unit_separator}B")?; + } else { + let size = bytes as f64; + + let mut ideal_prefix = 0usize; + let mut ideal_size = size; + loop { + ideal_prefix += 1; + ideal_size /= unit as f64; + + if ideal_size < unit as f64 { + break; + } + } + let exp = ideal_prefix; + + let unit_prefix = unit_prefixes[exp - 1] as char; + + write!( + f, + "{:.precision$}{unit_separator}{unit_prefix}{unit_suffix}", + size / unit.pow(exp as u32) as f64, + )?; + } + + Ok(()) + } +} + +macro_rules! impl_display { + ($($ty:ty),* $(,)?) => { + $( + impl BSize<$ty> { + /// Returns a display wrapper. + pub fn display(self) -> Display { + Display::new(self.0 as u64) + } + } + )* + }; +} + +impl_display!(u8, u16, u32, u64, usize); diff --git a/bsize/src/lib.rs b/bsize/src/lib.rs index 79df3e6..28b1c42 100644 --- a/bsize/src/lib.rs +++ b/bsize/src/lib.rs @@ -18,9 +18,13 @@ #![deny(missing_docs)] #![no_std] +mod display; mod ops; +mod parse; mod types; mod unsigned; +pub use self::display::Display; +pub use self::parse::ParseError; pub use self::types::BSize; pub use self::unsigned::Unsigned; diff --git a/bsize/src/ops.rs b/bsize/src/ops.rs index 71819fd..015f579 100644 --- a/bsize/src/ops.rs +++ b/bsize/src/ops.rs @@ -1,3 +1,17 @@ +// 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 core::ops; use crate::types::BSize; @@ -40,7 +54,7 @@ macro_rules! impl_ops { }; } -impl_ops!(u8, u16, u32, u64, u128, usize); +impl_ops!(u8, u16, u32, u64, usize); #[cfg(test)] mod tests { @@ -52,7 +66,6 @@ mod tests { assert_eq!((BSize::(3) + BSize(5)).0, 8); assert_eq!((BSize::(3) + BSize(5)).0, 8); assert_eq!((BSize::(3) + BSize(5)).0, 8); - assert_eq!((BSize::(3) + BSize(5)).0, 8); assert_eq!((BSize::(3) + BSize(5)).0, 8); } @@ -69,7 +82,6 @@ mod tests { assert_eq!((BSize::(8) - BSize(5)).0, 3); assert_eq!((BSize::(8) - BSize(5)).0, 3); assert_eq!((BSize::(8) - BSize(5)).0, 3); - assert_eq!((BSize::(8) - BSize(5)).0, 3); assert_eq!((BSize::(8) - BSize(5)).0, 3); } diff --git a/bsize/src/parse.rs b/bsize/src/parse.rs new file mode 100644 index 0000000..05e3a1c --- /dev/null +++ b/bsize/src/parse.rs @@ -0,0 +1,317 @@ +// 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 core::fmt; +use core::str::FromStr; + +use crate::BSize; + +/// The error returned when parsing a byte size fails. +#[derive(Debug, Clone, Eq, PartialEq)] +#[non_exhaustive] +pub enum ParseError { + /// The input contains no number. + Empty, + /// The input contains malformed bytes. + Malformed, + /// The parsed byte count is too large for the target integer type. + Overflow, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Empty => "cannot parse integer from empty string", + Self::Malformed => "malformed bytes found in string", + Self::Overflow => "number too large to fit in target type", + }) + } +} + +impl core::error::Error for ParseError {} + +macro_rules! impl_from_str { + ($($ty:ty),* $(,)?) => { + $( + impl FromStr for BSize<$ty> { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let size = parse_size(s.as_bytes())?; + if size <= <$ty>::MAX as u64 { + Ok(BSize(size as $ty)) + } else { + Err(ParseError::Overflow) + } + } + } + )* + }; +} + +impl_from_str!(u8, u16, u32, u64, usize); + +// This is derived from `parse-size` [1]. +// +// [1]: https://github.com/kennytm/parse-size/blob/8f2bc5a8/src/lib.rs#L364-L495 +fn parse_size(mut src: &[u8]) -> Result { + // trim starting and trailing spaces + while let [b' ', init @ ..] = src { + src = init; + } + while let [init @ .., b' '] = src { + src = init; + } + + // trim trailing 'b' or 'B' + if let [init @ .., b'b' | b'B'] = src { + src = init; + }; + + let mut multiply = 1u64; + if let [init @ .., b'i' | b'I'] = src { + src = init; + if let [init @ .., prefix] = src { + match prefix { + b'k' | b'K' => multiply = 1 << 10, + b'm' | b'M' => multiply = 1 << 20, + b'g' | b'G' => multiply = 1 << 30, + b't' | b'T' => multiply = 1 << 40, + b'p' | b'P' => multiply = 1 << 50, + b'e' | b'E' => multiply = 1 << 60, + _ => return Err(ParseError::Malformed), + } + + src = init; + } else { + // [iI][bB] is malformed suffix. + return Err(ParseError::Malformed); + } + } else { + if let [init @ .., prefix] = src { + 'skip: { + match prefix { + b'k' | b'K' => multiply = 1_000, + b'm' | b'M' => multiply = 1_000_000, + b'g' | b'G' => multiply = 1_000_000_000, + b't' | b'T' => multiply = 1_000_000_000_000, + b'p' | b'P' => multiply = 1_000_000_000_000_000, + b'e' | b'E' => multiply = 1_000_000_000_000_000_000, + _ => break 'skip, + } + src = init; + } + } + } + + // trim spaces between numeric part and unit part + while let [init @ .., b' '] = src { + src = init; + } + + macro_rules! append_digit { + ($before:expr, $method:ident, $digit_char:expr) => { + $before + .checked_mul(10) + .and_then(|v| v.$method(($digit_char - b'0').into())) + }; + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ParseState { + Empty, + Integer, + IntegerOverflow, + Fraction, + FractionOverflow, + } + + let mut mantissa = 0u64; + let mut exponent = 0i32; + let mut state = ParseState::Empty; + + for b in src { + match (state, *b) { + (ParseState::Integer | ParseState::Empty, b'0'..=b'9') => { + if let Some(m) = append_digit!(mantissa, checked_add, *b) { + mantissa = m; + state = ParseState::Integer; + } else { + if *b >= b'5' { + mantissa += 1; + } + state = ParseState::IntegerOverflow; + exponent += 1; + } + } + (ParseState::IntegerOverflow, b'0'..=b'9') => { + exponent += 1; + } + (ParseState::Fraction, b'0'..=b'9') => { + if let Some(m) = append_digit!(mantissa, checked_add, *b) { + mantissa = m; + exponent -= 1; + } else { + if *b >= b'5' { + mantissa += 1; + } + state = ParseState::FractionOverflow; + } + } + (_, b'_') => {} + (ParseState::Integer, b'.') => state = ParseState::Fraction, + (ParseState::IntegerOverflow, b'.') => state = ParseState::FractionOverflow, + _ => return Err(ParseError::Malformed), + } + } + + if matches!(state, ParseState::Empty) { + return Err(ParseError::Empty); + } + + let abs_exponent = exponent.unsigned_abs(); + if exponent >= 0 { + let power = 10_u64 + .checked_pow(abs_exponent) + .ok_or(ParseError::Overflow)?; + let multiply = multiply.checked_mul(power).ok_or(ParseError::Overflow)?; + mantissa.checked_mul(multiply).ok_or(ParseError::Overflow) + } else if exponent >= -38 { + let power = 10_u128.pow(abs_exponent); + let result = (u128::from(mantissa) * u128::from(multiply) + power / 2) / power; + u64::try_from(result).map_err(|_| ParseError::Overflow) + } else { + // (2^128) * 1e-39 < 1, always, and thus saturate to 0. + Ok(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_parse_ok(input: &str, expected: u64) { + assert_eq!( + input.parse::>(), + Ok(BSize::b(expected)), + "input: {input:?}", + ); + } + + fn assert_parse_err(input: &str, expected: ParseError) { + assert_eq!( + input.parse::>(), + Err(expected), + "input: {input:?}", + ); + } + + #[test] + fn test_parse_ok() { + for (input, expected) in [ + ("0", 0), + ("3", 3), + ("30", 30), + ("32", 32), + ("500", 500), + ("_5_", 5), + ("1_234_567", 1_234_567), + (" 42 ", 42), + ("1B", 1), + ("1 b", 1), + ("1kB", 1_000), + ("1K", 1_000), + ("1KB", 1_000), + ("2MB", 2_000_000), + ("3GB", 3_000_000_000), + ("4TB", 4_000_000_000_000), + ("5PB", 5_000_000_000_000_000), + ("6EB", 6_000_000_000_000_000_000), + ("8P", 8_000_000_000_000_000), + ("1Ki", 1 << 10), + ("1KiB", 1 << 10), + ("1.5Ki", 1_536), + ("1.5KiB", 1_536), + ("7 KiB", 7 << 10), + ("8 MiB", 8 << 20), + ("9 GiB", 9 << 30), + ("10 TiB", 10 << 40), + ("11 PiB", 11 << 50), + ("12 EiB", 12 << 60), + (" 7 KiB ", 7 << 10), + ("1mib", 1_048_576), + ("1.1 K", 1_100), + ("1.2345 K", 1_235), + ("1.2345m", 1_234_500), + ("5.k", 5_000), + ("0.0024KB", 2), + ("0.0025KB", 3), + ("0.4B", 0), + ("0.5B", 1), + ("18_446_744_073_709_551_581", 18_446_744_073_709_551_581), + ("18_446_744_073_709_551_615", u64::MAX), + ("18.446_744_073_709_551_615 EB", u64::MAX), + ("1.000_000_000_000_000_001 EB", 1_000_000_000_000_000_001), + ] { + assert_parse_ok(input, expected); + } + } + + #[test] + fn test_parse_err() { + for input in ["", " ", " ", "__", "k", "kb", "KiB"] { + assert_parse_err(input, ParseError::Empty); + } + + for input in [ + ".", + ".5k", + "a", + "a124GB", + "-1", + "1,5", + "1 234 567", + "1 000 B", + "1.3 42.0 B", + "1.3 ... B", + "IB", + "iB", + "1iB", + "1 ZiB", + "1 YiB", + "1e2 KIB", + "1E+6", + "0.1234567890123456789012", + "\t1", + "1\tKB", + ] { + assert_parse_err(input, ParseError::Malformed); + } + + for input in [ + "18_446_744_073_709_551_616", + "18_446_744_073_709_551_620", + "18.446_744_073_709_551_616 EB", + "19EB", + "16EiB", + "100000000000000000000", + ] { + assert_parse_err(input, ParseError::Overflow); + } + + assert_eq!("256".parse::>(), Err(ParseError::Overflow)); + assert_eq!("64 KiB".parse::>(), Err(ParseError::Overflow)); + assert_eq!("4GiB".parse::>(), Err(ParseError::Overflow)); + } +} diff --git a/bsize/src/types.rs b/bsize/src/types.rs index a406c49..1795194 100644 --- a/bsize/src/types.rs +++ b/bsize/src/types.rs @@ -1,7 +1,22 @@ -use crate::unsigned::Unsigned; +// 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 core::any::type_name; use core::fmt; +use crate::unsigned::Unsigned; + /// Byte size representation. #[derive(Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct BSize(pub T); @@ -78,21 +93,6 @@ impl_constructors!(u64 => { eib = 1_152_921_504_606_846_976, }); -impl_constructors!(u128 => { - kb = 1_000, - kib = 1_024, - mb = 1_000_000, - mib = 1_048_576, - gb = 1_000_000_000, - gib = 1_073_741_824, - tb = 1_000_000_000_000, - tib = 1_099_511_627_776, - pb = 1_000_000_000_000_000, - pib = 1_125_899_906_842_624, - eb = 1_000_000_000_000_000_000, - eib = 1_152_921_504_606_846_976, -}); - impl_constructors!(usize => { kb = 1_000, kib = 1_024, @@ -116,17 +116,95 @@ impl_constructors!(usize => { eib = 1_152_921_504_606_846_976, }); +macro_rules! impl_accessors { + ($ty:ty => { $($name:ident = $size:literal => $unit:literal),* $(,)? }) => { + impl BSize<$ty> { + $( + #[doc = concat!("Returns byte count as ", $unit, ".")] + /// + /// The result is approximate when the byte count cannot be + /// represented exactly as `f64`. + #[inline(always)] + pub fn $name(&self) -> f64 { + (self.0 as f64) / ($size as f64) + } + )* + } + }; +} + +impl_accessors!(u16 => { + as_kb = 1_000u16 => "kilobytes", + as_kib = 1_024u16 => "kibibytes", +}); + +impl_accessors!(u32 => { + as_kb = 1_000u32 => "kilobytes", + as_kib = 1_024u32 => "kibibytes", + as_mb = 1_000_000u32 => "megabytes", + as_mib = 1_048_576u32 => "mebibytes", + as_gb = 1_000_000_000u32 => "gigabytes", + as_gib = 1_073_741_824u32 => "gibibytes", +}); + +impl_accessors!(u64 => { + as_kb = 1_000u64 => "kilobytes", + as_kib = 1_024u64 => "kibibytes", + as_mb = 1_000_000u64 => "megabytes", + as_mib = 1_048_576u64 => "mebibytes", + as_gb = 1_000_000_000u64 => "gigabytes", + as_gib = 1_073_741_824u64 => "gibibytes", + as_tb = 1_000_000_000_000u64 => "terabytes", + as_tib = 1_099_511_627_776u64 => "tebibytes", + as_pb = 1_000_000_000_000_000u64 => "petabytes", + as_pib = 1_125_899_906_842_624u64 => "pebibytes", + as_eb = 1_000_000_000_000_000_000u64 => "exabytes", + as_eib = 1_152_921_504_606_846_976u64 => "exbibytes", +}); + +impl_accessors!(usize => { + as_kb = 1_000usize => "kilobytes", + as_kib = 1_024usize => "kibibytes", +}); + +#[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] +impl_accessors!(usize => { + as_mb = 1_000_000usize => "megabytes", + as_mib = 1_048_576usize => "mebibytes", + as_gb = 1_000_000_000usize => "gigabytes", + as_gib = 1_073_741_824usize => "gibibytes", +}); + +#[cfg(target_pointer_width = "64")] +impl_accessors!(usize => { + as_tb = 1_000_000_000_000usize => "terabytes", + as_tib = 1_099_511_627_776usize => "tebibytes", + as_pb = 1_000_000_000_000_000usize => "petabytes", + as_pib = 1_125_899_906_842_624usize => "pebibytes", + as_eb = 1_000_000_000_000_000_000usize => "exabytes", + as_eib = 1_152_921_504_606_846_976usize => "exbibytes", +}); + #[cfg(test)] mod tests { use super::BSize; + fn assert_close(actual: f64, expected: f64) { + let delta = (actual - expected).abs(); + let tolerance = f64::EPSILON; + + assert!( + delta <= tolerance, + "actual: {actual}, expected: {expected}, delta: {delta}, tolerance: {tolerance}", + ); + } + #[test] fn defaults() { assert_eq!(BSize::::default(), BSize::b(0)); assert_eq!(BSize::::default(), BSize::b(0)); assert_eq!(BSize::::default(), BSize::b(0)); assert_eq!(BSize::::default(), BSize::b(0)); - assert_eq!(BSize::::default(), BSize::b(0)); assert_eq!(BSize::::default(), BSize::b(0)); } @@ -141,12 +219,50 @@ mod tests { assert_eq!(BSize::::kib(2).0, 2_048); } + #[test] + fn returns_u16_units() { + assert_close(BSize::::kb(2).as_kb(), 2.0); + assert_close(BSize::::kib(2).as_kib(), 2.0); + } + + #[test] + fn returns_fractional_u16_units() { + let bytes = u16::MAX; + let kb = 1_000u16; + let kib = 1_024u16; + + assert_close(BSize::::b(bytes).as_kb(), (bytes as f64) / (kb as f64)); + assert_close( + BSize::::b(bytes).as_kib(), + (bytes as f64) / (kib as f64), + ); + } + #[test] fn constructs_u32_units() { assert_eq!(BSize::::gb(2).0, 2_000_000_000); assert_eq!(BSize::::gib(2).0, 2_147_483_648); } + #[test] + fn returns_u32_units() { + assert_close(BSize::::gb(2).as_gb(), 2.0); + assert_close(BSize::::gib(2).as_gib(), 2.0); + } + + #[test] + fn returns_fractional_u32_units() { + let bytes = u32::MAX; + let gb = 1_000_000_000u32; + let gib = 1_073_741_824u32; + + assert_close(BSize::::b(bytes).as_gb(), (bytes as f64) / (gb as f64)); + assert_close( + BSize::::b(bytes).as_gib(), + (bytes as f64) / (gib as f64), + ); + } + #[test] fn constructs_u64_units() { assert_eq!(BSize::::eb(2).0, 2_000_000_000_000_000_000); @@ -154,9 +270,21 @@ mod tests { } #[test] - fn constructs_u128_units() { - assert_eq!(BSize::::eb(20).0, 20_000_000_000_000_000_000); - assert_eq!(BSize::::eib(20).0, 23_058_430_092_136_939_520); + fn returns_u64_units() { + assert_close(BSize::::eib(2).as_eib(), 2.0); + } + + #[test] + fn returns_large_fractional_u64_units() { + let bytes = 9_876_543_210_987_654_321_u64; + let eb = 1_000_000_000_000_000_000u64; + let eib = 1_152_921_504_606_846_976u64; + + assert_close(BSize::::b(bytes).as_eb(), (bytes as f64) / (eb as f64)); + assert_close( + BSize::::b(bytes).as_eib(), + (bytes as f64) / (eib as f64), + ); } #[test] @@ -165,6 +293,12 @@ mod tests { assert_eq!(BSize::::kib(2).0, 2_048); } + #[test] + fn returns_usize_units() { + assert_close(BSize::::kb(2).as_kb(), 2.0); + assert_close(BSize::::kib(2).as_kib(), 2.0); + } + #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] #[test] fn constructs_32_bit_usize_units() { @@ -172,10 +306,23 @@ mod tests { assert_eq!(BSize::::gib(2).0, 2_147_483_648); } + #[cfg(any(target_pointer_width = "32", target_pointer_width = "64"))] + #[test] + fn returns_32_bit_usize_units() { + assert_close(BSize::::gb(2).as_gb(), 2.0); + assert_close(BSize::::gib(2).as_gib(), 2.0); + } + #[cfg(target_pointer_width = "64")] #[test] fn constructs_64_bit_usize_units() { assert_eq!(BSize::::eb(2).0, 2_000_000_000_000_000_000); assert_eq!(BSize::::eib(2).0, 2_305_843_009_213_693_952); } + + #[cfg(target_pointer_width = "64")] + #[test] + fn returns_64_bit_usize_units() { + assert_close(BSize::::eib(2).as_eib(), 2.0); + } } diff --git a/bsize/src/unsigned.rs b/bsize/src/unsigned.rs index 4d73fa9..28c8869 100644 --- a/bsize/src/unsigned.rs +++ b/bsize/src/unsigned.rs @@ -1,3 +1,17 @@ +// 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. + mod private { pub trait Sealed {} @@ -6,7 +20,6 @@ mod private { impl Sealed for u16 {} impl Sealed for u32 {} impl Sealed for u64 {} - impl Sealed for u128 {} } /// A marker trait for all unsigned integers. @@ -17,4 +30,3 @@ impl Unsigned for u8 {} impl Unsigned for u16 {} impl Unsigned for u32 {} impl Unsigned for u64 {} -impl Unsigned for u128 {}