Skip to main content

bee/swarm/
size.rs

1//! Decimal-base file size type. Mirrors bee-go's `pkg/swarm/size.go`
2//! (and bee-js `Size`): all conversions use 1000 as the base so they
3//! stay aligned with the Swarm theoretical/effective storage tables.
4
5use std::fmt;
6use std::str::FromStr;
7
8use crate::swarm::Error;
9
10/// Non-negative size in bytes. Constructors round fractional inputs
11/// up; negative or NaN inputs return [`Error::Argument`].
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct Size {
14    bytes: i64,
15}
16
17const KB: i64 = 1_000;
18const MB: i64 = KB * 1_000;
19const GB: i64 = MB * 1_000;
20const TB: i64 = GB * 1_000;
21
22impl Size {
23    /// Build from a byte count (rounded up if fractional).
24    pub fn from_bytes(b: f64) -> Result<Self, Error> {
25        Self::new(b)
26    }
27
28    /// Build from kilobytes (1 kB = 1000 B).
29    pub fn from_kilobytes(k: f64) -> Result<Self, Error> {
30        Self::new(k * KB as f64)
31    }
32
33    /// Build from megabytes.
34    pub fn from_megabytes(m: f64) -> Result<Self, Error> {
35        Self::new(m * MB as f64)
36    }
37
38    /// Build from gigabytes.
39    pub fn from_gigabytes(g: f64) -> Result<Self, Error> {
40        Self::new(g * GB as f64)
41    }
42
43    /// Build from terabytes.
44    pub fn from_terabytes(t: f64) -> Result<Self, Error> {
45        Self::new(t * TB as f64)
46    }
47
48    /// Parse strings like `"28MB"`, `"1gb"`, `"512 kb"`,
49    /// `"2megabytes"`. Case-insensitive, whitespace-tolerant. Accepts
50    /// decimal values (`"1.5gb"`) and concatenated parts (e.g.
51    /// `"1gb 256mb"`). See [`FromStr`].
52    pub fn parse(s: &str) -> Result<Self, Error> {
53        <Self as FromStr>::from_str(s)
54    }
55
56    /// Bytes (rounded up at construction).
57    pub const fn to_bytes(self) -> i64 {
58        self.bytes
59    }
60
61    /// Fractional kilobytes.
62    pub fn to_kilobytes(self) -> f64 {
63        self.bytes as f64 / KB as f64
64    }
65
66    /// Fractional megabytes.
67    pub fn to_megabytes(self) -> f64 {
68        self.bytes as f64 / MB as f64
69    }
70
71    /// Fractional gigabytes.
72    pub fn to_gigabytes(self) -> f64 {
73        self.bytes as f64 / GB as f64
74    }
75
76    /// Fractional terabytes.
77    pub fn to_terabytes(self) -> f64 {
78        self.bytes as f64 / TB as f64
79    }
80
81    fn new(b: f64) -> Result<Self, Error> {
82        if b.is_nan() {
83            return Err(Error::argument("size is NaN"));
84        }
85        if b < 0.0 {
86            return Err(Error::argument("size must be at least 0"));
87        }
88        Ok(Self {
89            bytes: b.ceil() as i64,
90        })
91    }
92}
93
94impl fmt::Display for Size {
95    /// Auto-scaled human-readable rendering (e.g. `"1.50 GB"`).
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        if self.bytes >= TB {
98            write!(f, "{:.2} TB", self.to_terabytes())
99        } else if self.bytes >= GB {
100            write!(f, "{:.2} GB", self.to_gigabytes())
101        } else if self.bytes >= MB {
102            write!(f, "{:.2} MB", self.to_megabytes())
103        } else if self.bytes >= KB {
104            write!(f, "{:.2} kB", self.to_kilobytes())
105        } else {
106            write!(f, "{} B", self.bytes)
107        }
108    }
109}
110
111impl FromStr for Size {
112    type Err = Error;
113
114    fn from_str(s: &str) -> Result<Self, Error> {
115        let clean: String = s.chars().filter(|c| !c.is_whitespace()).collect();
116        let lower = clean.to_ascii_lowercase();
117        if lower.is_empty() {
118            return Err(Error::argument("empty size string"));
119        }
120
121        let mut total_bytes: f64 = 0.0;
122        let mut chars = lower.chars().peekable();
123        let mut found = false;
124
125        while chars.peek().is_some() {
126            let mut num = String::new();
127            while let Some(&c) = chars.peek() {
128                if c.is_ascii_digit() || c == '.' {
129                    num.push(c);
130                    chars.next();
131                } else {
132                    break;
133                }
134            }
135            if num.is_empty() {
136                return Err(Error::argument(format!("unrecognized size string: {s}")));
137            }
138            let value: f64 = num
139                .parse()
140                .map_err(|_| Error::argument(format!("invalid size number: {num}")))?;
141
142            let mut unit = String::new();
143            while let Some(&c) = chars.peek() {
144                if c.is_ascii_alphabetic() {
145                    unit.push(c);
146                    chars.next();
147                } else {
148                    break;
149                }
150            }
151            if unit.is_empty() {
152                return Err(Error::argument(format!("missing unit in: {s}")));
153            }
154            total_bytes += value * unit_to_bytes(&unit)?;
155            found = true;
156        }
157
158        if !found {
159            return Err(Error::argument(format!("unrecognized size string: {s}")));
160        }
161        Self::new(total_bytes)
162    }
163}
164
165fn unit_to_bytes(unit: &str) -> Result<f64, Error> {
166    Ok(match unit {
167        "b" | "byte" | "bytes" => 1.0,
168        "kb" | "kilobyte" | "kilobytes" => KB as f64,
169        "mb" | "megabyte" | "megabytes" => MB as f64,
170        "gb" | "gigabyte" | "gigabytes" => GB as f64,
171        "tb" | "terabyte" | "terabytes" => TB as f64,
172        other => return Err(Error::argument(format!("unsupported size unit: {other}"))),
173    })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn from_bytes_rounds_up() {
182        assert_eq!(Size::from_bytes(10.4).unwrap().to_bytes(), 11);
183        assert_eq!(Size::from_bytes(0.0).unwrap().to_bytes(), 0);
184    }
185
186    #[test]
187    fn from_kilobytes_uses_decimal_base() {
188        let s = Size::from_kilobytes(1.5).unwrap();
189        assert_eq!(s.to_bytes(), 1500);
190    }
191
192    #[test]
193    fn from_megabytes_to_kilobytes_round_trip() {
194        let s = Size::from_megabytes(2.0).unwrap();
195        assert!((s.to_kilobytes() - 2_000.0).abs() < f64::EPSILON);
196    }
197
198    #[test]
199    fn from_str_parses_compound() {
200        let s = Size::parse("1gb256mb").unwrap();
201        assert_eq!(s.to_bytes(), GB + 256 * MB);
202    }
203
204    #[test]
205    fn from_str_handles_whitespace_and_mixed_case() {
206        let s = Size::parse("  512 kB  ").unwrap();
207        assert_eq!(s.to_bytes(), 512 * KB);
208    }
209
210    #[test]
211    fn from_str_decimal_value() {
212        let s = Size::parse("1.5gb").unwrap();
213        assert_eq!(s.to_bytes(), (1.5 * GB as f64).ceil() as i64);
214    }
215
216    #[test]
217    fn from_str_rejects_empty() {
218        assert!(Size::parse("").is_err());
219        assert!(Size::parse("   ").is_err());
220    }
221
222    #[test]
223    fn from_str_rejects_unknown_unit() {
224        assert!(Size::parse("2pb").is_err());
225    }
226
227    #[test]
228    fn negative_or_nan_rejected() {
229        assert!(Size::from_bytes(-1.0).is_err());
230        assert!(Size::from_bytes(f64::NAN).is_err());
231    }
232
233    #[test]
234    fn display_auto_scales() {
235        assert_eq!(Size::from_bytes(1.0).unwrap().to_string(), "1 B");
236        assert_eq!(Size::from_kilobytes(1.5).unwrap().to_string(), "1.50 kB");
237        assert_eq!(Size::from_megabytes(28.0).unwrap().to_string(), "28.00 MB");
238        assert_eq!(Size::from_gigabytes(1.0).unwrap().to_string(), "1.00 GB");
239        assert_eq!(Size::from_terabytes(2.0).unwrap().to_string(), "2.00 TB");
240    }
241}