Skip to main content

temps_jiff/
lib.rs

1//! # temps-jiff
2//!
3//! Jiff integration for the temps time expression parser.
4//!
5//! This crate provides a `JiffProvider` that implements the `TimeParser` trait
6//! using the jiff datetime library. It enables parsing natural language time
7//! expressions into jiff's `Zoned` type.
8//!
9//! ## Features
10//!
11//! - Full implementation of the temps `TimeParser` trait
12//! - Support for all time expression types
13//! - Proper handling of month/year arithmetic using jiff's `Span`
14//! - Timezone support (UTC and fixed offsets)
15//! - Precise time calculations with nanosecond precision
16//!
17//! ## Example
18//!
19//! ```
20//! use temps_jiff::{JiffProvider, parse_to_zoned};
21//! use temps_core::{Language, TimeParser};
22//!
23//! // Parse using the convenience function
24//! let datetime = parse_to_zoned("in 5 minutes", Language::English).unwrap();
25//! println!("In 5 minutes: {}", datetime);
26//!
27//! // Or use the provider directly
28//! let provider = JiffProvider;
29//! let expr = temps_core::parse("tomorrow at 3:30 pm", Language::English).unwrap();
30//! let datetime = provider.parse_expression(expr).unwrap();
31//! ```
32//!
33//! ## Month and Year Arithmetic
34//!
35//! This implementation uses jiff's `Span` type for date arithmetic, which
36//! provides correct handling of edge cases:
37//!
38//! - January 31 + 1 month = February 29 (leap year) or February 28 (non-leap year)
39//! - February 29, 2024 + 1 year = February 28, 2025
40//!
41//! ## Error Handling
42//!
43//! All parsing operations return `Result<Zoned, TempsError>`. Common errors include:
44//!
45//! - `ParseError`: Invalid input that cannot be parsed
46//! - `DateCalculationError`: Date arithmetic that results in invalid dates
47//! - `InvalidDate`/`InvalidTime`: Components that are out of valid ranges
48//! - `BackendError`: Errors from the jiff library
49
50use jiff::{Span, Zoned};
51use temps_core::{
52    DayReference, Direction, Language, Result, TempsError, TimeExpression, TimeParser, TimeUnit,
53    Weekday,
54    errors::*,
55    time_utils::{
56        calculate_timezone_offset_seconds, calculate_weekday_offset, convert_12_to_24_hour,
57        is_valid_time, is_valid_timezone_offset,
58    },
59};
60
61/// Jiff-based implementation of the TimeParser trait.
62///
63/// This provider uses jiff's `Zoned` as its datetime type, providing
64/// high-precision time calculations and comprehensive timezone support.
65///
66/// ## Example
67///
68/// ```
69/// use temps_jiff::JiffProvider;
70/// use temps_core::{TimeParser, parse, Language};
71///
72/// let provider = JiffProvider;
73/// let expr = parse("next Monday", Language::English).unwrap();
74/// let datetime = provider.parse_expression(expr).unwrap();
75/// ```
76pub struct JiffProvider;
77
78fn jiff_date_components(year: u16, month: u8, day: u8) -> Result<(i16, i8, i8)> {
79    Ok((
80        i16::try_from(year).map_err(|_| TempsError::invalid_date(year, month, day))?,
81        i8::try_from(month).map_err(|_| TempsError::invalid_date(year, month, day))?,
82        i8::try_from(day).map_err(|_| TempsError::invalid_date(year, month, day))?,
83    ))
84}
85
86fn jiff_time_components(
87    hour: u8,
88    minute: u8,
89    second: u8,
90    nanosecond: u32,
91) -> Result<(i8, i8, i8, i32)> {
92    Ok((
93        i8::try_from(hour).map_err(|_| TempsError::invalid_time(hour, minute, second))?,
94        i8::try_from(minute).map_err(|_| TempsError::invalid_time(hour, minute, second))?,
95        i8::try_from(second).map_err(|_| TempsError::invalid_time(hour, minute, second))?,
96        i32::try_from(nanosecond)
97            .map_err(|_| TempsError::backend_error("Invalid nanosecond component", "jiff"))?,
98    ))
99}
100
101impl TimeParser for JiffProvider {
102    type DateTime = Zoned;
103
104    fn now(&self) -> Self::DateTime {
105        Zoned::now()
106    }
107
108    fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime> {
109        match expr {
110            TimeExpression::Now => Ok(self.now()),
111            TimeExpression::Relative(rel) => {
112                if rel.amount < 0 {
113                    return Err(TempsError::date_calculation(
114                        ERR_RELATIVE_AMOUNT_NON_NEGATIVE,
115                    ));
116                }
117
118                let now = self.now();
119
120                // Create a span based on the time unit
121                let span = match rel.unit {
122                    TimeUnit::Second => Span::new().seconds(rel.amount),
123                    TimeUnit::Minute => Span::new().minutes(rel.amount),
124                    TimeUnit::Hour => Span::new().hours(rel.amount),
125                    TimeUnit::Day => Span::new().days(rel.amount),
126                    TimeUnit::Week => Span::new().weeks(rel.amount),
127                    TimeUnit::Month => Span::new().months(rel.amount),
128                    TimeUnit::Year => Span::new().years(rel.amount),
129                };
130
131                // Apply the span in the correct direction
132                match rel.direction {
133                    Direction::Past => now.checked_sub(span).map_err(|e| {
134                        TempsError::date_calculation_with_source(ERR_DATE_CALC_ERROR, e.to_string())
135                    }),
136                    Direction::Future => now.checked_add(span).map_err(|e| {
137                        TempsError::date_calculation_with_source(ERR_DATE_CALC_ERROR, e.to_string())
138                    }),
139                }
140            }
141            TimeExpression::Absolute(abs) => {
142                use jiff::civil::{Date, DateTime, Time};
143                use jiff::tz::{Offset, TimeZone};
144
145                let (year, month, day) = jiff_date_components(abs.year, abs.month, abs.day)?;
146                let date = Date::new(year, month, day)
147                    .map_err(|e| TempsError::backend_error(e.to_string(), "jiff"))?;
148
149                if let (Some(hour), Some(minute)) = (abs.hour, abs.minute) {
150                    // Validate hour is in valid range (0-23)
151                    if hour > 23 {
152                        return Err(TempsError::invalid_time(
153                            hour,
154                            minute,
155                            abs.second.unwrap_or(0),
156                        ));
157                    }
158                    // Validate minute is in valid range (0-59)
159                    if minute > 59 {
160                        return Err(TempsError::invalid_time(
161                            hour,
162                            minute,
163                            abs.second.unwrap_or(0),
164                        ));
165                    }
166                    // Validate second is in valid range (0-59)
167                    if let Some(second) = abs.second
168                        && second > 59
169                    {
170                        return Err(TempsError::invalid_time(hour, minute, second));
171                    }
172
173                    let second = abs.second.unwrap_or(0);
174                    let nanosecond = abs.nanosecond.unwrap_or(0);
175                    let (hour, minute, second, nanosecond) =
176                        jiff_time_components(hour, minute, second, nanosecond)?;
177
178                    let time = Time::new(hour, minute, second, nanosecond)
179                        .map_err(|e| TempsError::backend_error(e.to_string(), "jiff"))?;
180
181                    let datetime = DateTime::from_parts(date, time);
182
183                    match &abs.timezone {
184                        Some(temps_core::Timezone::Utc) => datetime
185                            .to_zoned(TimeZone::UTC)
186                            .map(|z| z.with_time_zone(TimeZone::system()))
187                            .map_err(|e| {
188                                TempsError::backend_error(
189                                    format!("{ERR_TIMEZONE_CONVERSION}: {e}"),
190                                    "jiff",
191                                )
192                            }),
193                        Some(temps_core::Timezone::Offset { hours, minutes }) => {
194                            if !is_valid_timezone_offset(temps_core::Timezone::Offset {
195                                hours: *hours,
196                                minutes: *minutes,
197                            }) {
198                                return Err(TempsError::invalid_timezone_offset(*hours, *minutes));
199                            }
200
201                            let total_seconds = calculate_timezone_offset_seconds(*hours, *minutes);
202                            let offset = Offset::from_seconds(total_seconds).map_err(|_| {
203                                TempsError::invalid_timezone_offset(*hours, *minutes)
204                            })?;
205
206                            datetime
207                                .to_zoned(TimeZone::fixed(offset))
208                                .map(|z| z.with_time_zone(TimeZone::system()))
209                                .map_err(|e| {
210                                    TempsError::backend_error(
211                                        format!("{ERR_TIMEZONE_CONVERSION}: {e}"),
212                                        "jiff",
213                                    )
214                                })
215                        }
216                        None => {
217                            // No timezone specified, treat as system timezone
218                            datetime.to_zoned(TimeZone::system()).map_err(|e| {
219                                TempsError::backend_error(
220                                    format!("{ERR_TIMEZONE_CONVERSION}: {e}"),
221                                    "jiff",
222                                )
223                            })
224                        }
225                    }
226                } else {
227                    // Date only, set time to midnight
228                    let datetime = date.at(0, 0, 0, 0);
229                    datetime.to_zoned(TimeZone::system()).map_err(|e| {
230                        TempsError::backend_error(format!("{ERR_TIMEZONE_CONVERSION}: {e}"), "jiff")
231                    })
232                }
233            }
234            TimeExpression::Day(day_ref) => {
235                let now = self.now();
236                match day_ref {
237                    DayReference::Today => {
238                        let date = now.date();
239                        date.at(0, 0, 0, 0)
240                            .to_zoned(now.time_zone().clone())
241                            .map_err(|e| {
242                                TempsError::date_calculation_with_source(
243                                    "Failed to create today's date",
244                                    e.to_string(),
245                                )
246                            })
247                    }
248                    DayReference::Yesterday => {
249                        let yesterday = now.checked_sub(Span::new().days(1)).map_err(|e| {
250                            TempsError::date_calculation_with_source(
251                                "Failed to calculate yesterday",
252                                e.to_string(),
253                            )
254                        })?;
255                        let date = yesterday.date();
256                        date.at(0, 0, 0, 0)
257                            .to_zoned(now.time_zone().clone())
258                            .map_err(|e| {
259                                TempsError::date_calculation_with_source(
260                                    "Failed to create yesterday's date",
261                                    e.to_string(),
262                                )
263                            })
264                    }
265                    DayReference::Tomorrow => {
266                        let tomorrow = now.checked_add(Span::new().days(1)).map_err(|e| {
267                            TempsError::date_calculation_with_source(
268                                "Failed to calculate tomorrow",
269                                e.to_string(),
270                            )
271                        })?;
272                        let date = tomorrow.date();
273                        date.at(0, 0, 0, 0)
274                            .to_zoned(now.time_zone().clone())
275                            .map_err(|e| {
276                                TempsError::date_calculation_with_source(
277                                    "Failed to create tomorrow's date",
278                                    e.to_string(),
279                                )
280                            })
281                    }
282                    DayReference::Weekday { day, modifier } => {
283                        let target_weekday = match day {
284                            Weekday::Monday => jiff::civil::Weekday::Monday,
285                            Weekday::Tuesday => jiff::civil::Weekday::Tuesday,
286                            Weekday::Wednesday => jiff::civil::Weekday::Wednesday,
287                            Weekday::Thursday => jiff::civil::Weekday::Thursday,
288                            Weekday::Friday => jiff::civil::Weekday::Friday,
289                            Weekday::Saturday => jiff::civil::Weekday::Saturday,
290                            Weekday::Sunday => jiff::civil::Weekday::Sunday,
291                        };
292
293                        let current_weekday = now.weekday();
294                        let current_offset = current_weekday.to_monday_zero_offset() as i64;
295                        let target_offset = target_weekday.to_monday_zero_offset() as i64;
296
297                        let days_to_add =
298                            calculate_weekday_offset(current_offset, target_offset, modifier);
299                        let target_date = now.checked_add(Span::new().days(days_to_add));
300
301                        let target = target_date.map_err(|e| {
302                            TempsError::date_calculation_with_source(
303                                "Failed to calculate weekday",
304                                e.to_string(),
305                            )
306                        })?;
307                        let date = target.date();
308                        date.at(0, 0, 0, 0)
309                            .to_zoned(now.time_zone().clone())
310                            .map_err(|e| {
311                                TempsError::date_calculation_with_source(
312                                    "Failed to create weekday date",
313                                    e.to_string(),
314                                )
315                            })
316                    }
317                }
318            }
319            TimeExpression::Time(time) => {
320                let now = self.now();
321                let date = now.date();
322
323                if !is_valid_time(time.hour, time.minute, time.second, time.meridiem) {
324                    return Err(TempsError::invalid_time(
325                        time.hour,
326                        time.minute,
327                        time.second,
328                    ));
329                }
330
331                let hour = convert_12_to_24_hour(time.hour, time.meridiem.as_ref());
332
333                let (hour, minute, second, nanosecond) =
334                    jiff_time_components(hour, time.minute, time.second, 0)?;
335
336                date.at(hour, minute, second, nanosecond)
337                    .to_zoned(now.time_zone().clone())
338                    .map_err(|e| {
339                        TempsError::backend_error(format!("Failed to create time: {e}"), "jiff")
340                    })
341            }
342            TimeExpression::DayTime(day_time) => {
343                // First get the day
344                let day_result = self.parse_expression(TimeExpression::Day(day_time.day))?;
345                let date = day_result.date();
346
347                if !is_valid_time(
348                    day_time.time.hour,
349                    day_time.time.minute,
350                    day_time.time.second,
351                    day_time.time.meridiem,
352                ) {
353                    return Err(TempsError::invalid_time(
354                        day_time.time.hour,
355                        day_time.time.minute,
356                        day_time.time.second,
357                    ));
358                }
359
360                let hour =
361                    convert_12_to_24_hour(day_time.time.hour, day_time.time.meridiem.as_ref());
362
363                let (hour, minute, second, nanosecond) =
364                    jiff_time_components(hour, day_time.time.minute, day_time.time.second, 0)?;
365
366                date.at(hour, minute, second, nanosecond)
367                    .to_zoned(day_result.time_zone().clone())
368                    .map_err(|e| {
369                        TempsError::backend_error(format!("Failed to create day time: {e}"), "jiff")
370                    })
371            }
372            TimeExpression::Date(date) => {
373                use jiff::civil::Date;
374
375                let (year, month, day) = jiff_date_components(date.year, date.month, date.day)?;
376                let jiff_date = Date::new(year, month, day)
377                    .map_err(|_| TempsError::invalid_date(date.year, date.month, date.day))?;
378
379                jiff_date
380                    .at(0, 0, 0, 0)
381                    .to_zoned(jiff::tz::TimeZone::system())
382                    .map_err(|e| {
383                        TempsError::backend_error(format!("Failed to create date: {e}"), "jiff")
384                    })
385            }
386        }
387    }
388}
389
390/// Parse a natural language time expression into a jiff `Zoned` datetime.
391///
392/// This is a convenience function that combines parsing and time calculation
393/// in a single call.
394///
395/// # Arguments
396///
397/// * `input` - The natural language time expression to parse
398/// * `language` - The language to use for parsing
399///
400/// # Returns
401///
402/// Returns `Ok(Zoned)` if parsing succeeds, or `Err(TempsError)`
403/// if the input cannot be parsed or the date calculation fails.
404///
405/// # Examples
406///
407/// ```
408/// use temps_jiff::parse_to_zoned;
409/// use temps_core::Language;
410///
411/// // Parse English expressions
412/// let dt = parse_to_zoned("in 30 minutes", Language::English).unwrap();
413/// let dt = parse_to_zoned("tomorrow at 12:00", Language::English).unwrap();
414/// let dt = parse_to_zoned("last Monday", Language::English).unwrap();
415///
416/// // Parse German expressions  
417/// let dt = parse_to_zoned("in 30 Minuten", Language::German).unwrap();
418/// let dt = parse_to_zoned("morgen um 15:30", Language::German).unwrap();
419/// ```
420///
421/// # Errors
422///
423/// This function will return an error if:
424/// - The input cannot be parsed as a valid time expression
425/// - Date calculation results in an invalid date
426/// - Components are out of valid ranges (e.g., month 13)
427/// - The jiff library returns an error during calculations
428pub fn parse_to_zoned(input: &str, language: Language) -> Result<Zoned> {
429    let expr = temps_core::parse(input, language)?;
430    JiffProvider.parse_expression(expr)
431}