1use thiserror::Error;
9
10#[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
23pub 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 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
63pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct Preset {
81 pub name: &'static str,
82 pub limit_bytes: Option<u64>,
84}
85
86pub 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" => ("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 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 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}