1pub use crate::constants::defaults::FALLBACK_RFC3339;
19
20use anyhow::{Context, Result, bail};
21use std::sync::OnceLock;
22use time::format_description::FormatItem;
23use time::format_description::well_known::Rfc3339;
24use time::{OffsetDateTime, UtcOffset};
25
26fn fixed_rfc3339_format() -> &'static [FormatItem<'static>] {
27 static FORMAT: OnceLock<Vec<FormatItem<'static>>> = OnceLock::new();
28 FORMAT
29 .get_or_init(|| {
30 time::format_description::parse(
33 "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9]Z",
34 )
35 .expect("compile-time RFC3339 format string is valid")
36 })
37 .as_slice()
38}
39
40pub fn now_utc_rfc3339() -> Result<String> {
41 OffsetDateTime::now_utc()
42 .format(fixed_rfc3339_format())
43 .context("format RFC3339 timestamp")
44}
45
46pub fn parse_rfc3339(ts: &str) -> Result<OffsetDateTime> {
47 let trimmed = ts.trim();
48 if trimmed.is_empty() {
49 bail!("timestamp is empty");
50 }
51 OffsetDateTime::parse(trimmed, &Rfc3339)
52 .with_context(|| format!("parse RFC3339 timestamp '{}'", trimmed))
53}
54
55pub fn parse_rfc3339_opt(ts: &str) -> Option<OffsetDateTime> {
56 let trimmed = ts.trim();
57 if trimmed.is_empty() {
58 return None;
59 }
60 parse_rfc3339(trimmed).ok()
61}
62
63pub fn format_rfc3339(dt: OffsetDateTime) -> Result<String> {
64 dt.to_offset(UtcOffset::UTC)
65 .format(fixed_rfc3339_format())
66 .context("format RFC3339 timestamp")
67}
68
69fn now_utc_rfc3339_or_fallback_impl<NowFn, OnErr>(now_fn: NowFn, on_err: OnErr) -> String
75where
76 NowFn: FnOnce() -> anyhow::Result<String>,
77 OnErr: FnOnce(&anyhow::Error),
78{
79 match now_fn() {
80 Ok(ts) => ts,
81 Err(ref err) => {
82 on_err(err);
83 FALLBACK_RFC3339.to_string()
84 }
85 }
86}
87
88pub fn now_utc_rfc3339_or_fallback() -> String {
93 now_utc_rfc3339_or_fallback_impl(now_utc_rfc3339, |err| {
94 log::error!(
95 "format RFC3339 timestamp failed; using FALLBACK_RFC3339='{}': {:#}",
96 FALLBACK_RFC3339,
97 err
98 );
99 })
100}
101
102pub fn parse_relative_time(expression: &str) -> Result<String> {
112 let trimmed = expression.trim();
113
114 if let Ok(dt) = parse_rfc3339(trimmed) {
116 return format_rfc3339(dt);
117 }
118
119 let lower = trimmed.to_lowercase();
121 let now = OffsetDateTime::now_utc();
122
123 if lower.starts_with("tomorrow") {
125 let tomorrow = now + time::Duration::days(1);
126 let time_part = lower.strip_prefix("tomorrow").unwrap_or("").trim();
127 let time = parse_time_expression(time_part).unwrap_or((9, 0));
128 let result = tomorrow
129 .replace_hour(time.0)
130 .map_err(|e| anyhow::anyhow!("Invalid hour: {}", e))?
131 .replace_minute(time.1)
132 .map_err(|e| anyhow::anyhow!("Invalid minute: {}", e))?;
133 return format_rfc3339(result);
134 }
135
136 if let Some(rest) = lower.strip_prefix("in ") {
138 return parse_in_expression(now, rest);
139 }
140
141 if let Some(rest) = lower.strip_prefix("next ") {
143 return parse_next_weekday(now, rest);
144 }
145
146 bail!(
147 "Unable to parse time expression: '{}'. Supported formats:\n - RFC3339: 2026-02-01T09:00:00Z\n - Relative: 'tomorrow 9am', 'in 2 hours', 'next monday'",
148 expression
149 )
150}
151
152fn parse_time_expression(expr: &str) -> Option<(u8, u8)> {
155 let expr = expr.trim();
156 if expr.is_empty() {
157 return None;
158 }
159
160 let expr = expr.replace(' ', "");
162
163 let is_pm = expr.ends_with("pm");
165 let is_am = expr.ends_with("am");
166 let num_part = if is_pm || is_am {
167 &expr[..expr.len() - 2]
168 } else {
169 &expr
170 };
171
172 let parts: Vec<&str> = num_part.split(':').collect();
174 let hour: u8 = parts[0].parse().ok()?;
175 let minute: u8 = parts.get(1).and_then(|m| m.parse().ok()).unwrap_or(0);
176
177 let hour_24 = if is_pm && hour != 12 {
179 hour + 12
180 } else if is_am && hour == 12 {
181 0
182 } else {
183 hour
184 };
185
186 if hour_24 > 23 || minute > 59 {
187 return None;
188 }
189
190 Some((hour_24, minute))
191}
192
193fn parse_in_expression(now: OffsetDateTime, expr: &str) -> Result<String> {
195 let expr = expr.trim();
196
197 let parts: Vec<&str> = expr.split_whitespace().collect();
199 if parts.len() < 2 {
200 bail!("Invalid 'in' expression: expected 'in N hours/minutes/days/weeks'");
201 }
202
203 let num: i64 = parts[0]
204 .parse()
205 .map_err(|_| anyhow::anyhow!("Invalid number in 'in' expression: '{}'", parts[0]))?;
206
207 let unit = parts[1].to_lowercase();
208 let unit = unit.trim_end_matches('s'); let duration = match unit {
211 "minute" => time::Duration::minutes(num),
212 "hour" => time::Duration::hours(num),
213 "day" => time::Duration::days(num),
214 "week" => time::Duration::weeks(num),
215 _ => bail!(
216 "Unknown time unit: '{}'. Use minutes, hours, days, or weeks.",
217 unit
218 ),
219 };
220
221 let result = now + duration;
222 format_rfc3339(result)
223}
224
225fn parse_next_weekday(now: OffsetDateTime, expr: &str) -> Result<String> {
227 let weekdays = [
228 ("sunday", time::Weekday::Sunday),
229 ("monday", time::Weekday::Monday),
230 ("tuesday", time::Weekday::Tuesday),
231 ("wednesday", time::Weekday::Wednesday),
232 ("thursday", time::Weekday::Thursday),
233 ("friday", time::Weekday::Friday),
234 ("saturday", time::Weekday::Saturday),
235 ];
236
237 let expr = expr.trim().to_lowercase();
238 let target_weekday = weekdays
239 .iter()
240 .find(|(name, _)| expr.starts_with(name))
241 .map(|(_, wd)| *wd)
242 .ok_or_else(|| anyhow::anyhow!("Unknown weekday: '{}'", expr))?;
243
244 let current_weekday = now.weekday();
245 let days_until = days_until_weekday(current_weekday, target_weekday);
246
247 let result = now + time::Duration::days(days_until);
248 let result = result
250 .replace_hour(9)
251 .map_err(|e| anyhow::anyhow!("Invalid hour: {}", e))?
252 .replace_minute(0)
253 .map_err(|e| anyhow::anyhow!("Invalid minute: {}", e))?;
254
255 format_rfc3339(result)
256}
257
258fn days_until_weekday(current: time::Weekday, target: time::Weekday) -> i64 {
260 let current_num = current as i64;
261 let target_num = target as i64;
262 if target_num > current_num {
263 target_num - current_num
264 } else {
265 7 - (current_num - target_num)
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_parse_relative_time_rfc3339() {
275 let result = parse_relative_time("2026-02-01T09:00:00Z").unwrap();
276 assert!(result.contains("2026-02-01T09:00:00"));
277 }
278
279 #[test]
280 fn test_parse_relative_time_tomorrow() {
281 let result = parse_relative_time("tomorrow 9am").unwrap();
282 let tomorrow = OffsetDateTime::now_utc() + time::Duration::days(1);
284 assert!(result.contains(&tomorrow.year().to_string()));
285 }
286
287 #[test]
288 fn test_parse_relative_time_in_hours() {
289 let result = parse_relative_time("in 2 hours").unwrap();
290 let now = OffsetDateTime::now_utc();
291 let parsed = parse_rfc3339(&result).unwrap();
293 let diff = parsed - now;
294 assert!(
296 diff.whole_hours() >= 1 && diff.whole_hours() <= 3,
297 "Expected ~2 hours, got {} hours",
298 diff.whole_hours()
299 );
300 }
301
302 #[test]
303 fn test_parse_relative_time_in_days() {
304 let result = parse_relative_time("in 3 days").unwrap();
305 let now = OffsetDateTime::now_utc();
306 let parsed = parse_rfc3339(&result).unwrap();
307 let diff = parsed - now;
308 assert!(
310 diff.whole_days() >= 2 && diff.whole_days() <= 4,
311 "Expected ~3 days, got {} days",
312 diff.whole_days()
313 );
314 }
315
316 #[test]
317 fn test_parse_relative_time_next_weekday() {
318 let result = parse_relative_time("next monday").unwrap();
319 assert!(!result.is_empty());
321 }
322
323 #[test]
324 fn test_parse_relative_time_invalid() {
325 let result = parse_relative_time("invalid expression");
326 assert!(result.is_err());
327 }
328
329 #[test]
330 fn test_parse_time_expression_am() {
331 assert_eq!(parse_time_expression("9am"), Some((9, 0)));
332 assert_eq!(parse_time_expression("12am"), Some((0, 0)));
333 }
334
335 #[test]
336 fn test_parse_time_expression_pm() {
337 assert_eq!(parse_time_expression("2pm"), Some((14, 0)));
338 assert_eq!(parse_time_expression("12pm"), Some((12, 0)));
339 }
340
341 #[test]
342 fn test_parse_time_expression_with_minutes() {
343 assert_eq!(parse_time_expression("9:30am"), Some((9, 30)));
344 assert_eq!(parse_time_expression("2:45pm"), Some((14, 45)));
345 }
346
347 #[test]
348 fn test_parse_time_expression_24h() {
349 assert_eq!(parse_time_expression("14:30"), Some((14, 30)));
350 assert_eq!(parse_time_expression("09:00"), Some((9, 0)));
351 }
352
353 #[test]
354 fn test_parse_time_expression_invalid() {
355 assert_eq!(parse_time_expression(""), None);
356 assert_eq!(parse_time_expression("invalid"), None);
357 }
358
359 #[test]
360 fn test_days_until_weekday() {
361 use time::Weekday;
362 assert_eq!(days_until_weekday(Weekday::Monday, Weekday::Monday), 7);
364 assert_eq!(days_until_weekday(Weekday::Monday, Weekday::Tuesday), 1);
366 assert_eq!(days_until_weekday(Weekday::Friday, Weekday::Monday), 3);
368 }
369
370 #[test]
371 fn now_utc_rfc3339_or_fallback_impl_ok_does_not_call_hook() {
372 let called = std::cell::Cell::new(false);
373 let out = now_utc_rfc3339_or_fallback_impl(
374 || Ok("2026-02-07T00:00:00.000000000Z".to_string()),
375 |_| called.set(true),
376 );
377 assert!(!called.get());
378 assert_eq!(out, "2026-02-07T00:00:00.000000000Z");
379 }
380
381 #[test]
382 fn now_utc_rfc3339_or_fallback_impl_err_calls_hook_and_returns_sentinel() {
383 let called = std::cell::Cell::new(false);
384 let out =
385 now_utc_rfc3339_or_fallback_impl(|| Err(anyhow::anyhow!("boom")), |_| called.set(true));
386 assert!(called.get());
387 assert_eq!(out, FALLBACK_RFC3339);
388 parse_rfc3339(&out).expect("sentinel must parse");
390 }
391
392 #[test]
393 fn fallback_rfc3339_is_unix_epoch() {
394 assert_eq!(FALLBACK_RFC3339, "1970-01-01T00:00:00.000000000Z");
396 let dt = parse_rfc3339(FALLBACK_RFC3339).unwrap();
398 assert_eq!(dt.year(), 1970);
399 assert_eq!(dt.month() as u8, 1);
400 assert_eq!(dt.day(), 1);
401 assert_eq!(dt.hour(), 0);
402 assert_eq!(dt.minute(), 0);
403 assert_eq!(dt.second(), 0);
404 }
405}