Skip to main content

deepshrink_core/
size.rs

1//! Human-readable size parsing and platform presets.
2//!
3//! Sizes (`8MB`, `500KB`, `1.5GB`) → bytes. Decimal units (`KB/MB/GB`) use 1000,
4//! binary units (`KiB/MiB/GiB`) use 1024. Decimal is the deliberate default:
5//! for the same number it is smaller than binary, so a "≤ 8 MB" goal stays
6//! conservative even on platforms that mean 8 MiB when they say "8 MB".
7
8use thiserror::Error;
9
10/// Errors from parsing a size or a percent.
11#[derive(Debug, Error, PartialEq)]
12pub enum SizeError {
13    #[error("empty size string")]
14    Empty,
15    #[error("invalid number in size: {0:?}")]
16    InvalidNumber(String),
17    #[error("unknown size unit: {0:?}")]
18    UnknownUnit(String),
19    #[error("percent must be in (0, 100), got {0}")]
20    PercentOutOfRange(f64),
21}
22
23/// Parse a size like `8MB`, `500KB`, `1.5GB`, `1024KiB`, `900000` into bytes.
24///
25/// Units are case-insensitive. A space between number and unit is allowed.
26pub fn parse_size(input: &str) -> Result<u64, SizeError> {
27    let s = input.trim();
28    if s.is_empty() {
29        return Err(SizeError::Empty);
30    }
31
32    // Split the numeric prefix from the unit suffix.
33    let split = s
34        .find(|c: char| !(c.is_ascii_digit() || c == '.'))
35        .unwrap_or(s.len());
36    let (num_part, unit_part) = s.split_at(split);
37    if num_part.is_empty() {
38        return Err(SizeError::InvalidNumber(s.to_string()));
39    }
40
41    let value: f64 = num_part
42        .parse()
43        .map_err(|_| SizeError::InvalidNumber(num_part.to_string()))?;
44    if !value.is_finite() || value < 0.0 {
45        return Err(SizeError::InvalidNumber(num_part.to_string()));
46    }
47
48    let unit = unit_part.trim();
49    let multiplier: f64 = match unit.to_ascii_lowercase().as_str() {
50        "" | "b" => 1.0,
51        "k" | "kb" => 1_000.0,
52        "m" | "mb" => 1_000_000.0,
53        "g" | "gb" => 1_000_000_000.0,
54        "kib" => 1_024.0,
55        "mib" => (1_024u64 * 1_024) as f64,
56        "gib" => (1_024u64 * 1_024 * 1_024) as f64,
57        _ => return Err(SizeError::UnknownUnit(unit.to_string())),
58    };
59
60    Ok((value * multiplier).round() as u64)
61}
62
63/// Parse a percent like `70%` or `70` into the fraction `0.70`. Range is strictly (0, 100).
64pub fn parse_percent(input: &str) -> Result<f64, SizeError> {
65    let s = input.trim().trim_end_matches('%').trim();
66    if s.is_empty() {
67        return Err(SizeError::Empty);
68    }
69    let value: f64 = s
70        .parse()
71        .map_err(|_| SizeError::InvalidNumber(input.to_string()))?;
72    if !value.is_finite() || value <= 0.0 || value >= 100.0 {
73        return Err(SizeError::PercentOutOfRange(value));
74    }
75    Ok(value / 100.0)
76}
77
78/// A platform preset: a name and a hard limit in bytes (if any).
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct Preset {
81    pub name: &'static str,
82    /// Target size in bytes; `None` means optimize without a hard limit (`web`).
83    pub limit_bytes: Option<u64>,
84}
85
86/// Look up a preset by name. Limit values are TO BE VERIFIED AT RELEASE: platforms change them.
87pub fn preset(name: &str) -> Option<Preset> {
88    let (name, limit_bytes) = match name.trim().to_ascii_lowercase().as_str() {
89        "discord" => ("discord", Some(10_000_000)),
90        "discord-nitro" => ("discord-nitro", Some(500_000_000)),
91        "email" => ("email", Some(20_000_000)),
92        // iMessage: Apple doesn't publish a limit; ~100 MB is what goes
93        // through as a video (larger is sent as a link) — to verify.
94        "imessage" => ("imessage", Some(100_000_000)),
95        "slack" => ("slack", Some(1_000_000_000)),
96        "telegram" => ("telegram", Some(2_000_000_000)),
97        "whatsapp" => ("whatsapp", Some(16_000_000)),
98        "web" => ("web", None),
99        _ => return None,
100    };
101    Some(Preset { name, limit_bytes })
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn parses_decimal_units() {
110        assert_eq!(parse_size("8MB").unwrap(), 8_000_000);
111        assert_eq!(parse_size("500KB").unwrap(), 500_000);
112        assert_eq!(parse_size("1.5GB").unwrap(), 1_500_000_000);
113        assert_eq!(parse_size("2GB").unwrap(), 2_000_000_000);
114    }
115
116    #[test]
117    fn parses_binary_units() {
118        assert_eq!(parse_size("1KiB").unwrap(), 1_024);
119        assert_eq!(parse_size("1MiB").unwrap(), 1_048_576);
120        assert_eq!(parse_size("1GiB").unwrap(), 1_073_741_824);
121    }
122
123    #[test]
124    fn parses_bare_bytes_and_case_and_spaces() {
125        assert_eq!(parse_size("900000").unwrap(), 900_000);
126        assert_eq!(parse_size("10b").unwrap(), 10);
127        assert_eq!(parse_size("8mb").unwrap(), 8_000_000);
128        assert_eq!(parse_size("  8 MB ").unwrap(), 8_000_000);
129    }
130
131    #[test]
132    fn rounds_fractional_bytes() {
133        // 0.0000005 MB = 0.5 bytes → rounds to 1 (round-half-away).
134        assert_eq!(parse_size("0.0000005MB").unwrap(), 1);
135    }
136
137    #[test]
138    fn rejects_garbage() {
139        assert_eq!(parse_size(""), Err(SizeError::Empty));
140        assert_eq!(parse_size("   "), Err(SizeError::Empty));
141        assert!(matches!(parse_size("MB"), Err(SizeError::InvalidNumber(_))));
142        assert!(matches!(parse_size("8TB"), Err(SizeError::UnknownUnit(_))));
143        assert!(matches!(
144            parse_size("1.2.3MB"),
145            Err(SizeError::InvalidNumber(_))
146        ));
147    }
148
149    #[test]
150    fn parses_percent() {
151        assert_eq!(parse_percent("70%").unwrap(), 0.70);
152        assert_eq!(parse_percent("70").unwrap(), 0.70);
153        assert_eq!(parse_percent(" 12.5 % ").unwrap(), 0.125);
154    }
155
156    #[test]
157    fn rejects_bad_percent() {
158        assert!(matches!(
159            parse_percent("0%"),
160            Err(SizeError::PercentOutOfRange(_))
161        ));
162        assert!(matches!(
163            parse_percent("100%"),
164            Err(SizeError::PercentOutOfRange(_))
165        ));
166        assert!(matches!(
167            parse_percent("150"),
168            Err(SizeError::PercentOutOfRange(_))
169        ));
170        assert!(matches!(
171            parse_percent("abc"),
172            Err(SizeError::InvalidNumber(_))
173        ));
174    }
175
176    #[test]
177    fn known_presets_resolve() {
178        assert_eq!(preset("discord").unwrap().limit_bytes, Some(10_000_000));
179        assert_eq!(preset("DISCORD").unwrap().limit_bytes, Some(10_000_000));
180        assert_eq!(
181            preset("discord-nitro").unwrap().limit_bytes,
182            Some(500_000_000)
183        );
184        assert_eq!(preset("email").unwrap().limit_bytes, Some(20_000_000));
185        assert_eq!(preset("telegram").unwrap().limit_bytes, Some(2_000_000_000));
186        assert_eq!(preset("whatsapp").unwrap().limit_bytes, Some(16_000_000));
187        assert_eq!(preset("imessage").unwrap().limit_bytes, Some(100_000_000));
188        assert_eq!(preset("slack").unwrap().limit_bytes, Some(1_000_000_000));
189        // web — optimization without a hard limit.
190        assert_eq!(preset("web").unwrap().limit_bytes, None);
191    }
192
193    #[test]
194    fn unknown_preset_is_none() {
195        assert!(preset("myspace").is_none());
196    }
197}