Skip to main content

syncweb_core/
schedule.rs

1use std::{
2    collections::BTreeMap,
3    fmt::{Display, Formatter},
4    time::{SystemTime, UNIX_EPOCH},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{Result, SyncwebError};
10
11/// TOML representation of the global and per-folder synchronization schedule.
12#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
13#[non_exhaustive]
14pub struct ScheduleConfig {
15    #[serde(default)]
16    pub active_hours: String,
17    #[serde(default)]
18    pub folders: BTreeMap<String, ScheduleFolderConfig>,
19}
20
21/// Per-folder schedule settings. Missing fields inherit the global schedule.
22#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
23#[non_exhaustive]
24pub struct ScheduleFolderConfig {
25    #[serde(default)]
26    pub active_hours: Option<String>,
27}
28
29/// A half-open time interval in minutes after midnight.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub struct TimeWindow {
33    start: u16,
34    end: u16,
35}
36
37impl TimeWindow {
38    /// Parse a `HH:MM-HH:MM` interval.
39    ///
40    /// An empty interval means that the schedule is active all day.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the interval is not in `HH:MM-HH:MM` form.
45    pub fn parse(value: &str) -> Result<Option<Self>> {
46        let trimmed = value.trim();
47        if trimmed.is_empty() {
48            return Ok(None);
49        }
50        let (start, end) = trimmed.split_once('-').ok_or_else(|| {
51            SyncwebError::InvalidConfig(format!("invalid schedule hours {trimmed:?}; expected HH:MM-HH:MM"))
52        })?;
53        Ok(Some(Self {
54            start: parse_clock(start)?,
55            end: parse_clock(end)?,
56        }))
57    }
58
59    #[must_use]
60    pub const fn contains(self, minute: u16) -> bool {
61        if self.start == self.end {
62            return false;
63        }
64        if self.start < self.end {
65            minute >= self.start && minute < self.end
66        } else {
67            minute >= self.start || minute < self.end
68        }
69    }
70
71    #[must_use]
72    pub const fn start(self) -> u16 {
73        self.start
74    }
75
76    #[must_use]
77    pub const fn end(self) -> u16 {
78        self.end
79    }
80}
81
82impl Display for TimeWindow {
83    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
84        write!(
85            formatter,
86            "{:02}:{:02}-{:02}:{:02}",
87            self.start.div_euclid(60),
88            self.start.rem_euclid(60),
89            self.end.div_euclid(60),
90            self.end.rem_euclid(60)
91        )
92    }
93}
94
95fn parse_clock(value: &str) -> Result<u16> {
96    let (hour_text, minute_text) = value
97        .trim()
98        .split_once(':')
99        .ok_or_else(|| SyncwebError::InvalidConfig(format!("invalid schedule time {value:?}; expected HH:MM")))?;
100    let hour_value = hour_text
101        .parse::<u16>()
102        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid schedule hour {value:?}: {error}")))?;
103    let minute_value = minute_text
104        .parse::<u16>()
105        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid schedule minute {value:?}: {error}")))?;
106    if hour_value > 24 || minute_value > 59 || (hour_value == 24 && minute_value != 0) {
107        return Err(SyncwebError::InvalidConfig(format!(
108            "schedule time {value:?} is outside the 24-hour clock"
109        )));
110    }
111    Ok(hour_value.saturating_mul(60).saturating_add(minute_value))
112}
113
114/// Parse a byte size such as `5MB`, `500KB`, `2M`, `500K`, `4096`, or `0`.
115///
116/// The scale letter (`k`/`m`/`g`/`t`) is case-insensitive; the unit letter
117/// distinguishes bytes (`B`) from bits (`b`). So `5KB` is 5000 bytes while
118/// `5Kb` is 5000 bits (625 bytes), and bare scales such as `2M` mean bytes.
119/// A trailing `/s` is accepted for backward compatibility but ignored.
120///
121/// # Errors
122///
123/// Returns an error if the value is not a whole number of bytes with a
124/// supported suffix.
125pub fn parse_byte_size(value: &str) -> Result<Option<u64>> {
126    let normalized = value.trim();
127    if normalized.is_empty() || normalized == "0" || normalized.eq_ignore_ascii_case("unlimited") {
128        return Ok(None);
129    }
130    let size_number = normalized
131        .strip_suffix("/s")
132        .or_else(|| normalized.strip_suffix("/S"))
133        .unwrap_or(normalized)
134        .trim();
135    let (numeric_part, multiplier, is_bytes) = split_size_suffix(size_number);
136    let amount = numeric_part.trim().parse::<u64>().map_err(|error| {
137        SyncwebError::InvalidConfig(format!(
138            "invalid byte size {value:?}; expected a whole number with an optional B, KB, or Kb suffix: {error}"
139        ))
140    })?;
141    let scaled = amount.checked_mul(multiplier);
142    if is_bytes {
143        scaled
144            .map(Some)
145            .ok_or_else(|| SyncwebError::InvalidConfig(format!("byte size {value:?} is too large")))
146    } else {
147        let bits = scaled.ok_or_else(|| SyncwebError::InvalidConfig(format!("byte size {value:?} is too large")))?;
148        if bits.rem_euclid(8) != 0 {
149            return Err(SyncwebError::InvalidConfig(format!(
150                "byte size {value:?} is not a whole number of bytes"
151            )));
152        }
153        Ok(Some(bits.checked_div(8).unwrap_or_default()))
154    }
155}
156
157/// Split a size into its numeric prefix, scale multiplier, and whether the
158/// unit is bytes (true) or bits (false).
159#[must_use]
160fn split_size_suffix(value: &str) -> (&str, u64, bool) {
161    let lower = value.to_ascii_lowercase();
162    for (suffix, multiplier) in [
163        ("tibit", 1_u64 << 40),
164        ("gibit", 1_u64 << 30),
165        ("mibit", 1_u64 << 20),
166        ("kibit", 1_u64 << 10),
167        ("tib", 1_u64 << 40),
168        ("gib", 1_u64 << 30),
169        ("mib", 1_u64 << 20),
170        ("kib", 1_u64 << 10),
171        ("tbit", 1_000_000_000_000_u64),
172        ("gbit", 1_000_000_000_u64),
173        ("mbit", 1_000_000_u64),
174        ("kbit", 1_000_u64),
175        ("tb", 1_000_000_000_000_u64),
176        ("gb", 1_000_000_000_u64),
177        ("mb", 1_000_000_u64),
178        ("kb", 1_000_u64),
179        ("bit", 1_u64),
180    ] {
181        if let Some(rest) = lower.strip_suffix(suffix) {
182            let is_bytes = if suffix.ends_with('t') {
183                false
184            } else {
185                matches!(value.as_bytes().last(), Some(b'B'))
186            };
187            return (&value[..rest.len()], multiplier, is_bytes);
188        }
189    }
190    let is_bytes = !matches!(value.as_bytes().last(), Some(b'b'));
191    let (cut, multiplier) = if lower.ends_with('t') {
192        (value.len().saturating_sub(1), 1_000_000_000_000_u64)
193    } else if lower.ends_with('g') {
194        (value.len().saturating_sub(1), 1_000_000_000_u64)
195    } else if lower.ends_with('m') {
196        (value.len().saturating_sub(1), 1_000_000_u64)
197    } else if lower.ends_with('k') {
198        (value.len().saturating_sub(1), 1_000_u64)
199    } else if lower.ends_with('b') {
200        (value.len().saturating_sub(1), 1_u64)
201    } else {
202        (value.len(), 1_u64)
203    };
204    (&value[..cut], multiplier, is_bytes)
205}
206
207/// Parsed schedule settings for one scope.
208#[derive(Clone, Debug, Default, Eq, PartialEq)]
209#[non_exhaustive]
210pub struct Schedule {
211    pub active_hours: Option<TimeWindow>,
212}
213
214#[derive(Clone, Debug)]
215struct FolderSchedule {
216    active_hours: Option<TimeWindow>,
217    active_hours_override: bool,
218}
219
220/// Evaluates global and per-folder schedules.
221#[derive(Clone, Debug)]
222#[non_exhaustive]
223pub struct ScheduleManager {
224    global: Schedule,
225    folders: BTreeMap<String, FolderSchedule>,
226}
227
228impl ScheduleManager {
229    /// Parse a schedule configuration.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if a schedule interval is invalid.
234    pub fn from_config(config: &ScheduleConfig) -> Result<Self> {
235        let global = parse_schedule(&config.active_hours)?;
236        let folders = config
237            .folders
238            .iter()
239            .map(|(name, folder)| {
240                let (active_hours_override, active_hours) = folder
241                    .active_hours
242                    .as_deref()
243                    .map(TimeWindow::parse)
244                    .transpose()?
245                    .map_or((false, None), |hours| (true, hours));
246                Ok((
247                    name.clone(),
248                    FolderSchedule {
249                        active_hours,
250                        active_hours_override,
251                    },
252                ))
253            })
254            .collect::<Result<BTreeMap<_, _>>>()?;
255        Ok(Self { global, folders })
256    }
257
258    /// Return whether a folder is active at the current local wall-clock time.
259    #[must_use]
260    pub fn is_active(&self, folder: Option<&str>) -> bool {
261        self.is_active_at(folder, current_minute())
262    }
263
264    /// Return whether a folder is active at a supplied minute after midnight.
265    #[must_use]
266    pub fn is_active_at(&self, folder: Option<&str>, minute: u16) -> bool {
267        let active_hours = folder
268            .and_then(|name| self.folders.get(name))
269            .filter(|schedule| schedule.active_hours_override)
270            .and_then(|schedule| schedule.active_hours)
271            .or(self.global.active_hours);
272        active_hours.is_none_or(|window| window.contains(minute))
273    }
274
275    /// Return the next minute at which the selected schedule becomes active.
276    #[must_use]
277    pub fn next_active_window_start_at(&self, folder: Option<&str>, minute: u16) -> Option<u16> {
278        let window = folder
279            .and_then(|name| self.folders.get(name))
280            .filter(|schedule| schedule.active_hours_override)
281            .and_then(|schedule| schedule.active_hours)
282            .or(self.global.active_hours)?;
283        if window.contains(minute) {
284            return Some(minute);
285        }
286        for offset in 1_u16..=1_440 {
287            let candidate = minute.saturating_add(offset) % 1_440;
288            if window.contains(candidate) {
289                return Some(candidate);
290            }
291        }
292        None
293    }
294
295    #[must_use]
296    pub const fn global(&self) -> &Schedule {
297        &self.global
298    }
299}
300
301fn parse_schedule(active_hours: &str) -> Result<Schedule> {
302    Ok(Schedule {
303        active_hours: TimeWindow::parse(active_hours)?,
304    })
305}
306
307pub(crate) fn current_minute() -> u16 {
308    let seconds = SystemTime::now()
309        .duration_since(UNIX_EPOCH)
310        .map_or(0, |duration| duration.as_secs());
311    let day_seconds = seconds % 86_400;
312    u16::try_from(day_seconds.div_euclid(60)).unwrap_or(0)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use anyhow::ensure;
319
320    #[test]
321    fn parse_byte_size_accepts_bare_scale_suffixes() -> anyhow::Result<()> {
322        ensure!(parse_byte_size("500K")? == Some(500_000), "500K should be 500_000");
323        ensure!(parse_byte_size("2M")? == Some(2_000_000), "2M should be 2_000_000");
324        ensure!(
325            parse_byte_size("1G")? == Some(1_000_000_000),
326            "1G should be 1_000_000_000"
327        );
328        ensure!(
329            parse_byte_size("2T")? == Some(2_000_000_000_000),
330            "2T should be 2_000_000_000_000"
331        );
332        ensure!(parse_byte_size("0")?.is_none(), "0 should be unlimited");
333        ensure!(parse_byte_size("unlimited")?.is_none(), "unlimited should be unlimited");
334        Ok(())
335    }
336
337    #[test]
338    fn parse_byte_size_preserves_existing_suffixes() -> anyhow::Result<()> {
339        ensure!(parse_byte_size("5MB")? == Some(5_000_000), "5MB should be 5_000_000");
340        ensure!(parse_byte_size("2MiB")? == Some(2_097_152), "2MiB should be 2_097_152");
341        ensure!(parse_byte_size("3KiB")? == Some(3_072), "3KiB should be 3_072");
342        ensure!(parse_byte_size("250B")? == Some(250), "250B should be 250");
343        ensure!(parse_byte_size("4096")? == Some(4096), "plain number should be bytes");
344        ensure!(
345            parse_byte_size("5MB/s")? == Some(5_000_000),
346            "a legacy /s suffix should be ignored"
347        );
348        Ok(())
349    }
350
351    #[test]
352    fn parse_byte_size_distinguishes_bytes_from_bits() -> anyhow::Result<()> {
353        ensure!(parse_byte_size("5KB")? == Some(5000), "5KB should be 5000 bytes");
354        ensure!(
355            parse_byte_size("5Kb")? == Some(625),
356            "5Kb should be 5000 bits = 625 bytes"
357        );
358        ensure!(
359            parse_byte_size("1MB")? == Some(1_000_000),
360            "1MB should be 1_000_000 bytes"
361        );
362        ensure!(
363            parse_byte_size("1Mb")? == Some(125_000),
364            "1Mb should be 1_000_000 bits = 125_000 bytes"
365        );
366        ensure!(
367            parse_byte_size("2Mbit")? == Some(250_000),
368            "2Mbit should be 250_000 bytes"
369        );
370        ensure!(parse_byte_size("8b")? == Some(1), "8b should be 1 byte");
371        ensure!(parse_byte_size("1b").is_err(), "1 bit should not be a whole byte");
372        ensure!(
373            parse_byte_size("2TB")? == Some(2_000_000_000_000),
374            "2TB should be 2 TB bytes"
375        );
376        ensure!(
377            parse_byte_size("2Tb")? == Some(250_000_000_000),
378            "2Tb should be 2 TB bits = 250 GB bytes"
379        );
380        ensure!(
381            parse_byte_size("1TiB")? == Some(1_099_511_627_776),
382            "1TiB should be 2^40 bytes"
383        );
384        Ok(())
385    }
386
387    #[test]
388    fn parse_byte_size_rejects_invalid_values() -> anyhow::Result<()> {
389        ensure!(parse_byte_size("banana").is_err(), "non-numeric size should fail");
390        ensure!(parse_byte_size("-2M").is_err(), "negative size should fail");
391        ensure!(parse_byte_size("M").is_err(), "bare suffix should fail");
392        Ok(())
393    }
394}