Skip to main content

prettier_bytes/
lib.rs

1#![doc = include_str!("../README.md")]
2#![no_std]
3#![deny(clippy::pedantic)]
4#![deny(clippy::nursery)]
5#![forbid(clippy::indexing_slicing)]
6#![forbid(clippy::panic)]
7#![forbid(clippy::unwrap_used)]
8#![forbid(clippy::expect_used)]
9#![forbid(clippy::unreachable)]
10#![forbid(clippy::todo)]
11#![forbid(clippy::unimplemented)]
12#![forbid(clippy::alloc_instead_of_core)]
13#![forbid(clippy::float_arithmetic)]
14#![forbid(clippy::cast_possible_wrap)]
15#![forbid(clippy::cast_possible_truncation)]
16#![forbid(unsafe_code)]
17
18use core::fmt;
19
20/// A configurable formatter for byte sizes.
21///
22/// # Examples
23///
24/// ```
25/// use prettier_bytes::{ByteFormatter, Standard};
26///
27/// let fmt = ByteFormatter::new().standard(Standard::SI);
28/// assert_eq!(fmt.format(2500).as_str().unwrap(), "2.50 kB");
29/// ```
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct ByteFormatter {
32    unit: Unit,
33    standard: Standard,
34    space: bool,
35}
36
37impl Default for ByteFormatter {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl ByteFormatter {
44    /// Creates a new `ByteFormatter` with sensible defaults:
45    /// Binary standard (Base 1024), Bytes, and a space between the number and unit.
46    #[must_use]
47    pub const fn new() -> Self {
48        Self {
49            unit: Unit::Bytes,
50            standard: Standard::Binary,
51            space: true,
52        }
53    }
54
55    /// Sets the formatting standard (e.g., SI vs Binary).
56    #[must_use]
57    pub const fn standard(mut self, standard: Standard) -> Self {
58        self.standard = standard;
59        self
60    }
61
62    /// Sets the base unit (e.g., Bytes vs Bits).
63    #[must_use]
64    pub const fn unit(mut self, unit: Unit) -> Self {
65        self.unit = unit;
66        self
67    }
68
69    /// Determines whether a space is placed between the number and the unit.
70    ///
71    /// Defaults to `true` (e.g., `"1.50 MB"`). If set to `false`, the space is omitted
72    /// (e.g., `"1.50MB"`).
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use prettier_bytes::ByteFormatter;
78    ///
79    /// let fmt_spaced = ByteFormatter::new().space(true);
80    /// assert_eq!(fmt_spaced.format(1024).as_str().unwrap(), "1.00 KiB");
81    ///
82    /// let fmt_compact = ByteFormatter::new().space(false);
83    /// assert_eq!(fmt_compact.format(1024).as_str().unwrap(), "1.00KiB");
84    /// ```
85    #[must_use]
86    pub const fn space(mut self, space: bool) -> Self {
87        self.space = space;
88        self
89    }
90
91    /// Formats the given value and returns a stack-allocated buffer.
92    #[must_use]
93    pub fn format(self, val: u64) -> FormattedBytes {
94        FormattedBytes::from_formatter(val, self.unit, self.standard, self.space)
95    }
96}
97
98/// Represents the base unit to format against.
99#[derive(Debug, Copy, Clone, PartialEq, Eq)]
100pub enum Unit {
101    /// Formats with a trailing 'B' (e.g., MB, GiB).
102    Bytes,
103    /// Formats with a trailing 'b' (e.g., Mb, Mib, Gib).
104    Bits,
105}
106
107/// Represents the mathematical standard for calculating magnitudes.
108#[derive(Debug, Copy, Clone, PartialEq, Eq)]
109pub enum Standard {
110    /// Base 1000 (SI standard). Produces kB, MB, GB, etc.
111    SI,
112    /// Base 1024 (IEC/Binary standard). Produces KiB, MiB, GiB, etc.
113    Binary,
114}
115
116/// A stack-allocated buffer containing the formatted byte string.
117///
118/// `FormattedBytes` calculates the formatting upon instantiation and stores
119/// the result internally. It can be cheaply converted to a `&str` or `&[u8]`.
120#[derive(Clone, Copy)]
121pub struct FormattedBytes {
122    buf: [u8; 16],
123    len: usize,
124}
125
126impl FormattedBytes {
127    pub(crate) fn from_formatter(val: u64, unit: Unit, standard: Standard, space: bool) -> Self {
128        // Determine the magnitude (0=Base, 1=Kilo, 2=Mega, etc.)
129        let mag = if val == 0 {
130            0
131        } else {
132            match standard {
133                Standard::SI => (val.ilog10() / 3) as usize,
134                Standard::Binary => (val.ilog2() / 10) as usize,
135            }
136        };
137
138        // Cap magnitude at 6 (Exabytes), the physical limit of a u64.
139        let mag = mag.min(6);
140
141        // We calculate both the whole number AND the fraction simultaneously
142        // such that LLVM never emits a dynamic CPU `div` instruction.
143        let (mut whole, mut frac) = if mag == 0 {
144            (val, 0)
145        } else {
146            match standard {
147                Standard::Binary => {
148                    // Base 1024: Pure bitwise operations (1 clock cycle)
149                    let shift = mag * 10;
150                    let divisor = 1_u64 << shift;
151                    let whole = val >> shift;
152                    let rem = val & (divisor - 1);
153
154                    // To prevent u64 overflow on Exbibytes (mag 6), we shift down by 7.
155                    let (scaled_rem, final_shift) = if mag == 6 {
156                        (rem >> 7, shift - 7)
157                    } else {
158                        (rem, shift)
159                    };
160
161                    // Rounding: Add half of the divisor before shifting to round to nearest integer
162                    let rounder = 1_u64 << (final_shift - 1);
163                    let f = ((scaled_rem * 100) + rounder) >> final_shift;
164
165                    (whole, f)
166                }
167                Standard::SI => {
168                    // Base 1000: We use a local macro to force LLVM to calculate the
169                    // fraction while the divisor is a hardcoded literal
170                    // This triggers reciprocal multiplication, bypassing CPU division
171                    macro_rules! calc_si {
172                        ($div:expr) => {{
173                            let w = val / $div;
174                            let r = val % $div;
175                            let (sr, sd) = if mag == 6 {
176                                (r >> 7, $div >> 7)
177                            } else {
178                                (r, $div)
179                            };
180                            let f = (sr * 100 + (sd / 2)) / sd;
181                            (w, f)
182                        }};
183                    }
184
185                    match mag {
186                        1 => calc_si!(1_000_u64),
187                        2 => calc_si!(1_000_000_u64),
188                        3 => calc_si!(1_000_000_000_u64),
189                        4 => calc_si!(1_000_000_000_000_u64),
190                        5 => calc_si!(1_000_000_000_000_000_u64),
191                        _ => calc_si!(1_000_000_000_000_000_000_u64),
192                    }
193                }
194            }
195        };
196
197        // Carry over if rounding pushed the fraction to 100
198        if frac >= 100 {
199            frac = 0;
200            whole += 1;
201        }
202
203        // String assembly
204        let mut buf = [0u8; 16];
205        let mut iter = buf.iter_mut();
206
207        // Safe iterator pushing
208        let mut push = |b: u8| {
209            if let Some(slot) = iter.next() {
210                *slot = b;
211            }
212        };
213
214        let (num_buf, num_len) = format_small_num(whole);
215
216        // Push whole number
217        for b in num_buf.into_iter().take(num_len) {
218            push(b);
219        }
220
221        // Push decimals
222        if mag != 0 {
223            push(b'.');
224            push(b'0' + u8::try_from(frac / 10).unwrap_or(0));
225            push(b'0' + u8::try_from(frac % 10).unwrap_or(0));
226        }
227
228        // Push space
229        if space {
230            push(b' ');
231        }
232
233        // Push prefixes
234        if mag != 0 {
235            let prefix = match (mag, standard) {
236                (1, Standard::SI) => b'k',
237                (1, Standard::Binary) => b'K',
238                (2, _) => b'M',
239                (3, _) => b'G',
240                (4, _) => b'T',
241                (5, _) => b'P',
242                _ => b'E',
243            };
244            push(prefix);
245
246            if standard == Standard::Binary {
247                push(b'i');
248            }
249        }
250
251        // Push unit
252        push(match unit {
253            Unit::Bytes => b'B',
254            Unit::Bits => b'b',
255        });
256
257        // Calculate final length mathematically
258        let len = 16 - iter.len();
259
260        Self { buf, len }
261    }
262
263    /// Returns the formatted output as a raw byte slice.
264    /// Useful for direct `std::io::Write` usage.
265    #[inline]
266    #[must_use]
267    pub fn as_bytes(&self) -> &[u8] {
268        self.buf.get(..self.len).unwrap_or(&self.buf)
269    }
270
271    /// Returns the formatted output as a UTF-8 string slice.
272    ///
273    /// # Errors
274    ///
275    /// Returns a `core::str::Utf8Error` if the internal buffer contains
276    /// invalid UTF-8 bytes. (Note: The format method only writes valid
277    /// ASCII characters, so this is guaranteed to succeed in practice).
278    #[inline]
279    pub fn as_str(&self) -> Result<&str, core::str::Utf8Error> {
280        let bytes = self.buf.get(..self.len).unwrap_or(&self.buf);
281        core::str::from_utf8(bytes)
282    }
283}
284
285/// Allows `FormattedBytes` to be used in standard Rust formatting macros
286/// (e.g., `format!()`, `println!()`, `write!()`).
287impl fmt::Display for FormattedBytes {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.write_str(self.as_str().map_err(|_| fmt::Error)?)
290    }
291}
292
293/// Inlines formatting for numbers strictly < 1024.
294///
295/// Avoids the heavy loop/modulo cost of general `itoa` routines.
296#[inline]
297fn format_small_num(n: u64) -> ([u8; 4], usize) {
298    if n < 10 {
299        ([b'0' + u8::try_from(n).unwrap_or(0), 0, 0, 0], 1)
300    } else if n < 100 {
301        (
302            [
303                b'0' + u8::try_from(n / 10).unwrap_or(0),
304                b'0' + u8::try_from(n % 10).unwrap_or(0),
305                0,
306                0,
307            ],
308            2,
309        )
310    } else if n < 1000 {
311        (
312            [
313                b'0' + u8::try_from(n / 100).unwrap_or(0),
314                b'0' + u8::try_from((n / 10) % 10).unwrap_or(0),
315                b'0' + u8::try_from(n % 10).unwrap_or(0),
316                0,
317            ],
318            3,
319        )
320    } else {
321        (
322            [
323                b'0' + u8::try_from(n / 1000).unwrap_or(0),
324                b'0' + u8::try_from((n / 100) % 10).unwrap_or(0),
325                b'0' + u8::try_from((n / 10) % 10).unwrap_or(0),
326                b'0' + u8::try_from(n % 10).unwrap_or(0),
327            ],
328            4,
329        )
330    }
331}
332
333/// Allow `FormattedBytes` to be logged seamlessly in resource-constrained
334/// embedded environments using the `defmt` framework.
335#[cfg(feature = "defmt")]
336impl defmt::Format for FormattedBytes {
337    fn format(&self, fmt: defmt::Formatter) {
338        // We use `.as_str()` because we want to log the human-readable text.
339        // The "{=str}" syntax is specific to `defmt` for safely transmitting string slices.
340        if let Ok(text) = self.as_str() {
341            defmt::write!(fmt, "{=str}", text);
342        } else {
343            // Fallback in case of a highly improbable UTF-8 failure,
344            // ensuring we never panic the embedded device.
345            defmt::write!(fmt, "<prettier-bytes: invalid utf-8>");
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    extern crate alloc;
353
354    use alloc::format;
355
356    use super::*;
357
358    macro_rules! assert_fmt {
359        ($val:expr, $unit:path, $std:path, $space:expr, $expected:expr) => {
360            let fmt = ByteFormatter::new()
361                .unit($unit)
362                .standard($std)
363                .space($space)
364                .format($val);
365            assert_eq!(fmt.as_str().unwrap(), $expected);
366        };
367    }
368
369    #[test]
370    fn test_zero() {
371        assert_fmt!(0, Unit::Bytes, Standard::SI, true, "0 B");
372        assert_fmt!(0, Unit::Bits, Standard::SI, true, "0 b");
373        assert_fmt!(0, Unit::Bytes, Standard::Binary, false, "0B");
374        assert_fmt!(0, Unit::Bits, Standard::Binary, false, "0b");
375    }
376
377    #[test]
378    fn test_base_units_under_1000() {
379        // Values < 1000 should format as raw bytes with no decimals in either standard.
380        assert_fmt!(1, Unit::Bytes, Standard::SI, true, "1 B");
381        assert_fmt!(12, Unit::Bytes, Standard::Binary, true, "12 B");
382        assert_fmt!(345, Unit::Bytes, Standard::SI, false, "345B");
383        assert_fmt!(999, Unit::Bytes, Standard::SI, true, "999 B");
384        assert_fmt!(999, Unit::Bytes, Standard::Binary, true, "999 B");
385    }
386
387    #[test]
388    fn test_si_exact_magnitudes() {
389        // Tests exactly hitting the boundary of SI prefixes (Base 1000)
390        assert_fmt!(1_000, Unit::Bytes, Standard::SI, true, "1.00 kB");
391        assert_fmt!(1_000_000, Unit::Bytes, Standard::SI, true, "1.00 MB");
392        assert_fmt!(1_000_000_000, Unit::Bytes, Standard::SI, true, "1.00 GB");
393        assert_fmt!(
394            1_000_000_000_000,
395            Unit::Bytes,
396            Standard::SI,
397            true,
398            "1.00 TB"
399        );
400        assert_fmt!(
401            1_000_000_000_000_000,
402            Unit::Bytes,
403            Standard::SI,
404            true,
405            "1.00 PB"
406        );
407        assert_fmt!(
408            1_000_000_000_000_000_000,
409            Unit::Bytes,
410            Standard::SI,
411            true,
412            "1.00 EB"
413        );
414    }
415
416    #[test]
417    fn test_binary_exact_magnitudes() {
418        // Tests exactly hitting the boundary of Binary prefixes (Base 1024)
419        assert_fmt!(1_024, Unit::Bytes, Standard::Binary, true, "1.00 KiB");
420        assert_fmt!(1_048_576, Unit::Bytes, Standard::Binary, true, "1.00 MiB");
421        assert_fmt!(
422            1_073_741_824,
423            Unit::Bytes,
424            Standard::Binary,
425            true,
426            "1.00 GiB"
427        );
428        assert_fmt!(
429            1_099_511_627_776,
430            Unit::Bytes,
431            Standard::Binary,
432            true,
433            "1.00 TiB"
434        );
435        assert_fmt!(
436            1_125_899_906_842_624,
437            Unit::Bytes,
438            Standard::Binary,
439            true,
440            "1.00 PiB"
441        );
442        assert_fmt!(
443            1_152_921_504_606_846_976,
444            Unit::Bytes,
445            Standard::Binary,
446            true,
447            "1.00 EiB"
448        );
449    }
450
451    #[test]
452    fn test_si_vs_binary_difference() {
453        // 1,000 bytes is 1.00 kB in SI, but still 1000 B in Binary (since it's < 1024)
454        assert_fmt!(1_000, Unit::Bytes, Standard::SI, true, "1.00 kB");
455        assert_fmt!(1_000, Unit::Bytes, Standard::Binary, true, "1000 B");
456
457        // 1,023 bytes
458        assert_fmt!(1_023, Unit::Bytes, Standard::SI, true, "1.02 kB");
459        assert_fmt!(1_023, Unit::Bytes, Standard::Binary, true, "1023 B");
460    }
461
462    #[test]
463    fn test_rounding_and_decimals() {
464        // 1,500 bytes = 1.50 kB
465        assert_fmt!(1_500, Unit::Bytes, Standard::SI, true, "1.50 kB");
466
467        // 1,536 bytes = 1.50 KiB (1024 + 512)
468        assert_fmt!(1_536, Unit::Bytes, Standard::Binary, true, "1.50 KiB");
469
470        // Rounding down: 1,004 bytes -> 1.00 kB
471        assert_fmt!(1_004, Unit::Bytes, Standard::SI, true, "1.00 kB");
472
473        // Rounding up: 1,005 bytes -> 1.01 kB
474        assert_fmt!(1_005, Unit::Bytes, Standard::SI, true, "1.01 kB");
475
476        // 1.23 MB
477        assert_fmt!(1_230_000, Unit::Bytes, Standard::SI, true, "1.23 MB");
478    }
479
480    #[test]
481    fn test_carry_over_rounding() {
482        // If a value is right on the edge of the next decimal, it should round the whole number up.
483        // 999,999 bytes in SI is 999.999 kB. Rounding to 2 digits makes it 1000.00 kB.
484        assert_fmt!(999_999, Unit::Bytes, Standard::SI, true, "1000.00 kB");
485
486        // Binary equivalent: 1,048,575 bytes is 1 byte shy of 1 MiB.
487        // In KiB, it's 1023.999... which rounds up to 1024.00 KiB.
488        assert_fmt!(
489            1_048_575,
490            Unit::Bytes,
491            Standard::Binary,
492            true,
493            "1024.00 KiB"
494        );
495    }
496
497    #[test]
498    fn test_formatting_variations() {
499        let val = 2_500_000;
500
501        // Bytes vs Bits
502        assert_fmt!(val, Unit::Bytes, Standard::SI, true, "2.50 MB");
503        assert_fmt!(val, Unit::Bits, Standard::SI, true, "2.50 Mb");
504
505        // SI vs Binary
506        assert_fmt!(val, Unit::Bytes, Standard::SI, true, "2.50 MB");
507        assert_fmt!(val, Unit::Bytes, Standard::Binary, true, "2.38 MiB");
508
509        // Spacing vs No Spacing
510        assert_fmt!(val, Unit::Bytes, Standard::SI, true, "2.50 MB");
511        assert_fmt!(val, Unit::Bytes, Standard::SI, false, "2.50MB");
512    }
513
514    #[test]
515    fn test_extreme_values() {
516        // u64::MAX is 18_446_744_073_709_551_615
517
518        // In SI (Base 1000): ~18.45 Exabytes
519        assert_fmt!(u64::MAX, Unit::Bytes, Standard::SI, true, "18.45 EB");
520
521        // In Binary (Base 1024):
522        // u64::MAX is just 1 byte shy of 16 Exbibytes (16.00 EiB after rounding)
523        assert_fmt!(u64::MAX, Unit::Bytes, Standard::Binary, true, "16.00 EiB");
524    }
525
526    #[test]
527    fn test_as_bytes() {
528        // Ensure the raw byte slice matches the string representation exactly
529        let fmt = ByteFormatter::new()
530            .unit(Unit::Bytes)
531            .standard(Standard::SI)
532            .space(false)
533            .format(1500);
534        assert_eq!(fmt.as_bytes(), b"1.50kB");
535    }
536
537    #[test]
538    fn test_number_boundaries() {
539        // Tests the transition points inside the `format_small_num` logic
540        assert_fmt!(9, Unit::Bytes, Standard::SI, true, "9 B"); // 1 digit max
541        assert_fmt!(10, Unit::Bytes, Standard::SI, true, "10 B"); // 2 digits min
542        assert_fmt!(99, Unit::Bytes, Standard::SI, true, "99 B"); // 2 digits max
543        assert_fmt!(100, Unit::Bytes, Standard::SI, true, "100 B"); // 3 digits min
544        assert_fmt!(999, Unit::Bytes, Standard::SI, true, "999 B"); // 3 digits max
545    }
546
547    #[test]
548    fn test_display_trait() {
549        let fmt = ByteFormatter::new()
550            .unit(Unit::Bytes)
551            .standard(Standard::Binary)
552            .space(true)
553            .format(1_048_576);
554
555        // This implicitly calls the `Display` trait implementation!
556        let output = format!("{fmt}");
557
558        assert_eq!(output, "1.00 MiB");
559    }
560}