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 folders: BTreeMap<String, ScheduleFolderConfig>,
19}
20
21#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub struct TimeWindow {
33 start: u16,
34 end: u16,
35}
36
37impl TimeWindow {
38 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
114pub 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#[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#[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#[derive(Clone, Debug)]
222#[non_exhaustive]
223pub struct ScheduleManager {
224 global: Schedule,
225 folders: BTreeMap<String, FolderSchedule>,
226}
227
228impl ScheduleManager {
229 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 #[must_use]
260 pub fn is_active(&self, folder: Option<&str>) -> bool {
261 self.is_active_at(folder, current_minute())
262 }
263
264 #[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 #[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}