Skip to main content

cel_core/eval/
time.rs

1//! Time parsing and formatting utilities for CEL timestamps and durations.
2//!
3//! This module provides functions to parse and format timestamps and durations
4//! according to CEL specification requirements.
5
6use super::value::{Duration, Timestamp};
7use chrono::{DateTime, Datelike, FixedOffset, Offset, TimeZone, Timelike};
8use chrono_tz::Tz;
9
10/// Parse an RFC 3339 timestamp string.
11///
12/// Supports formats like:
13/// - "2009-02-13T23:31:30Z"
14/// - "2009-02-13T23:31:30.123456789Z"
15/// - "2009-02-13T23:31:30+01:00"
16pub fn parse_timestamp(s: &str) -> Result<Timestamp, String> {
17    // Try parsing with chrono's RFC 3339 parser
18    let dt =
19        DateTime::parse_from_rfc3339(s).map_err(|e| format!("invalid timestamp format: {}", e))?;
20
21    let ts = Timestamp {
22        seconds: dt.timestamp(),
23        nanos: dt.timestamp_subsec_nanos() as i32,
24    };
25
26    if !ts.is_valid() {
27        return Err("timestamp out of range: must be between year 0001 and 9999".to_string());
28    }
29
30    Ok(ts)
31}
32
33/// Parse a CEL duration string.
34///
35/// Supports formats like:
36/// - "100s" - 100 seconds
37/// - "1.5h" - 1.5 hours
38/// - "30m" - 30 minutes
39/// - "1h30m" - 1 hour 30 minutes
40/// - "1h30m45s" - 1 hour 30 minutes 45 seconds
41/// - "100ms" - 100 milliseconds
42/// - "100us" - 100 microseconds
43/// - "100ns" - 100 nanoseconds
44/// - "-30s" - negative 30 seconds
45pub fn parse_duration(s: &str) -> Result<Duration, String> {
46    if s.is_empty() {
47        return Err("empty duration string".to_string());
48    }
49
50    let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
51        (true, rest)
52    } else {
53        (false, s)
54    };
55
56    if s.is_empty() {
57        return Err("invalid duration: no value".to_string());
58    }
59
60    let mut total_nanos: i128 = 0;
61    let mut remaining = s;
62
63    while !remaining.is_empty() {
64        // Parse the numeric part (including optional decimal point)
65        let num_end = remaining
66            .find(|c: char| !c.is_ascii_digit() && c != '.')
67            .unwrap_or(remaining.len());
68
69        if num_end == 0 {
70            return Err(format!(
71                "invalid duration format: expected number at '{}'",
72                remaining
73            ));
74        }
75
76        let num_str = &remaining[..num_end];
77        remaining = &remaining[num_end..];
78
79        // Parse the unit
80        let unit_end = remaining
81            .find(|c: char| c.is_ascii_digit() || c == '.')
82            .unwrap_or(remaining.len());
83
84        if unit_end == 0 {
85            return Err(format!(
86                "invalid duration: missing unit after '{}'",
87                num_str
88            ));
89        }
90
91        let unit = &remaining[..unit_end];
92        remaining = &remaining[unit_end..];
93
94        // Convert to nanoseconds based on unit
95        let multiplier: i128 = match unit {
96            "h" => 3_600_000_000_000,    // hours
97            "m" => 60_000_000_000,       // minutes
98            "s" => 1_000_000_000,        // seconds
99            "ms" => 1_000_000,           // milliseconds
100            "us" | "\u{00b5}s" => 1_000, // microseconds (supports μs)
101            "ns" => 1,                   // nanoseconds
102            _ => return Err(format!("invalid duration unit: '{}'", unit)),
103        };
104
105        // Parse the number (may be floating point)
106        if num_str.contains('.') {
107            let num: f64 = num_str
108                .parse()
109                .map_err(|_| format!("invalid number in duration: '{}'", num_str))?;
110            total_nanos += (num * multiplier as f64) as i128;
111        } else {
112            let num: i128 = num_str
113                .parse()
114                .map_err(|_| format!("invalid number in duration: '{}'", num_str))?;
115            total_nanos += num * multiplier;
116        }
117    }
118
119    if negative {
120        total_nanos = -total_nanos;
121    }
122
123    // Convert to seconds and nanos
124    let seconds = (total_nanos / 1_000_000_000) as i64;
125    let nanos = (total_nanos % 1_000_000_000) as i32;
126
127    let duration = Duration::new(seconds, nanos);
128
129    if !duration.is_valid() {
130        return Err("duration out of range: must be within approximately 10000 years".to_string());
131    }
132
133    Ok(duration)
134}
135
136/// Format a timestamp as an RFC 3339 string with nanosecond precision.
137///
138/// Examples:
139/// - "2009-02-13T23:31:30Z" (no fractional seconds)
140/// - "2009-02-13T23:31:30.123456789Z" (with nanoseconds)
141pub fn format_timestamp(ts: &Timestamp) -> String {
142    if let Some(dt) = ts.to_datetime_utc() {
143        if ts.nanos == 0 {
144            dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
145        } else {
146            // Format with nanoseconds, trimming trailing zeros
147            let nanos_str = format!("{:09}", ts.nanos);
148            let trimmed = nanos_str.trim_end_matches('0');
149            if trimmed.is_empty() {
150                dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
151            } else {
152                format!("{}.{}Z", dt.format("%Y-%m-%dT%H:%M:%S"), trimmed)
153            }
154        }
155    } else {
156        // Fallback for invalid timestamps
157        format!("{}s", ts.seconds)
158    }
159}
160
161/// Format a duration as a string.
162///
163/// CEL format: "Xs" or "X.XXXXXXXXXs" for durations with fractional seconds.
164pub fn format_duration(d: &Duration) -> String {
165    if d.nanos == 0 {
166        format!("{}s", d.seconds)
167    } else {
168        // Format with fractional seconds
169        let total_nanos = d.seconds as i128 * 1_000_000_000 + d.nanos as i128;
170        let sign = if total_nanos < 0 { "-" } else { "" };
171        let abs_nanos = total_nanos.abs();
172        let secs = abs_nanos / 1_000_000_000;
173        let frac = abs_nanos % 1_000_000_000;
174
175        if frac == 0 {
176            format!("{}{}s", sign, secs)
177        } else {
178            // Format fractional part, trimming trailing zeros
179            let frac_str = format!("{:09}", frac);
180            let trimmed = frac_str.trim_end_matches('0');
181            format!("{}{}.{}s", sign, secs, trimmed)
182        }
183    }
184}
185
186/// Parse a timezone string.
187///
188/// Supports:
189/// - IANA timezone names: "America/New_York", "Europe/London", "Australia/Sydney"
190/// - Fixed UTC offsets: "+01:00", "-05:30", "02:00" (positive assumed)
191///
192/// Returns a FixedOffset that can be used with chrono.
193pub fn parse_timezone(tz: &str) -> Result<TimezoneInfo, String> {
194    // First, try parsing as an IANA timezone name
195    if let Ok(tz_parsed) = tz.parse::<Tz>() {
196        return Ok(TimezoneInfo::Iana(tz_parsed));
197    }
198
199    // Try parsing as a fixed offset
200    parse_fixed_offset(tz).map(TimezoneInfo::Fixed)
201}
202
203/// Parse a fixed UTC offset string like "+01:00", "-05:30", or "02:00".
204fn parse_fixed_offset(s: &str) -> Result<FixedOffset, String> {
205    let s = s.trim();
206
207    if s.is_empty() {
208        return Err("empty timezone string".to_string());
209    }
210
211    // Determine sign and remaining string
212    let (negative, rest) = if let Some(r) = s.strip_prefix('-') {
213        (true, r)
214    } else if let Some(r) = s.strip_prefix('+') {
215        (false, r)
216    } else {
217        // No sign prefix - assume positive
218        (false, s)
219    };
220
221    // Parse hours and minutes
222    let parts: Vec<&str> = rest.split(':').collect();
223    if parts.len() != 2 {
224        return Err(format!("invalid timezone offset format: '{}'", s));
225    }
226
227    let hours: i32 = parts[0]
228        .parse()
229        .map_err(|_| format!("invalid hours in timezone: '{}'", parts[0]))?;
230    let minutes: i32 = parts[1]
231        .parse()
232        .map_err(|_| format!("invalid minutes in timezone: '{}'", parts[1]))?;
233
234    let total_seconds = (hours * 3600 + minutes * 60) * if negative { -1 } else { 1 };
235
236    FixedOffset::east_opt(total_seconds)
237        .ok_or_else(|| format!("timezone offset out of range: '{}'", s))
238}
239
240/// Represents either an IANA timezone or a fixed offset.
241pub enum TimezoneInfo {
242    Iana(Tz),
243    Fixed(FixedOffset),
244}
245
246impl TimezoneInfo {
247    /// Convert a UTC timestamp to a DateTime in this timezone.
248    pub fn datetime_from_timestamp(&self, ts: &Timestamp) -> Option<DateTime<FixedOffset>> {
249        let utc_dt = ts.to_datetime_utc()?;
250
251        match self {
252            TimezoneInfo::Iana(tz) => {
253                let local = utc_dt.with_timezone(tz);
254                // Convert to fixed offset
255                let offset = local.offset().fix();
256                Some(local.with_timezone(&offset))
257            }
258            TimezoneInfo::Fixed(offset) => Some(utc_dt.with_timezone(offset)),
259        }
260    }
261}
262
263/// Timestamp accessor component.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum TimestampComponent {
266    /// Full 4-digit year.
267    FullYear,
268    /// Month (0-11, 0 = January).
269    Month,
270    /// Day of month (1-31, 1-indexed).
271    Date,
272    /// Day of month (0-30, 0-indexed).
273    DayOfMonth,
274    /// Day of week (0-6, 0 = Sunday).
275    DayOfWeek,
276    /// Day of year (0-365).
277    DayOfYear,
278    /// Hours (0-23).
279    Hours,
280    /// Minutes (0-59).
281    Minutes,
282    /// Seconds (0-59).
283    Seconds,
284    /// Milliseconds (0-999).
285    Milliseconds,
286}
287
288impl TimestampComponent {
289    /// Get the component value from a DateTime.
290    pub fn extract<Tz: TimeZone>(&self, dt: &DateTime<Tz>) -> i64 {
291        match self {
292            TimestampComponent::FullYear => dt.year() as i64,
293            TimestampComponent::Month => (dt.month0()) as i64, // 0-11
294            TimestampComponent::Date => dt.day() as i64,       // 1-31
295            TimestampComponent::DayOfMonth => (dt.day() - 1) as i64, // 0-30
296            TimestampComponent::DayOfWeek => {
297                // chrono: Mon=0, Sun=6; CEL: Sun=0, Sat=6
298                let weekday = dt.weekday().num_days_from_sunday();
299                weekday as i64
300            }
301            TimestampComponent::DayOfYear => (dt.ordinal0()) as i64, // 0-365
302            TimestampComponent::Hours => dt.hour() as i64,
303            TimestampComponent::Minutes => dt.minute() as i64,
304            TimestampComponent::Seconds => dt.second() as i64,
305            TimestampComponent::Milliseconds => (dt.nanosecond() / 1_000_000) as i64,
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_parse_timestamp_basic() {
316        let ts = parse_timestamp("2009-02-13T23:31:30Z").unwrap();
317        assert_eq!(ts.seconds, 1234567890);
318        assert_eq!(ts.nanos, 0);
319    }
320
321    #[test]
322    fn test_parse_timestamp_with_nanos() {
323        let ts = parse_timestamp("2009-02-13T23:31:30.123456789Z").unwrap();
324        assert_eq!(ts.seconds, 1234567890);
325        assert_eq!(ts.nanos, 123456789);
326    }
327
328    #[test]
329    fn test_parse_timestamp_with_offset() {
330        let ts = parse_timestamp("2009-02-13T18:31:30-05:00").unwrap();
331        assert_eq!(ts.seconds, 1234567890);
332    }
333
334    #[test]
335    fn test_parse_duration_seconds() {
336        let d = parse_duration("100s").unwrap();
337        assert_eq!(d.seconds, 100);
338        assert_eq!(d.nanos, 0);
339    }
340
341    #[test]
342    fn test_parse_duration_hours() {
343        let d = parse_duration("2h").unwrap();
344        assert_eq!(d.seconds, 7200);
345    }
346
347    #[test]
348    fn test_parse_duration_compound() {
349        let d = parse_duration("1h30m").unwrap();
350        assert_eq!(d.seconds, 5400);
351    }
352
353    #[test]
354    fn test_parse_duration_negative() {
355        let d = parse_duration("-30s").unwrap();
356        assert_eq!(d.seconds, -30);
357    }
358
359    #[test]
360    fn test_parse_duration_milliseconds() {
361        let d = parse_duration("500ms").unwrap();
362        assert_eq!(d.seconds, 0);
363        assert_eq!(d.nanos, 500_000_000);
364    }
365
366    #[test]
367    fn test_parse_duration_fractional() {
368        let d = parse_duration("1.5h").unwrap();
369        assert_eq!(d.seconds, 5400);
370    }
371
372    #[test]
373    fn test_format_timestamp() {
374        let ts = Timestamp::new(1234567890, 0);
375        assert_eq!(format_timestamp(&ts), "2009-02-13T23:31:30Z");
376    }
377
378    #[test]
379    fn test_format_timestamp_with_nanos() {
380        let ts = Timestamp::new(1234567890, 123000000);
381        assert_eq!(format_timestamp(&ts), "2009-02-13T23:31:30.123Z");
382    }
383
384    #[test]
385    fn test_format_duration() {
386        let d = Duration::new(100, 0);
387        assert_eq!(format_duration(&d), "100s");
388    }
389
390    #[test]
391    fn test_format_duration_with_nanos() {
392        let d = Duration::new(1, 500000000);
393        assert_eq!(format_duration(&d), "1.5s");
394    }
395
396    #[test]
397    fn test_parse_timezone_iana() {
398        let tz = parse_timezone("America/New_York").unwrap();
399        assert!(matches!(tz, TimezoneInfo::Iana(_)));
400    }
401
402    #[test]
403    fn test_parse_timezone_offset() {
404        let tz = parse_timezone("+05:30").unwrap();
405        assert!(matches!(tz, TimezoneInfo::Fixed(_)));
406    }
407
408    #[test]
409    fn test_parse_timezone_offset_no_sign() {
410        let tz = parse_timezone("05:30").unwrap();
411        assert!(matches!(tz, TimezoneInfo::Fixed(_)));
412    }
413
414    #[test]
415    fn test_timestamp_component_extract() {
416        let ts = Timestamp::new(1234567890, 0);
417        let dt = ts.to_datetime_utc().unwrap();
418
419        assert_eq!(TimestampComponent::FullYear.extract(&dt), 2009);
420        assert_eq!(TimestampComponent::Month.extract(&dt), 1); // February = 1 (0-indexed)
421        assert_eq!(TimestampComponent::Date.extract(&dt), 13);
422        assert_eq!(TimestampComponent::DayOfMonth.extract(&dt), 12); // 0-indexed
423        assert_eq!(TimestampComponent::Hours.extract(&dt), 23);
424        assert_eq!(TimestampComponent::Minutes.extract(&dt), 31);
425        assert_eq!(TimestampComponent::Seconds.extract(&dt), 30);
426    }
427
428    #[test]
429    fn test_day_of_week() {
430        // 2009-02-13 was a Friday
431        let ts = Timestamp::new(1234567890, 0);
432        let dt = ts.to_datetime_utc().unwrap();
433        assert_eq!(TimestampComponent::DayOfWeek.extract(&dt), 5); // Friday = 5 (Sun=0)
434    }
435}