Skip to main content

ralph/
timeutil.rs

1//! Time helpers for RFC3339 timestamps with consistent precision.
2//!
3//! Responsibilities:
4//! - Parse RFC3339 timestamps for queue/reporting workflows.
5//! - Format timestamps with fixed 9-digit fractional seconds in UTC.
6//! - Provide a testable fallback mechanism for formatting failures.
7//!
8//! Does not handle:
9//! - Parsing non-RFC3339 timestamp formats.
10//! - Guessing or inferring time zones for naive timestamps.
11//!
12//! Invariants/assumptions:
13//! - Callers provide RFC3339 strings when parsing.
14//! - Formatted timestamps are always UTC with 9-digit subseconds.
15//! - Formatting errors are logged and result in a sentinel fallback value.
16
17// Re-export for backward compatibility
18pub 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            // This format string is a compile-time constant that is always valid.
31            // The expect documents this invariant and ensures we fail fast if it changes.
32            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
69/// Internal implementation for `now_utc_rfc3339_or_fallback` that accepts
70/// injectable dependencies for testability.
71///
72/// The `now_fn` produces the timestamp or an error.
73/// The `on_err` callback is invoked when an error occurs, before returning the fallback.
74fn 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
88/// Returns the current UTC timestamp in RFC3339 format, or a sentinel fallback on error.
89///
90/// On formatting failure, logs an error and returns `FALLBACK_RFC3339` (Unix epoch).
91/// The fallback value is intentionally "obviously wrong" to make debugging easier.
92pub 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
102/// Parse a relative or absolute time expression into RFC3339.
103///
104/// Supports:
105/// - RFC3339 timestamps (2026-02-01T09:00:00Z)
106/// - Relative expressions: "tomorrow 9am", "in 2 hours", "next monday"
107///
108/// Time parsing for expressions like "tomorrow 9am" uses a simple heuristic:
109/// - "9am", "9:00am", "09:00" formats are supported
110/// - If no time is specified, defaults to 9:00 AM
111pub fn parse_relative_time(expression: &str) -> Result<String> {
112    let trimmed = expression.trim();
113
114    // First try RFC3339 parsing
115    if let Ok(dt) = parse_rfc3339(trimmed) {
116        return format_rfc3339(dt);
117    }
118
119    // Try relative parsing
120    let lower = trimmed.to_lowercase();
121    let now = OffsetDateTime::now_utc();
122
123    // "tomorrow [TIME]"
124    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    // "in N [units]"
137    if let Some(rest) = lower.strip_prefix("in ") {
138        return parse_in_expression(now, rest);
139    }
140
141    // "next [weekday]"
142    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
152/// Parse a time expression like "9am", "14:30", "2:30pm"
153/// Returns (hour, minute) in 24-hour format
154fn parse_time_expression(expr: &str) -> Option<(u8, u8)> {
155    let expr = expr.trim();
156    if expr.is_empty() {
157        return None;
158    }
159
160    // Try to parse "9am", "9:30am", "2pm", etc.
161    let expr = expr.replace(' ', "");
162
163    // Check for am/pm
164    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    // Split by colon if present
173    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    // Convert to 24-hour format
178    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
193/// Parse "in N hours/minutes/days/weeks"
194fn parse_in_expression(now: OffsetDateTime, expr: &str) -> Result<String> {
195    let expr = expr.trim();
196
197    // Parse number and unit
198    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'); // Handle both "hour" and "hours"
209
210    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
225/// Parse "next monday", "next tuesday", etc.
226fn 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    // Default to 9:00 AM
249    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
258/// Calculate days until target weekday from current weekday
259fn 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        // Should be tomorrow at 9am
283        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        // Parse result and verify it's approximately 2 hours from now
292        let parsed = parse_rfc3339(&result).unwrap();
293        let diff = parsed - now;
294        // Allow for some test execution time (within 5 minutes)
295        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        // Should be approximately 3 days (allow 2-4 for test timing)
309        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        // Should parse successfully
320        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        // If today is Monday, next Monday is 7 days away
363        assert_eq!(days_until_weekday(Weekday::Monday, Weekday::Monday), 7);
364        // If today is Monday, next Tuesday is 1 day away
365        assert_eq!(days_until_weekday(Weekday::Monday, Weekday::Tuesday), 1);
366        // If today is Friday, next Monday is 3 days away
367        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        // Ensure sentinel is parseable
389        parse_rfc3339(&out).expect("sentinel must parse");
390    }
391
392    #[test]
393    fn fallback_rfc3339_is_unix_epoch() {
394        // Verify the sentinel value is the Unix epoch
395        assert_eq!(FALLBACK_RFC3339, "1970-01-01T00:00:00.000000000Z");
396        // Verify it parses correctly
397        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}