use crate::error::ApiError;
pub const MIN_INTERVAL_SECS: u64 = 300;
pub const MAX_INTERVAL_SECS: u64 = 366 * DAY;
const MINUTE: u64 = 60;
const HOUR: u64 = 3600;
const DAY: u64 = 86400;
const WEEK: u64 = 7 * DAY;
pub fn cron_period(cron: &str) -> Result<u64, ApiError> {
let f: Vec<&str> = cron.split_whitespace().collect();
let bad = || {
ApiError::invalid(format!(
"schedule.cron {cron:?}: only evenly spaced schedules are supported (*/N * * * *, M * * * *, M */N * * *, M H * * *, M H * * D)"
))
};
if f.len() != 5 {
return Err(bad());
}
let num = |s: &str| s.parse::<u64>().ok();
let every = |s: &str| s.strip_prefix("*/").and_then(num);
let rest_any = f[2] == "*" && f[3] == "*";
match (f[0], f[1], rest_any, f[4]) {
(m, "*", true, "*") if every(m).is_some() => {
Ok(every(m).unwrap_or(1).saturating_mul(MINUTE))
}
("*", "*", true, "*") => Ok(MINUTE),
(m, "*", true, "*") if num(m).is_some() => Ok(HOUR),
(m, h, true, "*") if num(m).is_some() && every(h).is_some() => {
Ok(every(h).unwrap_or(1).saturating_mul(HOUR))
}
(m, h, true, "*") if num(m).is_some() && num(h).is_some() => Ok(DAY),
(m, h, true, d) if num(m).is_some() && num(h).is_some() && num(d).is_some() => Ok(WEEK),
_ => Err(bad()),
}
}
pub fn text_period(text: &str) -> Result<u64, ApiError> {
let t = text.trim().to_ascii_lowercase();
let words: Vec<&str> = t.split_whitespace().collect();
let n = |i: usize| words.get(i).and_then(|w| w.parse::<u64>().ok());
let period = match words.as_slice() {
["hourly", ..] => Some(HOUR),
["daily", ..] => Some(DAY),
["weekly", ..] => Some(WEEK),
["every", "minute", ..] => Some(MINUTE),
["every", "hour", ..] => Some(HOUR),
["every", "day", ..] => Some(DAY),
["every", "week", ..] => Some(WEEK),
["every", _, unit, ..] => n(1).and_then(|k| {
let u = unit.trim_end_matches('s');
match u {
"minute" | "min" => Some(k.saturating_mul(MINUTE)),
"hour" => Some(k.saturating_mul(HOUR)),
"day" => Some(k.saturating_mul(DAY)),
"week" => Some(k.saturating_mul(WEEK)),
_ => None,
}
}),
_ => None,
};
period.ok_or_else(|| {
ApiError::invalid(format!(
"schedule.text {text:?}: use every N minutes, hourly, every N hours, daily, daily at …, or weekly"
))
})
}
pub fn unsupported(s: &super::Schedule, period: Option<u64>) -> Vec<String> {
let mut out = Vec::new();
let Some(period) = period else {
return out;
};
let normalized = normalized_cron(period);
if let Some(c) = &s.cron
&& c.split_whitespace().collect::<Vec<_>>().join(" ") != normalized
{
out.push(format!(
"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"
));
}
if let Some(t) = s.text.as_deref().filter(|_| s.cron.is_none())
&& t.to_ascii_lowercase().contains(" at ")
{
out.push(format!(
"monitor_time_ignored: {t:?} runs every {period} s from the moment the monitor was created; the time is not applied"
));
}
if let Some(z) = s
.timezone
.as_deref()
.filter(|z| !z.eq_ignore_ascii_case("UTC"))
{
out.push(format!(
"monitor_timezone_ignored: timezone {z:?} is not applied; schedules are computed in UTC"
));
}
out
}
pub fn normalized_cron(period: u64) -> String {
match period {
p if p % WEEK == 0 => "0 0 * * 0".into(),
p if p % DAY == 0 => "0 0 * * *".into(),
p if p % HOUR == 0 && p / HOUR > 1 => format!("0 */{} * * *", p / HOUR),
p if p == HOUR => "0 * * * *".into(),
p => format!("*/{} * * * *", (p / MINUTE).max(1)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn periods() {
assert_eq!(cron_period("*/30 * * * *").unwrap(), 1800);
assert_eq!(cron_period("7 * * * *").unwrap(), 3600);
assert_eq!(cron_period("0 */2 * * *").unwrap(), 7200);
assert_eq!(cron_period("30 9 * * *").unwrap(), 86400);
assert!(cron_period("0 9,17 * * *").is_err());
assert_eq!(
text_period("every 15 minutes starting at :07").unwrap(),
900
);
assert_eq!(text_period("daily at 9am").unwrap(), 86400);
assert_eq!(text_period("every 2 hours").unwrap(), 7200);
assert!(text_period("sometimes").is_err());
assert_eq!(normalized_cron(1800), "*/30 * * * *");
assert_eq!(normalized_cron(7200), "0 */2 * * *");
}
}