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`, or `0`.
157///
158/// # Errors
159///
160/// Returns an error if the rate is not a whole number with a supported suffix.
161pub fn parse_rate(value: &str) -> Result<Option<u64>> {
162    let normalized = value.trim().to_ascii_lowercase();
163    if normalized.is_empty() || normalized == "0" || normalized == "unlimited" {
164        return Ok(None);
165    }
166    let rate_number = normalized.strip_suffix("/s").unwrap_or(&normalized).trim();
167    let (numeric_part, multiplier) = split_rate_suffix(rate_number);
168    let amount = numeric_part.trim().parse::<u64>().map_err(|error| {
169        SyncwebError::InvalidConfig(format!(
170            "invalid bandwidth rate {value:?}; expected a whole number with an optional B/s suffix: {error}"
171        ))
172    })?;
173    amount
174        .checked_mul(multiplier)
175        .map(Some)
176        .ok_or_else(|| SyncwebError::InvalidConfig(format!("bandwidth rate {value:?} is too large")))
177}
178
179#[must_use]
180fn split_rate_suffix(value: &str) -> (&str, u64) {
181    if let Some(stripped) = value.strip_suffix("kib") {
182        return (stripped, 1024_u64);
183    }
184    if let Some(stripped) = value.strip_suffix("mib") {
185        return (stripped, 1024_u64.saturating_mul(1024));
186    }
187    if let Some(stripped) = value.strip_suffix("gib") {
188        return (stripped, 1024_u64.saturating_mul(1024).saturating_mul(1024));
189    }
190    if let Some(stripped) = value.strip_suffix("kb") {
191        return (stripped, 1000_u64);
192    }
193    if let Some(stripped) = value.strip_suffix("mb") {
194        return (stripped, 1000_u64.saturating_mul(1000));
195    }
196    if let Some(stripped) = value.strip_suffix("gb") {
197        return (stripped, 1000_u64.saturating_mul(1000).saturating_mul(1000));
198    }
199    if let Some(stripped) = value.strip_suffix('b') {
200        return (stripped, 1);
201    }
202    (value, 1)
203}
204
205/// A parsed bandwidth window.
206#[derive(Clone, Debug, Eq, PartialEq)]
207#[non_exhaustive]
208pub struct BandwidthWindow {
209    pub hours: TimeWindow,
210    pub max_upload: Option<u64>,
211    pub max_download: Option<u64>,
212}
213
214/// Parsed schedule settings for one scope.
215#[derive(Clone, Debug, Default, Eq, PartialEq)]
216#[non_exhaustive]
217pub struct Schedule {
218    pub active_hours: Option<TimeWindow>,
219    pub bandwidth: Vec<BandwidthWindow>,
220}
221
222/// Effective limits at a point in time.
223#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
224#[non_exhaustive]
225pub struct BandwidthLimits {
226    pub max_upload: Option<u64>,
227    pub max_download: Option<u64>,
228}
229
230#[derive(Clone, Debug)]
231struct FolderSchedule {
232    active_hours: Option<TimeWindow>,
233    active_hours_override: bool,
234    max_upload: Option<u64>,
235    max_upload_override: bool,
236    max_download: Option<u64>,
237    max_download_override: bool,
238}
239
240/// Evaluates global and per-folder schedules.
241#[derive(Clone, Debug)]
242#[non_exhaustive]
243pub struct ScheduleManager {
244    global: Schedule,
245    folders: BTreeMap<String, FolderSchedule>,
246}
247
248impl ScheduleManager {
249    /// Parse a schedule configuration.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if a schedule interval or bandwidth rate is invalid.
254    pub fn from_config(config: &ScheduleConfig) -> Result<Self> {
255        let global = parse_schedule(&config.active_hours, &config.bandwidth)?;
256        let folders = config
257            .folders
258            .iter()
259            .map(|(name, folder)| {
260                let (active_hours_override, active_hours) = folder
261                    .active_hours
262                    .as_deref()
263                    .map(TimeWindow::parse)
264                    .transpose()?
265                    .map_or((false, None), |hours| (true, hours));
266                let (max_upload_override, max_upload) = folder
267                    .max_upload
268                    .as_deref()
269                    .map(parse_rate)
270                    .transpose()?
271                    .map_or((false, None), |rate| (true, rate));
272                let (max_download_override, max_download) = folder
273                    .max_download
274                    .as_deref()
275                    .map(parse_rate)
276                    .transpose()?
277                    .map_or((false, None), |rate| (true, rate));
278                Ok((
279                    name.clone(),
280                    FolderSchedule {
281                        active_hours,
282                        active_hours_override,
283                        max_upload,
284                        max_upload_override,
285                        max_download,
286                        max_download_override,
287                    },
288                ))
289            })
290            .collect::<Result<BTreeMap<_, _>>>()?;
291        Ok(Self { global, folders })
292    }
293
294    /// Return whether a folder is active at the current local wall-clock time.
295    #[must_use]
296    pub fn is_active(&self, folder: Option<&str>) -> bool {
297        self.is_active_at(folder, current_minute())
298    }
299
300    /// Return whether a folder is active at a supplied minute after midnight.
301    #[must_use]
302    pub fn is_active_at(&self, folder: Option<&str>, minute: u16) -> bool {
303        let active_hours = folder
304            .and_then(|name| self.folders.get(name))
305            .filter(|schedule| schedule.active_hours_override)
306            .and_then(|schedule| schedule.active_hours)
307            .or(self.global.active_hours);
308        active_hours.is_none_or(|window| window.contains(minute))
309    }
310
311    /// Return the limits for a folder at the current local wall-clock time.
312    #[must_use]
313    pub fn current_limits(&self, folder: Option<&str>) -> BandwidthLimits {
314        self.current_limits_at(folder, current_minute())
315    }
316
317    /// Return the limits for a folder at a supplied minute after midnight.
318    #[must_use]
319    pub fn current_limits_at(&self, folder: Option<&str>, minute: u16) -> BandwidthLimits {
320        let mut limits = self
321            .global
322            .bandwidth
323            .iter()
324            .find(|window| window.hours.contains(minute))
325            .map_or_else(BandwidthLimits::default, |window| BandwidthLimits {
326                max_upload: window.max_upload,
327                max_download: window.max_download,
328            });
329        if let Some(folder_schedule) = folder.and_then(|name| self.folders.get(name)) {
330            if folder_schedule.max_upload_override {
331                limits.max_upload = folder_schedule.max_upload;
332            }
333            if folder_schedule.max_download_override {
334                limits.max_download = folder_schedule.max_download;
335            }
336        }
337        limits
338    }
339
340    /// Return the next minute at which the selected schedule becomes active.
341    #[must_use]
342    pub fn next_active_window_start_at(&self, folder: Option<&str>, minute: u16) -> Option<u16> {
343        let window = folder
344            .and_then(|name| self.folders.get(name))
345            .filter(|schedule| schedule.active_hours_override)
346            .and_then(|schedule| schedule.active_hours)
347            .or(self.global.active_hours)?;
348        if window.contains(minute) {
349            return Some(minute);
350        }
351        for offset in 1_u16..=1_440 {
352            let candidate = minute.saturating_add(offset) % 1_440;
353            if window.contains(candidate) {
354                return Some(candidate);
355            }
356        }
357        None
358    }
359
360    #[must_use]
361    pub const fn global(&self) -> &Schedule {
362        &self.global
363    }
364}
365
366fn parse_schedule(active_hours: &str, bandwidth_configs: &[BandwidthWindowConfig]) -> Result<Schedule> {
367    let bandwidth = bandwidth_configs
368        .iter()
369        .map(|window| {
370            let hours = TimeWindow::parse(&window.hours)?.ok_or_else(|| {
371                SyncwebError::InvalidConfig("bandwidth windows must specify a non-empty hours interval".to_owned())
372            })?;
373            Ok(BandwidthWindow {
374                hours,
375                max_upload: parse_rate(&window.max_upload)?,
376                max_download: parse_rate(&window.max_download)?,
377            })
378        })
379        .collect::<Result<Vec<_>>>()?;
380    Ok(Schedule {
381        active_hours: TimeWindow::parse(active_hours)?,
382        bandwidth,
383    })
384}
385
386pub(crate) fn current_minute() -> u16 {
387    let seconds = SystemTime::now()
388        .duration_since(UNIX_EPOCH)
389        .map_or(0, |duration| duration.as_secs());
390    let day_seconds = seconds % 86_400;
391    u16::try_from(day_seconds.div_euclid(60)).unwrap_or(0)
392}