Skip to main content

syncweb_core/
parsing.rs

1use std::time::{Duration, SystemTime};
2
3use crate::error::{Result, SyncwebError};
4
5/// Parse a human-readable byte size string like "5GB", "10MB", "500K", or "6%10".
6///
7/// Supports:
8/// - Binary: KiB, MiB, GiB, TiB, PiB
9/// - Decimal: KB, MB, GB, TB, PB
10/// - Plain bytes: B
11/// - Percentage: "6%10" means 6MB +/-10%
12///
13/// Returns (`min_bytes`, `max_bytes`). For exact values, min == max.
14///
15/// # Errors
16///
17/// Returns an error if the string cannot be parsed.
18pub fn parse_size_constraint(s: &str) -> Result<(Option<u64>, Option<u64>)> {
19    let trimmed = s.trim();
20    if trimmed.is_empty() {
21        return Ok((None, None));
22    }
23
24    if let Some((size_part, percent_part)) = trimmed.split_once('%') {
25        let base_size = parse_bytes(size_part)?;
26        let percent: u64 = percent_part
27            .parse::<u64>()
28            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid percentage {percent_part:?}: {error}")))?;
29
30        let lower = base_size
31            .saturating_mul(100_u64.saturating_sub(percent))
32            .checked_div(100)
33            .unwrap_or(0);
34        let upper = base_size
35            .saturating_mul(100_u64.saturating_add(percent))
36            .checked_div(100)
37            .unwrap_or(0);
38        return Ok((Some(lower), Some(upper)));
39    }
40
41    let (prefix, value_str) = trimmed
42        .strip_prefix('+')
43        .map(|rest| ("+", rest))
44        .or_else(|| trimmed.strip_prefix('-').map(|rest| ("-", rest)))
45        .or_else(|| trimmed.strip_prefix('>').map(|rest| (">", rest)))
46        .or_else(|| trimmed.strip_prefix('<').map(|rest| ("<", rest)))
47        .or_else(|| trimmed.strip_prefix('=').map(|rest| ("=", rest)))
48        .unwrap_or(("=", trimmed));
49
50    let size = parse_bytes(value_str)?;
51    match prefix {
52        "+" | ">" => Ok((Some(size.saturating_add(1)), None)),
53        "-" | "<" => Ok((None, Some(size.saturating_sub(1)))),
54        _ => Ok((Some(size), Some(size))),
55    }
56}
57
58/// Parse a byte size string to bytes.
59///
60/// # Errors
61///
62/// Returns an error if the string cannot be parsed.
63pub fn parse_bytes(s: &str) -> Result<u64> {
64    let trimmed = s.trim().to_ascii_lowercase();
65    let (number_str, multiplier) = split_size_suffix(&trimmed);
66
67    let parts: Vec<&str> = number_str.split('.').collect();
68    if parts.len() > 2 {
69        return Err(SyncwebError::InvalidConfig(format!("invalid size {s:?}")));
70    }
71
72    let integer_part: u64 = parts
73        .first()
74        .copied()
75        .unwrap_or("0")
76        .parse::<u64>()
77        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid size {s:?}: {error}")))?;
78
79    let mut total = integer_part.saturating_mul(multiplier);
80
81    if parts.len() == 2 {
82        let fraction_str = parts.get(1).copied().unwrap_or("0");
83        let fraction_part: u64 = fraction_str
84            .parse::<u64>()
85            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid size {s:?}: {error}")))?;
86
87        let fraction_divisor = 10_u64.pow(u32::try_from(fraction_str.len()).unwrap_or(0));
88        if fraction_divisor > 0 {
89            let fraction_bytes = multiplier
90                .saturating_mul(fraction_part)
91                .checked_div(fraction_divisor)
92                .unwrap_or(0);
93            total = total.saturating_add(fraction_bytes);
94        }
95    }
96
97    Ok(total)
98}
99
100/// Split a size string into numeric part and byte multiplier.
101#[must_use]
102fn split_size_suffix(value: &str) -> (&str, u64) {
103    if let Some(stripped) = value.strip_suffix("pib") {
104        return (stripped, 1024_u64.pow(5));
105    }
106    if let Some(stripped) = value.strip_suffix("tib") {
107        return (stripped, 1024_u64.pow(4));
108    }
109    if let Some(stripped) = value.strip_suffix("gib") {
110        return (stripped, 1024_u64.pow(3));
111    }
112    if let Some(stripped) = value.strip_suffix("mib") {
113        return (stripped, 1024_u64.pow(2));
114    }
115    if let Some(stripped) = value.strip_suffix("kib") {
116        return (stripped, 1024);
117    }
118    if let Some(stripped) = value.strip_suffix("pb") {
119        return (stripped, 1_000_000_000_000_000);
120    }
121    if let Some(stripped) = value.strip_suffix("tb") {
122        return (stripped, 1_000_000_000_000);
123    }
124    if let Some(stripped) = value.strip_suffix("gb") {
125        return (stripped, 1_000_000_000);
126    }
127    if let Some(stripped) = value.strip_suffix("mb") {
128        return (stripped, 1_000_000);
129    }
130    if let Some(stripped) = value.strip_suffix("kb") {
131        return (stripped, 1000);
132    }
133    if let Some(stripped) = value.strip_suffix('b') {
134        return (stripped, 1);
135    }
136    (value, 1)
137}
138
139/// Parse a human-readable time duration string like "3 days", "2 weeks", "1 year".
140///
141/// # Errors
142///
143/// Returns an error if the string cannot be parsed.
144pub fn parse_duration(s: &str) -> Result<Duration> {
145    let trimmed = s.trim().to_ascii_lowercase();
146    let (number_str, unit) = split_duration_parts(&trimmed);
147
148    let parts: Vec<&str> = number_str.split('.').collect();
149    if parts.len() > 2 {
150        return Err(SyncwebError::InvalidConfig(format!("invalid duration {s:?}")));
151    }
152
153    let integer_part: u64 = parts
154        .first()
155        .copied()
156        .unwrap_or("0")
157        .parse::<u64>()
158        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid duration {s:?}: {error}")))?;
159
160    let multiplier = duration_multiplier(unit)?;
161    let mut total_secs = integer_part.saturating_mul(multiplier);
162
163    if parts.len() == 2 {
164        let fraction_str = parts.get(1).copied().unwrap_or("0");
165        let fraction_part: u64 = fraction_str
166            .parse::<u64>()
167            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid duration {s:?}: {error}")))?;
168
169        let fraction_divisor = 10_u64.pow(u32::try_from(fraction_str.len()).unwrap_or(0));
170        if fraction_divisor > 0 {
171            let fraction_secs = multiplier
172                .saturating_mul(fraction_part)
173                .checked_div(fraction_divisor)
174                .unwrap_or(0);
175            total_secs = total_secs.saturating_add(fraction_secs);
176        }
177    }
178
179    Ok(Duration::from_secs(total_secs))
180}
181
182/// Split a duration string into numeric part and unit.
183#[must_use]
184fn split_duration_parts(value: &str) -> (&str, &str) {
185    let unit_start = value
186        .char_indices()
187        .find_map(|(index, character)| character.is_ascii_alphabetic().then_some(index))
188        .unwrap_or(value.len());
189    let (number, unit_str) = value.split_at(unit_start);
190    let unit = unit_str.strip_suffix('s').unwrap_or(unit_str);
191    (number.trim(), unit)
192}
193
194/// Get the multiplier for a duration unit.
195fn duration_multiplier(unit: &str) -> Result<u64> {
196    match unit {
197        "s" | "sec" | "second" => Ok(1),
198        "m" | "min" | "minute" => Ok(60),
199        "h" | "hr" | "hour" => Ok(3600),
200        "d" | "day" => Ok(86400),
201        "w" | "week" => Ok(604_800),
202        "mo" | "mon" | "month" => Ok(2_592_000),
203        "y" | "yr" | "year" => Ok(31_536_000),
204        _ => Err(SyncwebError::InvalidConfig(format!(
205            "unsupported duration unit {unit:?}"
206        ))),
207    }
208}
209
210/// Parse a relative time string like "3 days" or "-3 days" into a `SystemTime`.
211///
212/// # Errors
213///
214/// Returns an error if the string cannot be parsed.
215pub fn parse_relative_time(s: &str) -> Result<SystemTime> {
216    let trimmed = s.trim();
217    let (sign, duration_str) = trimmed.strip_prefix('-').map_or_else(
218        || trimmed.strip_prefix('+').map_or((1_i64, trimmed), |rest| (1_i64, rest)),
219        |rest| (-1_i64, rest),
220    );
221
222    let duration = parse_duration(duration_str)?;
223    let now = SystemTime::now();
224    if sign < 0 {
225        now.checked_sub(duration)
226            .ok_or_else(|| SyncwebError::InvalidConfig("time overflow".to_owned()))
227    } else {
228        now.checked_add(duration)
229            .ok_or_else(|| SyncwebError::InvalidConfig("time overflow".to_owned()))
230    }
231}
232
233/// Parse depth constraints from strings like "+2", "-3", "1".
234///
235/// Returns (`min_depth`, `max_depth`).
236#[must_use]
237pub fn parse_depth_constraints(
238    depth_list: &[String],
239    min_depth: usize,
240    max_depth: Option<usize>,
241) -> (usize, Option<usize>) {
242    let mut result_min = min_depth;
243    let mut result_max = max_depth;
244    for s in depth_list {
245        if let Some(rest) = s.strip_prefix('+') {
246            if let Ok(val) = rest.parse::<usize>() {
247                result_min = result_min.max(val);
248            }
249            continue;
250        }
251        if let Some(rest) = s.strip_prefix('-') {
252            if let Ok(val) = rest.parse::<usize>() {
253                result_max = Some(result_max.map_or(val, |current| current.min(val)));
254            }
255            continue;
256        }
257        if let Ok(val) = s.parse::<usize>() {
258            result_min = val;
259            result_max = Some(val);
260        }
261    }
262    (result_min, result_max)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_parse_bytes() {
271        assert_eq!(parse_bytes("5GB").unwrap(), 5_000_000_000);
272        assert_eq!(parse_bytes("5Gib").unwrap(), 5 * 1024 * 1024 * 1024);
273        assert_eq!(parse_bytes("500KB").unwrap(), 500_000);
274        assert_eq!(parse_bytes("1024").unwrap(), 1024);
275        assert_eq!(parse_bytes("1024b").unwrap(), 1024);
276    }
277
278    #[test]
279    fn test_parse_size_constraint() {
280        assert_eq!(
281            parse_size_constraint("5GB").unwrap(),
282            (Some(5_000_000_000), Some(5_000_000_000))
283        );
284        assert_eq!(parse_size_constraint("+5GB").unwrap(), (Some(5_000_000_001), None));
285        assert_eq!(parse_size_constraint("-5GB").unwrap(), (None, Some(4_999_999_999)));
286    }
287
288    #[test]
289    fn test_parse_size_constraint_percentage() {
290        let (min, max) = parse_size_constraint("6MB%10").unwrap();
291        assert!(min.is_some());
292        assert!(max.is_some());
293        let min_val = min.unwrap();
294        let max_val = max.unwrap();
295        assert!(min_val < 6_000_000);
296        assert!(max_val > 6_000_000);
297    }
298
299    #[test]
300    fn test_parse_duration() {
301        assert_eq!(parse_duration("3d").unwrap(), Duration::from_hours(72));
302        assert_eq!(parse_duration("2 weeks").unwrap(), Duration::from_hours(336));
303        assert_eq!(parse_duration("1 year").unwrap(), Duration::from_hours(8760));
304        assert_eq!(parse_duration("30 sec").unwrap(), Duration::from_secs(30));
305    }
306
307    #[test]
308    fn test_parse_depth_constraints() {
309        let empty: Vec<String> = vec![];
310        assert_eq!(parse_depth_constraints(&empty, 0, None), (0, None));
311        assert_eq!(parse_depth_constraints(&["+2".to_owned()], 0, None), (2, None));
312        assert_eq!(parse_depth_constraints(&["-3".to_owned()], 0, None), (0, Some(3)));
313        assert_eq!(parse_depth_constraints(&["2".to_owned()], 0, None), (2, Some(2)));
314        assert_eq!(
315            parse_depth_constraints(&["+1".to_owned(), "-3".to_owned()], 0, None),
316            (1, Some(3))
317        );
318    }
319}