pub use crate::constants::defaults::FALLBACK_RFC3339;
use anyhow::{Context, Result, bail};
use std::sync::OnceLock;
use time::format_description::FormatItem;
use time::format_description::well_known::Rfc3339;
use time::{OffsetDateTime, UtcOffset};
fn fixed_rfc3339_format() -> &'static [FormatItem<'static>] {
static FORMAT: OnceLock<Vec<FormatItem<'static>>> = OnceLock::new();
FORMAT
.get_or_init(|| {
time::format_description::parse(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9]Z",
)
.expect("compile-time RFC3339 format string is valid")
})
.as_slice()
}
pub fn now_utc_rfc3339() -> Result<String> {
OffsetDateTime::now_utc()
.format(fixed_rfc3339_format())
.context("format RFC3339 timestamp")
}
pub fn parse_rfc3339(ts: &str) -> Result<OffsetDateTime> {
let trimmed = ts.trim();
if trimmed.is_empty() {
bail!("timestamp is empty");
}
OffsetDateTime::parse(trimmed, &Rfc3339)
.with_context(|| format!("parse RFC3339 timestamp '{}'", trimmed))
}
pub fn parse_rfc3339_opt(ts: &str) -> Option<OffsetDateTime> {
let trimmed = ts.trim();
if trimmed.is_empty() {
return None;
}
parse_rfc3339(trimmed).ok()
}
pub fn format_rfc3339(dt: OffsetDateTime) -> Result<String> {
dt.to_offset(UtcOffset::UTC)
.format(fixed_rfc3339_format())
.context("format RFC3339 timestamp")
}
fn now_utc_rfc3339_or_fallback_impl<NowFn, OnErr>(now_fn: NowFn, on_err: OnErr) -> String
where
NowFn: FnOnce() -> anyhow::Result<String>,
OnErr: FnOnce(&anyhow::Error),
{
match now_fn() {
Ok(ts) => ts,
Err(ref err) => {
on_err(err);
FALLBACK_RFC3339.to_string()
}
}
}
pub fn now_utc_rfc3339_or_fallback() -> String {
now_utc_rfc3339_or_fallback_impl(now_utc_rfc3339, |err| {
log::error!(
"format RFC3339 timestamp failed; using FALLBACK_RFC3339='{}': {:#}",
FALLBACK_RFC3339,
err
);
})
}
pub fn parse_relative_time(expression: &str) -> Result<String> {
let trimmed = expression.trim();
if let Ok(dt) = parse_rfc3339(trimmed) {
return format_rfc3339(dt);
}
let lower = trimmed.to_lowercase();
let now = OffsetDateTime::now_utc();
if lower.starts_with("tomorrow") {
let tomorrow = now + time::Duration::days(1);
let time_part = lower.strip_prefix("tomorrow").unwrap_or("").trim();
let time = parse_time_expression(time_part).unwrap_or((9, 0));
let result = tomorrow
.replace_hour(time.0)
.map_err(|e| anyhow::anyhow!("Invalid hour: {}", e))?
.replace_minute(time.1)
.map_err(|e| anyhow::anyhow!("Invalid minute: {}", e))?;
return format_rfc3339(result);
}
if let Some(rest) = lower.strip_prefix("in ") {
return parse_in_expression(now, rest);
}
if let Some(rest) = lower.strip_prefix("next ") {
return parse_next_weekday(now, rest);
}
bail!(
"Unable to parse time expression: '{}'. Supported formats:\n - RFC3339: 2026-02-01T09:00:00Z\n - Relative: 'tomorrow 9am', 'in 2 hours', 'next monday'",
expression
)
}
fn parse_time_expression(expr: &str) -> Option<(u8, u8)> {
let expr = expr.trim();
if expr.is_empty() {
return None;
}
let expr = expr.replace(' ', "");
let is_pm = expr.ends_with("pm");
let is_am = expr.ends_with("am");
let num_part = if is_pm || is_am {
&expr[..expr.len() - 2]
} else {
&expr
};
let parts: Vec<&str> = num_part.split(':').collect();
let hour: u8 = parts[0].parse().ok()?;
let minute: u8 = parts.get(1).and_then(|m| m.parse().ok()).unwrap_or(0);
let hour_24 = if is_pm && hour != 12 {
hour + 12
} else if is_am && hour == 12 {
0
} else {
hour
};
if hour_24 > 23 || minute > 59 {
return None;
}
Some((hour_24, minute))
}
fn parse_in_expression(now: OffsetDateTime, expr: &str) -> Result<String> {
let expr = expr.trim();
let parts: Vec<&str> = expr.split_whitespace().collect();
if parts.len() < 2 {
bail!("Invalid 'in' expression: expected 'in N hours/minutes/days/weeks'");
}
let num: i64 = parts[0]
.parse()
.map_err(|_| anyhow::anyhow!("Invalid number in 'in' expression: '{}'", parts[0]))?;
let unit = parts[1].to_lowercase();
let unit = unit.trim_end_matches('s');
let duration = match unit {
"minute" => time::Duration::minutes(num),
"hour" => time::Duration::hours(num),
"day" => time::Duration::days(num),
"week" => time::Duration::weeks(num),
_ => bail!(
"Unknown time unit: '{}'. Use minutes, hours, days, or weeks.",
unit
),
};
let result = now + duration;
format_rfc3339(result)
}
fn parse_next_weekday(now: OffsetDateTime, expr: &str) -> Result<String> {
let weekdays = [
("sunday", time::Weekday::Sunday),
("monday", time::Weekday::Monday),
("tuesday", time::Weekday::Tuesday),
("wednesday", time::Weekday::Wednesday),
("thursday", time::Weekday::Thursday),
("friday", time::Weekday::Friday),
("saturday", time::Weekday::Saturday),
];
let expr = expr.trim().to_lowercase();
let target_weekday = weekdays
.iter()
.find(|(name, _)| expr.starts_with(name))
.map(|(_, wd)| *wd)
.ok_or_else(|| anyhow::anyhow!("Unknown weekday: '{}'", expr))?;
let current_weekday = now.weekday();
let days_until = days_until_weekday(current_weekday, target_weekday);
let result = now + time::Duration::days(days_until);
let result = result
.replace_hour(9)
.map_err(|e| anyhow::anyhow!("Invalid hour: {}", e))?
.replace_minute(0)
.map_err(|e| anyhow::anyhow!("Invalid minute: {}", e))?;
format_rfc3339(result)
}
fn days_until_weekday(current: time::Weekday, target: time::Weekday) -> i64 {
let current_num = current as i64;
let target_num = target as i64;
if target_num > current_num {
target_num - current_num
} else {
7 - (current_num - target_num)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_relative_time_rfc3339() {
let result = parse_relative_time("2026-02-01T09:00:00Z").unwrap();
assert!(result.contains("2026-02-01T09:00:00"));
}
#[test]
fn test_parse_relative_time_tomorrow() {
let result = parse_relative_time("tomorrow 9am").unwrap();
let tomorrow = OffsetDateTime::now_utc() + time::Duration::days(1);
assert!(result.contains(&tomorrow.year().to_string()));
}
#[test]
fn test_parse_relative_time_in_hours() {
let result = parse_relative_time("in 2 hours").unwrap();
let now = OffsetDateTime::now_utc();
let parsed = parse_rfc3339(&result).unwrap();
let diff = parsed - now;
assert!(
diff.whole_hours() >= 1 && diff.whole_hours() <= 3,
"Expected ~2 hours, got {} hours",
diff.whole_hours()
);
}
#[test]
fn test_parse_relative_time_in_days() {
let result = parse_relative_time("in 3 days").unwrap();
let now = OffsetDateTime::now_utc();
let parsed = parse_rfc3339(&result).unwrap();
let diff = parsed - now;
assert!(
diff.whole_days() >= 2 && diff.whole_days() <= 4,
"Expected ~3 days, got {} days",
diff.whole_days()
);
}
#[test]
fn test_parse_relative_time_next_weekday() {
let result = parse_relative_time("next monday").unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_parse_relative_time_invalid() {
let result = parse_relative_time("invalid expression");
assert!(result.is_err());
}
#[test]
fn test_parse_time_expression_am() {
assert_eq!(parse_time_expression("9am"), Some((9, 0)));
assert_eq!(parse_time_expression("12am"), Some((0, 0)));
}
#[test]
fn test_parse_time_expression_pm() {
assert_eq!(parse_time_expression("2pm"), Some((14, 0)));
assert_eq!(parse_time_expression("12pm"), Some((12, 0)));
}
#[test]
fn test_parse_time_expression_with_minutes() {
assert_eq!(parse_time_expression("9:30am"), Some((9, 30)));
assert_eq!(parse_time_expression("2:45pm"), Some((14, 45)));
}
#[test]
fn test_parse_time_expression_24h() {
assert_eq!(parse_time_expression("14:30"), Some((14, 30)));
assert_eq!(parse_time_expression("09:00"), Some((9, 0)));
}
#[test]
fn test_parse_time_expression_invalid() {
assert_eq!(parse_time_expression(""), None);
assert_eq!(parse_time_expression("invalid"), None);
}
#[test]
fn test_days_until_weekday() {
use time::Weekday;
assert_eq!(days_until_weekday(Weekday::Monday, Weekday::Monday), 7);
assert_eq!(days_until_weekday(Weekday::Monday, Weekday::Tuesday), 1);
assert_eq!(days_until_weekday(Weekday::Friday, Weekday::Monday), 3);
}
#[test]
fn now_utc_rfc3339_or_fallback_impl_ok_does_not_call_hook() {
let called = std::cell::Cell::new(false);
let out = now_utc_rfc3339_or_fallback_impl(
|| Ok("2026-02-07T00:00:00.000000000Z".to_string()),
|_| called.set(true),
);
assert!(!called.get());
assert_eq!(out, "2026-02-07T00:00:00.000000000Z");
}
#[test]
fn now_utc_rfc3339_or_fallback_impl_err_calls_hook_and_returns_sentinel() {
let called = std::cell::Cell::new(false);
let out =
now_utc_rfc3339_or_fallback_impl(|| Err(anyhow::anyhow!("boom")), |_| called.set(true));
assert!(called.get());
assert_eq!(out, FALLBACK_RFC3339);
parse_rfc3339(&out).expect("sentinel must parse");
}
#[test]
fn fallback_rfc3339_is_unix_epoch() {
assert_eq!(FALLBACK_RFC3339, "1970-01-01T00:00:00.000000000Z");
let dt = parse_rfc3339(FALLBACK_RFC3339).unwrap();
assert_eq!(dt.year(), 1970);
assert_eq!(dt.month() as u8, 1);
assert_eq!(dt.day(), 1);
assert_eq!(dt.hour(), 0);
assert_eq!(dt.minute(), 0);
assert_eq!(dt.second(), 0);
}
}