Skip to main content

stuffr_cli/
size.rs

1//! Parsing for `--memory-limit`.
2//!
3//! Strict on purpose: a malformed size is a usage error naming the accepted
4//! forms, never a silent fall back to the default. A user who typed `512MB`
5//! and got 25% of RAM instead would have no way to notice.
6
7/// Parses `512`, `512K`, `512M`, `2G` (and lowercase) into bytes.
8///
9/// Binary multipliers (1024-based), matching what `--max-ratio`'s siblings and
10/// every compression tool in this space mean by `M`.
11pub fn parse_size(s: &str) -> Result<u64, String> {
12    let t = s.trim();
13    if t.is_empty() {
14        // Shows the same concrete forms every other error here shows. A user
15        // who passed an empty value needs the guidance at least as much as one
16        // who mistyped a suffix, and a test now requires every syntax error to
17        // carry it.
18        return Err("empty size; expected e.g. 512M, 2G, or a byte count".into());
19    }
20    let (digits, mult) = match t.as_bytes()[t.len() - 1] {
21        b'K' | b'k' => (&t[..t.len() - 1], 1024u64),
22        b'M' | b'm' => (&t[..t.len() - 1], 1024 * 1024),
23        b'G' | b'g' => (&t[..t.len() - 1], 1024 * 1024 * 1024),
24        _ => (t, 1),
25    };
26    let n: u64 = digits.trim().parse::<u64>().map_err(|e| match e.kind() {
27        // A digit string too large for u64 is an overflow, not a syntax
28        // error, and saying "invalid size" for `99999999999999999999` would
29        // send the reader hunting for a typo that is not there.
30        std::num::IntErrorKind::PosOverflow => {
31            format!("size `{s}` overflows a 64-bit byte count")
32        }
33        _ => format!("invalid size `{s}`; expected e.g. 512M, 2G, or a byte count"),
34    })?;
35    n.checked_mul(mult)
36        .ok_or_else(|| format!("size `{s}` overflows a 64-bit byte count"))
37}
38
39/// Renders a byte count the way a human reads it, for `stuffr info`.
40///
41/// The inverse of [`parse_size`], and the two live in different crates but
42/// are tested together below so they cannot drift: anything this prints
43/// must parse back to the same value.
44///
45/// Lives in `stuffr-core` now, not here — whole-branch review LOW-5: three
46/// codecs in `stuffr-formats` had their own, differently-united renderings
47/// of the identical "declared size vs. configured limit" message this
48/// function was already written for, and `stuffr-formats` cannot depend on
49/// this crate to reach it. See `stuffr_core::size`'s module doc for the
50/// full reasoning. Re-exported here so every existing caller in this crate
51/// (`main.rs`'s `stuffr_cli::size::format_size`, this module's own tests)
52/// keeps working unchanged.
53pub use stuffr::size::format_size;
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    // `format_size`'s own rendering behaviour is tested where it now lives,
60    // `stuffr_core::size`. What belongs here is the property that only holds
61    // with BOTH functions present: this crate's `parse_size` must invert
62    // that crate's `format_size`.
63    #[test]
64    fn every_exact_rendering_parses_back_to_itself() {
65        // The two functions live together so they cannot drift; this is what
66        // enforces it. Only exact multiples round-trip — a "1.5 KiB" rendering
67        // is for human eyes and parse_size deliberately rejects floats.
68        for n in [
69            512u64,
70            1024,
71            4096,
72            256 * 1024 * 1024,
73            2 * 1024 * 1024 * 1024,
74        ] {
75            let rendered = format_size(n);
76            let compact: String = rendered
77                .replace(" bytes", "")
78                .replace(" KiB", "K")
79                .replace(" MiB", "M")
80                .replace(" GiB", "G");
81            assert_eq!(
82                parse_size(&compact).unwrap(),
83                n,
84                "{n} rendered as {rendered:?} did not parse back"
85            );
86        }
87    }
88
89    #[test]
90    fn plain_bytes_and_every_suffix() {
91        assert_eq!(parse_size("512").unwrap(), 512);
92        assert_eq!(parse_size("512K").unwrap(), 512 * 1024);
93        assert_eq!(parse_size("512M").unwrap(), 512 * 1024 * 1024);
94        assert_eq!(parse_size("2G").unwrap(), 2 * 1024 * 1024 * 1024);
95        assert_eq!(parse_size("2g").unwrap(), 2 * 1024 * 1024 * 1024);
96    }
97
98    #[test]
99    fn suffixes_are_binary_not_decimal() {
100        // 1024-based, not 1000-based. Stated as a test because getting this
101        // wrong is invisible until someone compares against `xz --memlimit`.
102        assert_eq!(parse_size("1M").unwrap(), 1_048_576);
103        assert_ne!(parse_size("1M").unwrap(), 1_000_000);
104    }
105
106    #[test]
107    fn malformed_input_is_an_error_naming_the_accepted_forms() {
108        // The `||` this replaced was too weak to constrain anything: its
109        // second branch, `contains("expected")`, matches nearly every message
110        // the function can produce, so the assertion passed almost by default.
111        // Requiring the concrete example means a future rewording that drops
112        // the guidance actually fails.
113        for bad in [
114            "", "  ", "512MB", "M", "-1", "1.5G", "abc", "512 MB", "K512",
115        ] {
116            let err = parse_size(bad).unwrap_err();
117            assert!(
118                err.contains("512M") && err.contains("2G"),
119                "error for {bad:?} must show the accepted forms concretely, got: {err}"
120            );
121        }
122    }
123
124    #[test]
125    fn forms_that_parse_but_are_worth_pinning() {
126        // Neither is documented anywhere a user would look, so pin the actual
127        // behaviour rather than leaving it to be discovered and "fixed".
128        //
129        // A space before the suffix is accepted, because the digit half is
130        // trimmed: `512 M` is 512 MiB.
131        assert_eq!(parse_size("512 M").unwrap(), 512 * 1024 * 1024);
132        // A leading `+` is accepted by Rust's unsigned FromStr.
133        assert_eq!(parse_size("+512").unwrap(), 512);
134    }
135
136    #[test]
137    fn a_digit_string_too_large_for_u64_reports_overflow_not_a_syntax_error() {
138        // Distinct from the exact-u64::MAX-times-a-multiplier case below:
139        // this overflows before any multiplier applies, and calling it
140        // "invalid size" would send the reader hunting for a typo.
141        let err = parse_size("99999999999999999999999").unwrap_err();
142        assert!(
143            err.contains("overflow"),
144            "expected an overflow message, got: {err}"
145        );
146    }
147
148    #[test]
149    fn overflow_is_reported_rather_than_wrapping() {
150        assert!(
151            parse_size("18446744073709551615G")
152                .unwrap_err()
153                .contains("overflow")
154        );
155    }
156}