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 true;
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>> {
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#[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#[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#[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#[derive(Clone, Debug)]
242#[non_exhaustive]
243pub struct ScheduleManager {
244 global: Schedule,
245 folders: BTreeMap<String, FolderSchedule>,
246}
247
248impl ScheduleManager {
249 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 #[must_use]
296 pub fn is_active(&self, folder: Option<&str>) -> bool {
297 self.is_active_at(folder, current_minute())
298 }
299
300 #[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 #[must_use]
313 pub fn current_limits(&self, folder: Option<&str>) -> BandwidthLimits {
314 self.current_limits_at(folder, current_minute())
315 }
316
317 #[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 #[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
386fn 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}