pagesift_api/monitor/
schedule.rs1use crate::error::ApiError;
7
8pub const MIN_INTERVAL_SECS: u64 = 300;
10pub const MAX_INTERVAL_SECS: u64 = 366 * DAY;
12
13const MINUTE: u64 = 60;
14const HOUR: u64 = 3600;
15const DAY: u64 = 86400;
16const WEEK: u64 = 7 * DAY;
18
19pub fn cron_period(cron: &str) -> Result<u64, ApiError> {
22 let f: Vec<&str> = cron.split_whitespace().collect();
23 let bad = || {
24 ApiError::invalid(format!(
25 "schedule.cron {cron:?}: only evenly spaced schedules are supported (*/N * * * *, M * * * *, M */N * * *, M H * * *, M H * * D)"
26 ))
27 };
28 if f.len() != 5 {
30 return Err(bad());
31 }
32 let num = |s: &str| s.parse::<u64>().ok();
33 let every = |s: &str| s.strip_prefix("*/").and_then(num);
34 let rest_any = f[2] == "*" && f[3] == "*";
35 match (f[0], f[1], rest_any, f[4]) {
36 (m, "*", true, "*") if every(m).is_some() => {
37 Ok(every(m).unwrap_or(1).saturating_mul(MINUTE))
38 }
39 ("*", "*", true, "*") => Ok(MINUTE),
40 (m, "*", true, "*") if num(m).is_some() => Ok(HOUR),
41 (m, h, true, "*") if num(m).is_some() && every(h).is_some() => {
42 Ok(every(h).unwrap_or(1).saturating_mul(HOUR))
43 }
44 (m, h, true, "*") if num(m).is_some() && num(h).is_some() => Ok(DAY),
45 (m, h, true, d) if num(m).is_some() && num(h).is_some() && num(d).is_some() => Ok(WEEK),
46 _ => Err(bad()),
47 }
48}
49
50pub fn text_period(text: &str) -> Result<u64, ApiError> {
53 let t = text.trim().to_ascii_lowercase();
54 let words: Vec<&str> = t.split_whitespace().collect();
55 let n = |i: usize| words.get(i).and_then(|w| w.parse::<u64>().ok());
56 let period = match words.as_slice() {
57 ["hourly", ..] => Some(HOUR),
58 ["daily", ..] => Some(DAY),
59 ["weekly", ..] => Some(WEEK),
60 ["every", "minute", ..] => Some(MINUTE),
61 ["every", "hour", ..] => Some(HOUR),
62 ["every", "day", ..] => Some(DAY),
63 ["every", "week", ..] => Some(WEEK),
64 ["every", _, unit, ..] => n(1).and_then(|k| {
65 let u = unit.trim_end_matches('s');
66 match u {
67 "minute" | "min" => Some(k.saturating_mul(MINUTE)),
68 "hour" => Some(k.saturating_mul(HOUR)),
69 "day" => Some(k.saturating_mul(DAY)),
70 "week" => Some(k.saturating_mul(WEEK)),
71 _ => None,
72 }
73 }),
74 _ => None,
75 };
76 period.ok_or_else(|| {
77 ApiError::invalid(format!(
78 "schedule.text {text:?}: use every N minutes, hourly, every N hours, daily, daily at …, or weekly"
79 ))
80 })
81}
82
83pub fn unsupported(s: &super::Schedule, period: Option<u64>) -> Vec<String> {
85 let mut out = Vec::new();
86 let Some(period) = period else {
87 return out;
88 };
89 let normalized = normalized_cron(period);
90 if let Some(c) = &s.cron
91 && c.split_whitespace().collect::<Vec<_>>().join(" ") != normalized
92 {
93 out.push(format!(
94 "monitor_time_ignored: cron {c:?} runs every {period} s from the moment the monitor was created; the time of day and weekday are not applied"
95 ));
96 }
97 if let Some(t) = s.text.as_deref().filter(|_| s.cron.is_none())
99 && t.to_ascii_lowercase().contains(" at ")
100 {
101 out.push(format!(
102 "monitor_time_ignored: {t:?} runs every {period} s from the moment the monitor was created; the time is not applied"
103 ));
104 }
105 if let Some(z) = s
106 .timezone
107 .as_deref()
108 .filter(|z| !z.eq_ignore_ascii_case("UTC"))
109 {
110 out.push(format!(
111 "monitor_timezone_ignored: timezone {z:?} is not applied; schedules are computed in UTC"
112 ));
113 }
114 out
115}
116
117pub fn normalized_cron(period: u64) -> String {
119 match period {
120 p if p % WEEK == 0 => "0 0 * * 0".into(),
121 p if p % DAY == 0 => "0 0 * * *".into(),
122 p if p % HOUR == 0 && p / HOUR > 1 => format!("0 */{} * * *", p / HOUR),
123 p if p == HOUR => "0 * * * *".into(),
124 p => format!("*/{} * * * *", (p / MINUTE).max(1)),
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn periods() {
134 assert_eq!(cron_period("*/30 * * * *").unwrap(), 1800);
135 assert_eq!(cron_period("7 * * * *").unwrap(), 3600);
136 assert_eq!(cron_period("0 */2 * * *").unwrap(), 7200);
137 assert_eq!(cron_period("30 9 * * *").unwrap(), 86400);
138 assert!(cron_period("0 9,17 * * *").is_err());
139 assert_eq!(
140 text_period("every 15 minutes starting at :07").unwrap(),
141 900
142 );
143 assert_eq!(text_period("daily at 9am").unwrap(), 86400);
144 assert_eq!(text_period("every 2 hours").unwrap(), 7200);
145 assert!(text_period("sometimes").is_err());
146 assert_eq!(normalized_cron(1800), "*/30 * * * *");
147 assert_eq!(normalized_cron(7200), "0 */2 * * *");
148 }
149}