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