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}