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#[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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73#[non_exhaustive]
74pub struct TimeWindow {
75 start: u16,
76 end: u16,
77}
78
79impl TimeWindow {
80 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
156pub 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#[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#[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#[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#[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#[derive(Clone, Debug)]
286#[non_exhaustive]
287pub struct ScheduleManager {
288 global: Schedule,
289 folders: BTreeMap<String, FolderSchedule>,
290}
291
292impl ScheduleManager {
293 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 #[must_use]
340 pub fn is_active(&self, folder: Option<&str>) -> bool {
341 self.is_active_at(folder, current_minute())
342 }
343
344 #[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 #[must_use]
357 pub fn current_limits(&self, folder: Option<&str>) -> BandwidthLimits {
358 self.current_limits_at(folder, current_minute())
359 }
360
361 #[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 #[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}