use chrono::{DateTime, Offset as _};
use chrono_tz::Tz;
use crate::routine::RoutineSchedule;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cadence {
pub phrase: String,
pub has_time_of_day: bool,
}
const WEEKDAYS: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
#[must_use]
pub fn cadence_text(schedule: &RoutineSchedule) -> Option<Cadence> {
match schedule {
RoutineSchedule::Once { .. } => Some(Cadence {
phrase: "once".to_owned(),
has_time_of_day: false,
}),
RoutineSchedule::Cron { expression, .. } => cron_cadence(expression),
}
}
fn cron_cadence(expression: &str) -> Option<Cadence> {
let fields: Vec<&str> = expression.split_ascii_whitespace().collect();
let [minute, hour, dom, month, dow] = fields[..] else {
return None;
};
if hour == "*" && dom == "*" && month == "*" && dow == "*" {
if let Some(step) = minute.strip_prefix("*/")
&& let Ok(n) = step.parse::<u32>()
&& (2..60).contains(&n)
{
return Some(Cadence {
phrase: format!("every {n} minutes"),
has_time_of_day: false,
});
}
if let Ok(m) = minute.parse::<u32>()
&& m < 60
{
return Some(Cadence {
phrase: format!("every hour, at :{m:02}"),
has_time_of_day: false,
});
}
return None;
}
let (minute, hour) = (minute.parse::<u32>().ok()?, hour.parse::<u32>().ok()?);
if minute >= 60 || hour >= 24 {
return None;
}
let at = format!("at {}", clock_time(hour, minute));
if month != "*" {
return None;
}
let phrase = match (dom, dow) {
("*", "*") => format!("every day {at}"),
("*", dow) => format!("{} {at}", weekday_phrase(dow)?),
(dom, "*") => {
let day = dom.parse::<u32>().ok().filter(|d| (1..=31).contains(d))?;
format!("on the {} of every month {at}", ordinal(day))
}
_ => return None,
};
Some(Cadence {
phrase,
has_time_of_day: true,
})
}
fn weekday_phrase(dow: &str) -> Option<String> {
let mut days = parse_dow(dow)?;
days.sort_unstable();
days.dedup();
if days == [1, 2, 3, 4, 5] {
return Some("every weekday".to_owned());
}
if days.len() == 7 {
return Some("every day".to_owned());
}
let names: Vec<&str> = days.iter().map(|&d| WEEKDAYS[d as usize]).collect();
Some(format!("every {}", list_phrase(&names)))
}
fn parse_dow(dow: &str) -> Option<Vec<u8>> {
let mut out = Vec::new();
for atom in dow.split(',') {
match atom.split_once('-') {
Some((a, b)) => {
let (a, b) = (parse_day(a)?, parse_day(b)?);
if a > b {
return None;
}
out.extend(a..=b);
}
None => out.push(parse_day(atom)?),
}
}
Some(out)
}
fn parse_day(s: &str) -> Option<u8> {
match s.parse::<u8>().ok()? {
7 => Some(0),
d if d <= 6 => Some(d),
_ => None,
}
}
fn list_phrase(names: &[&str]) -> String {
match names {
[] => String::new(),
[one] => (*one).to_owned(),
[a, b] => format!("{a} and {b}"),
[rest @ .., last] => format!("{}, and {last}", rest.join(", ")),
}
}
fn clock_time(hour: u32, minute: u32) -> String {
let suffix = if hour < 12 { "AM" } else { "PM" };
let display = match hour % 12 {
0 => 12,
h => h,
};
format!("{display}:{minute:02} {suffix}")
}
fn ordinal(n: u32) -> String {
let suffix = match (n % 10, n % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
format!("{n}{suffix}")
}
#[must_use]
pub fn zone_label(at: DateTime<Tz>) -> String {
let name = at.timezone().name();
let offset = offset_label(at);
if name == "UTC" {
return "UTC".to_owned();
}
if name.starts_with("Etc/") {
return offset;
}
let abbreviation = at.format("%Z").to_string();
if abbreviation.starts_with(|c: char| c.is_ascii_alphabetic()) {
format!("{abbreviation} ({offset})")
} else {
format!("{} ({offset})", city_of(name))
}
}
fn city_of(zone_name: &str) -> String {
zone_name
.rsplit('/')
.next()
.unwrap_or(zone_name)
.replace('_', " ")
}
fn offset_label(at: DateTime<Tz>) -> String {
let seconds = at.offset().fix().local_minus_utc();
let sign = if seconds < 0 { '-' } else { '+' };
let (hours, minutes) = (seconds.abs() / 3600, (seconds.abs() % 3600) / 60);
if minutes == 0 {
format!("UTC{sign}{hours}")
} else {
format!("UTC{sign}{hours}:{minutes:02}")
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use chrono::TimeZone as _;
fn cron(expr: &str) -> RoutineSchedule {
RoutineSchedule::Cron {
expression: expr.to_owned(),
timezone: None,
}
}
fn phrase(expr: &str) -> Option<String> {
cadence_text(&cron(expr)).map(|c| c.phrase)
}
struct CadenceCase {
expression: &'static str,
phrase: Option<&'static str>,
has_time_of_day: bool,
}
#[test]
fn cadence_text_table() {
let cases = [
CadenceCase {
expression: "0 9 * * *",
phrase: Some("every day at 9:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 9 * * 1-5",
phrase: Some("every weekday at 9:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "30 14 * * 1",
phrase: Some("every Monday at 2:30 PM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 9 * * 1,3,5",
phrase: Some("every Monday, Wednesday, and Friday at 9:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 9 * * 0,6",
phrase: Some("every Sunday and Saturday at 9:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 9 15 * *",
phrase: Some("on the 15th of every month at 9:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 9 1 * *",
phrase: Some("on the 1st of every month at 9:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 0 * * *",
phrase: Some("every day at 12:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 12 * * *",
phrase: Some("every day at 12:00 PM"),
has_time_of_day: true,
},
CadenceCase {
expression: "*/15 * * * *",
phrase: Some("every 15 minutes"),
has_time_of_day: false,
},
CadenceCase {
expression: "5 * * * *",
phrase: Some("every hour, at :05"),
has_time_of_day: false,
},
CadenceCase {
expression: "0 9 * * 7",
phrase: Some("every Sunday at 9:00 AM"),
has_time_of_day: true,
},
CadenceCase {
expression: "0 9 * * 0-6",
phrase: Some("every day at 9:00 AM"),
has_time_of_day: true,
},
];
for case in cases {
let actual = cadence_text(&cron(case.expression));
assert_eq!(
actual.as_ref().map(|c| c.phrase.as_str()),
case.phrase,
"phrase for `{}`",
case.expression
);
assert_eq!(
actual.map(|c| c.has_time_of_day),
Some(case.has_time_of_day),
"has_time_of_day for `{}`",
case.expression
);
}
}
#[test]
fn undescribable_expressions_yield_no_cadence() {
for expression in [
"0 9 * * MON-FRI", "0 9 */2 * *", "0 9 1 1 *", "0 9 1 * 1", "0 9-17 * * *", "0 9 * *", "0 9 * * * *", "0 99 * * *", "0 9 * * 5-1", "*/1000 * * * *", "",
] {
assert_eq!(
phrase(expression),
None,
"`{expression}` must not be described"
);
}
}
#[test]
fn a_once_schedule_reads_as_once_and_pins_no_clock_time() {
let schedule = RoutineSchedule::Once {
at: "2026-07-29T09:00:00Z".to_owned(),
};
let cadence = cadence_text(&schedule).expect("once is always describable");
assert_eq!(cadence.phrase, "once");
assert!(!cadence.has_time_of_day);
}
struct ZoneCase {
zone: &'static str,
month: u32,
label: &'static str,
}
#[test]
fn zone_label_table() {
let cases = [
ZoneCase {
zone: "America/Los_Angeles",
month: 7,
label: "PDT (UTC-7)",
},
ZoneCase {
zone: "America/Los_Angeles",
month: 1,
label: "PST (UTC-8)",
},
ZoneCase {
zone: "Australia/Sydney",
month: 7,
label: "AEST (UTC+10)",
},
ZoneCase {
zone: "Australia/Sydney",
month: 1,
label: "AEDT (UTC+11)",
},
ZoneCase {
zone: "Asia/Kolkata",
month: 7,
label: "IST (UTC+5:30)",
},
ZoneCase {
zone: "Asia/Katmandu",
month: 7,
label: "Katmandu (UTC+5:45)",
},
ZoneCase {
zone: "Asia/Shanghai",
month: 7,
label: "CST (UTC+8)",
},
ZoneCase {
zone: "Asia/Dubai",
month: 7,
label: "Dubai (UTC+4)",
},
ZoneCase {
zone: "Asia/Singapore",
month: 7,
label: "Singapore (UTC+8)",
},
ZoneCase {
zone: "Asia/Ho_Chi_Minh",
month: 7,
label: "Ho Chi Minh (UTC+7)",
},
ZoneCase {
zone: "America/Sao_Paulo",
month: 7,
label: "Sao Paulo (UTC-3)",
},
ZoneCase {
zone: "America/Argentina/Buenos_Aires",
month: 7,
label: "Buenos Aires (UTC-3)",
},
ZoneCase {
zone: "UTC",
month: 7,
label: "UTC",
},
];
for case in cases {
let zone: Tz = case.zone.parse().expect("known zone");
let at = zone
.with_ymd_and_hms(2026, case.month, 15, 9, 0, 0)
.single()
.expect("unambiguous instant");
assert_eq!(zone_label(at), case.label, "label for {}", case.zone);
}
}
#[test]
fn etc_zones_render_as_the_offset_alone() {
let zone: Tz = "Etc/GMT+4".parse().expect("known zone");
let at = zone
.with_ymd_and_hms(2026, 7, 15, 9, 0, 0)
.single()
.expect("unambiguous instant");
assert_eq!(zone_label(at), "UTC-4");
}
}