use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;
#[derive(Debug, thiserror::Error)]
pub enum ResetParseError {
#[error("empty resets string")]
Empty,
#[error("unrecognised resets format: {0:?}")]
UnrecognisedFormat(String),
#[error("unknown timezone: {0:?}")]
UnknownTimezone(String),
#[error("date arithmetic overflow")]
Overflow,
}
pub fn resets_to_epoch(resets: &str) -> Result<DateTime<Utc>, ResetParseError> {
resets_to_epoch_at(resets, Utc::now())
}
pub fn resets_to_epoch_at(
resets: &str,
now: DateTime<Utc>,
) -> Result<DateTime<Utc>, ResetParseError> {
let s = resets.trim();
if s.is_empty() {
return Err(ResetParseError::Empty);
}
let (body, tz): (&str, Tz) = extract_tz(s)?;
let body = body.replace(" at ", " ");
let body = body.trim().to_string();
if body.is_empty() {
return Err(ResetParseError::UnrecognisedFormat(resets.to_string()));
}
let now_in_tz = now.with_timezone(&tz);
let today = now_in_tz.date_naive();
let body = if is_bare_time(&body) {
let prefix = format_month_day(today);
format!("{} {}", prefix, body)
} else {
body
};
let body = inject_minutes(&body);
let year = now_in_tz.year();
let dt = parse_body_with_year(&body, year, tz).ok_or_else(|| {
ResetParseError::UnrecognisedFormat(format!("{} (from {:?})", body, resets))
})?;
let dt_utc = dt.with_timezone(&Utc);
if dt_utc < now {
let dt_next = parse_body_with_year(&body, year + 1, tz).ok_or(ResetParseError::Overflow)?;
return Ok(dt_next.with_timezone(&Utc));
}
Ok(dt_utc)
}
fn extract_tz(s: &str) -> Result<(&str, Tz), ResetParseError> {
if let Some(paren_start) = s.find(" (") {
let body = s[..paren_start].trim();
let rest = &s[paren_start + 2..]; let tz_str = rest.trim_end_matches(')').trim();
let tz: Tz = tz_str
.parse()
.map_err(|_| ResetParseError::UnknownTimezone(tz_str.to_string()))?;
Ok((body, tz))
} else {
Ok((s, chrono_tz::UTC))
}
}
fn is_bare_time(s: &str) -> bool {
let s_lower = s.to_lowercase();
let (digits_part, _ampm) = if let Some(stem) = s_lower
.strip_suffix("am")
.or_else(|| s_lower.strip_suffix("pm"))
{
(stem, ())
} else {
return false;
};
if let Some(colon_pos) = digits_part.find(':') {
let hour = &digits_part[..colon_pos];
let min = &digits_part[colon_pos + 1..];
!hour.is_empty()
&& hour.len() <= 2
&& hour.chars().all(|c| c.is_ascii_digit())
&& min.len() == 2
&& min.chars().all(|c| c.is_ascii_digit())
} else {
!digits_part.is_empty()
&& digits_part.len() <= 2
&& digits_part.chars().all(|c| c.is_ascii_digit())
}
}
fn format_month_day(date: NaiveDate) -> String {
date.format("%b %d").to_string()
}
fn inject_minutes(s: &str) -> String {
let lower = s.to_lowercase();
let bytes = lower.as_bytes();
let mut result = String::with_capacity(s.len() + 4);
let mut i = 0usize;
while i < bytes.len() {
if let Some((end, ampm)) = try_bare_hour_at(bytes, i) {
let preceded_by_safe = if i == 0 {
true
} else {
let prev = bytes[i - 1];
prev != b':' && !prev.is_ascii_digit()
};
if preceded_by_safe {
let digits = &lower[i..end - 2]; result.push_str(digits);
result.push_str(":00");
result.push_str(ampm);
i = end;
continue;
}
}
result.push(lower.chars().nth(i).unwrap_or(bytes[i] as char));
i += 1;
}
result
}
fn try_bare_hour_at(bytes: &[u8], start: usize) -> Option<(usize, &'static str)> {
let mut pos = start;
if pos >= bytes.len() || !bytes[pos].is_ascii_digit() {
return None;
}
pos += 1;
if pos < bytes.len() && bytes[pos].is_ascii_digit() {
pos += 1;
}
if bytes.get(pos..pos + 2) == Some(b"am") {
Some((pos + 2, "am"))
} else if bytes.get(pos..pos + 2) == Some(b"pm") {
Some((pos + 2, "pm"))
} else {
None
}
}
fn parse_body_with_year(body: &str, year: i32, tz: Tz) -> Option<DateTime<Tz>> {
let tokens: Vec<&str> = body.split_whitespace().collect();
if tokens.len() != 3 {
return None;
}
let month_str = tokens[0];
let day_str = tokens[1];
let time_str = tokens[2];
let month = parse_month(month_str)?;
let day: u32 = day_str.parse().ok()?;
let naive_time = parse_12h_time(time_str)?;
let naive_date = NaiveDate::from_ymd_opt(year, month, day)?;
let naive_dt = NaiveDateTime::new(naive_date, naive_time);
tz.from_local_datetime(&naive_dt).single()
}
fn parse_month(s: &str) -> Option<u32> {
match s.to_lowercase().as_str() {
"jan" => Some(1),
"feb" => Some(2),
"mar" => Some(3),
"apr" => Some(4),
"may" => Some(5),
"jun" => Some(6),
"jul" => Some(7),
"aug" => Some(8),
"sep" => Some(9),
"oct" => Some(10),
"nov" => Some(11),
"dec" => Some(12),
_ => None,
}
}
fn parse_12h_time(s: &str) -> Option<NaiveTime> {
let (stem, is_pm) = if let Some(stem) = s.strip_suffix("pm") {
(stem, true)
} else if let Some(stem) = s.strip_suffix("am") {
(stem, false)
} else {
return None;
};
let colon = stem.find(':')?;
let hour_str = &stem[..colon];
let min_str = &stem[colon + 1..];
let hour: u32 = hour_str.parse().ok()?;
let min: u32 = min_str.parse().ok()?;
let hour_24 = match (is_pm, hour) {
(false, 12) => 0, (false, h) => h,
(true, 12) => 12, (true, h) => h + 12,
};
NaiveTime::from_hms_opt(hour_24, min, 0)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 17, 12, 0, 0).unwrap()
}
fn assert_epoch(input: &str, expected_utc: DateTime<Utc>) {
let result = resets_to_epoch_at(input, fixed_now())
.unwrap_or_else(|e| panic!("parse failed for {:?}: {e}", input));
assert_eq!(
result, expected_utc,
"input={:?}: expected {} got {}",
input, expected_utc, result
);
}
#[test]
fn empty_string_is_err() {
let err = resets_to_epoch_at("", fixed_now());
assert!(matches!(err, Err(ResetParseError::Empty)), "{:?}", err);
}
#[test]
fn whitespace_only_is_err() {
let err = resets_to_epoch_at(" ", fixed_now());
assert!(matches!(err, Err(ResetParseError::Empty)), "{:?}", err);
}
#[test]
fn bare_hour_pm_with_tz_same_day() {
let expected = Utc.with_ymd_and_hms(2026, 6, 17, 12, 0, 0).unwrap();
assert_epoch("9pm (Asia/Seoul)", expected);
}
#[test]
fn bare_hour_already_past_rolls_to_next_year() {
let expected = Utc.with_ymd_and_hms(2027, 6, 17, 11, 0, 0).unwrap();
assert_epoch("8pm (Asia/Seoul)", expected);
}
#[test]
fn time_with_minutes_rolls_to_next_year() {
let expected = Utc.with_ymd_and_hms(2027, 6, 17, 11, 20, 0).unwrap();
assert_epoch("8:20pm (Asia/Seoul)", expected);
}
#[test]
fn bare_time_with_minutes_past_rolls_to_next_year() {
let expected = Utc.with_ymd_and_hms(2027, 6, 17, 9, 50, 0).unwrap();
assert_epoch("6:50pm (Asia/Seoul)", expected);
}
#[test]
fn late_evening_kst_is_future() {
let expected = Utc.with_ymd_and_hms(2026, 6, 17, 14, 0, 0).unwrap();
assert_epoch("11pm (Asia/Seoul)", expected);
}
#[test]
fn explicit_date_future() {
let expected = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap();
assert_epoch("Jun 18 at 9pm (Asia/Seoul)", expected);
}
#[test]
fn explicit_date_past_rolls_next_year() {
let expected = Utc.with_ymd_and_hms(2027, 6, 4, 12, 0, 0).unwrap();
assert_epoch("Jun 4 at 9pm (Asia/Seoul)", expected);
}
#[test]
fn explicit_date_with_minutes_past_rolls_next_year() {
let expected = Utc.with_ymd_and_hms(2027, 6, 3, 8, 59, 0).unwrap();
assert_epoch("Jun 3 at 5:59pm (Asia/Seoul)", expected);
}
#[test]
fn explicit_date_december_future_same_year() {
let expected = Utc.with_ymd_and_hms(2026, 12, 31, 12, 0, 0).unwrap();
assert_epoch("Dec 31 at 9pm (Asia/Seoul)", expected);
}
#[test]
fn explicit_date_january_past_rolls_next_year() {
let expected = Utc.with_ymd_and_hms(2027, 1, 1, 12, 0, 0).unwrap();
assert_epoch("Jan 1 at 9pm (Asia/Seoul)", expected);
}
#[test]
fn midnight_12am() {
let expected = Utc.with_ymd_and_hms(2027, 6, 17, 0, 0, 0).unwrap();
assert_epoch("12am", expected);
}
#[test]
fn noon_12pm_utc_not_past() {
let expected = Utc.with_ymd_and_hms(2026, 6, 17, 12, 0, 0).unwrap();
assert_epoch("12pm", expected);
}
#[test]
fn early_morning_kst_rolls_to_next_year() {
let expected = Utc.with_ymd_and_hms(2027, 6, 16, 16, 0, 0).unwrap();
assert_epoch("1am (Asia/Seoul)", expected);
}
#[test]
fn new_york_timezone() {
let expected = Utc.with_ymd_and_hms(2026, 6, 18, 1, 0, 0).unwrap();
assert_epoch("9pm (America/New_York)", expected);
}
#[test]
fn explicit_utc_timezone() {
let expected = Utc.with_ymd_and_hms(2026, 6, 20, 21, 0, 0).unwrap();
assert_epoch("Jun 20 at 9pm (UTC)", expected);
}
#[test]
fn bare_time_no_tz_defaults_utc() {
let expected = Utc.with_ymd_and_hms(2026, 6, 17, 21, 0, 0).unwrap();
assert_epoch("9pm", expected);
}
#[test]
fn unknown_timezone_is_err() {
let err = resets_to_epoch_at("9pm (Fake/Timezone)", fixed_now());
assert!(
matches!(err, Err(ResetParseError::UnknownTimezone(_))),
"{:?}",
err
);
}
#[test]
fn is_bare_time_recognises_forms() {
assert!(is_bare_time("9pm"), "9pm");
assert!(is_bare_time("9am"), "9am");
assert!(is_bare_time("12pm"), "12pm");
assert!(is_bare_time("8:20pm"), "8:20pm");
assert!(is_bare_time("11:59am"), "11:59am");
assert!(!is_bare_time("Jun 4 9pm"), "should not match date+time");
assert!(!is_bare_time(""), "empty");
assert!(!is_bare_time("9"), "no ampm");
assert!(!is_bare_time("9:00"), "no ampm after colon");
}
#[test]
fn inject_minutes_bare_hour_leading() {
assert_eq!(inject_minutes("9pm"), "9:00pm");
assert_eq!(inject_minutes("12am"), "12:00am");
}
#[test]
fn inject_minutes_bare_hour_after_date() {
let result = inject_minutes("jun 17 9pm");
assert_eq!(result, "jun 17 9:00pm");
}
#[test]
fn inject_minutes_already_has_minutes() {
assert_eq!(inject_minutes("5:59pm"), "5:59pm");
assert_eq!(inject_minutes("jun 17 8:20pm"), "jun 17 8:20pm");
}
#[test]
fn parse_12h_time_variants() {
use chrono::Timelike;
let t = parse_12h_time("9:00pm").unwrap();
assert_eq!(t.hour(), 21);
assert_eq!(t.minute(), 0);
let t = parse_12h_time("12:00am").unwrap();
assert_eq!(t.hour(), 0);
let t = parse_12h_time("12:00pm").unwrap();
assert_eq!(t.hour(), 12);
let t = parse_12h_time("8:20am").unwrap();
assert_eq!(t.hour(), 8);
assert_eq!(t.minute(), 20);
let t = parse_12h_time("11:59pm").unwrap();
assert_eq!(t.hour(), 23);
assert_eq!(t.minute(), 59);
}
#[test]
fn parse_month_all_names() {
let expected = [
("jan", 1u32),
("feb", 2),
("mar", 3),
("apr", 4),
("may", 5),
("jun", 6),
("jul", 7),
("aug", 8),
("sep", 9),
("oct", 10),
("nov", 11),
("dec", 12),
];
for (name, num) in expected {
assert_eq!(parse_month(name), Some(num), "month={name}");
let title = format!("{}{}", &name[..1].to_uppercase(), &name[1..]);
assert_eq!(parse_month(&title), Some(num), "month={title}");
}
assert_eq!(parse_month("xyz"), None);
}
#[test]
fn spec_sample_jun18_9pm_seoul() {
let expected = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap();
assert_epoch("Jun 18 at 9pm (Asia/Seoul)", expected);
}
#[test]
fn spec_sample_jun20_820pm_seoul() {
let expected = Utc.with_ymd_and_hms(2026, 6, 20, 11, 20, 0).unwrap();
assert_epoch("Jun 20 at 8:20pm (Asia/Seoul)", expected);
}
#[test]
fn spec_sample_9pm_seoul_not_past() {
let expected = Utc.with_ymd_and_hms(2026, 6, 17, 12, 0, 0).unwrap();
assert_epoch("9pm (Asia/Seoul)", expected);
}
#[test]
fn january_now_june_reset_no_rollover() {
let now_jan = Utc.with_ymd_and_hms(2026, 1, 5, 12, 0, 0).unwrap();
let result = resets_to_epoch_at("Jun 4 at 9pm (Asia/Seoul)", now_jan).unwrap();
let expected = Utc.with_ymd_and_hms(2026, 6, 4, 12, 0, 0).unwrap();
assert_eq!(result, expected);
}
#[test]
fn december_now_june_reset_rolls_next_year() {
let now_dec = Utc.with_ymd_and_hms(2026, 12, 1, 12, 0, 0).unwrap();
let result = resets_to_epoch_at("Jun 4 at 9pm (Asia/Seoul)", now_dec).unwrap();
let expected = Utc.with_ymd_and_hms(2027, 6, 4, 12, 0, 0).unwrap();
assert_eq!(result, expected);
}
}