Skip to main content

temps_chrono/
lib.rs

1//! # temps-chrono
2//!
3//! Chrono integration for the temps time expression parser.
4//!
5//! This crate provides a `ChronoProvider` that implements the `TimeParser` trait
6//! using the chrono datetime library. It enables parsing natural language time
7//! expressions into chrono's `DateTime<Local>` 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
14//! - Timezone support (UTC and fixed offsets)
15//! - DST-aware local time handling
16//!
17//! ## Example
18//!
19//! ```
20//! use temps_chrono::{ChronoProvider, parse_to_datetime};
21//! use temps_core::{Language, TimeParser};
22//!
23//! // Parse using the convenience function
24//! let datetime = parse_to_datetime("in 5 minutes", Language::English).unwrap();
25//! println!("In 5 minutes: {}", datetime);
26//!
27//! // Or use the provider directly
28//! let provider = ChronoProvider;
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 chrono's `checked_add_months` and `checked_sub_months`
36//! for proper month/year arithmetic. This handles edge cases correctly:
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<DateTime<Local>, TempsError>`. Common errors include:
44//!
45//! - `ParseError`: Invalid input that cannot be parsed
46//! - `DateCalculationError`: Date arithmetic that results in invalid dates
47//! - `AmbiguousTime`: Local times that are ambiguous due to DST transitions
48//! - `InvalidDate`/`InvalidTime`: Components that are out of valid ranges
49
50use chrono::{DateTime, Datelike, Duration, Local, Months};
51use temps_core::{
52    DayReference, Direction, Language, Result, TempsError, TimeExpression, TimeParser, TimeUnit,
53    Weekday,
54    constants::MONTHS_PER_YEAR,
55    errors::*,
56    time_utils::{
57        calculate_timezone_offset_seconds, calculate_weekday_offset, convert_12_to_24_hour,
58        is_valid_time, is_valid_timezone_offset,
59    },
60};
61
62/// Chrono-based implementation of the TimeParser trait.
63///
64/// This provider uses chrono's `DateTime<Local>` as its datetime type,
65/// providing full support for timezones, DST, and proper date arithmetic.
66///
67/// ## Example
68///
69/// ```
70/// use temps_chrono::ChronoProvider;
71/// use temps_core::{TimeParser, parse, Language};
72///
73/// let provider = ChronoProvider;
74/// let expr = parse("next Monday", Language::English).unwrap();
75/// let datetime = provider.parse_expression(expr).unwrap();
76/// ```
77pub struct ChronoProvider;
78
79impl TimeParser for ChronoProvider {
80    type DateTime = DateTime<Local>;
81
82    fn now(&self) -> Self::DateTime {
83        Local::now()
84    }
85
86    fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime> {
87        match expr {
88            TimeExpression::Now => Ok(self.now()),
89            TimeExpression::Relative(rel) => {
90                if rel.amount < 0 {
91                    return Err(TempsError::date_calculation(
92                        ERR_RELATIVE_AMOUNT_NON_NEGATIVE,
93                    ));
94                }
95
96                let now = self.now();
97
98                // Handle months and years separately for proper date arithmetic
99                match rel.unit {
100                    TimeUnit::Month => {
101                        let months = Months::new(
102                            rel.amount
103                                .try_into()
104                                .map_err(|_| TempsError::date_calculation(ERR_MONTH_POSITIVE))?,
105                        );
106
107                        match rel.direction {
108                            Direction::Past => now
109                                .checked_sub_months(months)
110                                .ok_or_else(|| TempsError::date_calculation(ERR_DATE_CALC_INVALID)),
111                            Direction::Future => now
112                                .checked_add_months(months)
113                                .ok_or_else(|| TempsError::date_calculation(ERR_DATE_CALC_INVALID)),
114                        }
115                    }
116                    TimeUnit::Year => {
117                        // Convert years to months for proper arithmetic
118                        let months_count = rel
119                            .amount
120                            .checked_mul(MONTHS_PER_YEAR as i64)
121                            .ok_or_else(|| TempsError::arithmetic_overflow(ERR_YEAR_OVERFLOW))?;
122                        let months = Months::new(
123                            months_count
124                                .try_into()
125                                .map_err(|_| TempsError::date_calculation(ERR_YEAR_POSITIVE))?,
126                        );
127
128                        match rel.direction {
129                            Direction::Past => now
130                                .checked_sub_months(months)
131                                .ok_or_else(|| TempsError::date_calculation(ERR_DATE_CALC_INVALID)),
132                            Direction::Future => now
133                                .checked_add_months(months)
134                                .ok_or_else(|| TempsError::date_calculation(ERR_DATE_CALC_INVALID)),
135                        }
136                    }
137                    _ => {
138                        // Use Duration for time units that have fixed lengths
139                        let duration = match rel.unit {
140                            TimeUnit::Second => Duration::seconds(rel.amount),
141                            TimeUnit::Minute => Duration::minutes(rel.amount),
142                            TimeUnit::Hour => Duration::hours(rel.amount),
143                            TimeUnit::Day => Duration::days(rel.amount),
144                            TimeUnit::Week => Duration::weeks(rel.amount),
145                            _ => unreachable!(), // Month and Year handled above
146                        };
147
148                        match rel.direction {
149                            Direction::Past => Ok(now - duration),
150                            Direction::Future => Ok(now + duration),
151                        }
152                    }
153                }
154            }
155            TimeExpression::Absolute(abs) => {
156                use chrono::{FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
157
158                let date =
159                    NaiveDate::from_ymd_opt(abs.year as i32, abs.month as u32, abs.day as u32)
160                        .ok_or_else(|| TempsError::invalid_date(abs.year, abs.month, abs.day))?;
161
162                let datetime = if let (Some(hour), Some(minute)) = (abs.hour, abs.minute) {
163                    let time = NaiveTime::from_hms_nano_opt(
164                        hour as u32,
165                        minute as u32,
166                        abs.second.unwrap_or(0) as u32,
167                        abs.nanosecond.unwrap_or(0),
168                    )
169                    .ok_or_else(|| {
170                        TempsError::invalid_time(hour, minute, abs.second.unwrap_or(0))
171                    })?;
172
173                    let naive_dt = NaiveDateTime::new(date, time);
174
175                    match &abs.timezone {
176                        Some(temps_core::Timezone::Utc) => {
177                            Utc.from_utc_datetime(&naive_dt).with_timezone(&Local)
178                        }
179                        Some(temps_core::Timezone::Offset { hours, minutes }) => {
180                            if !is_valid_timezone_offset(temps_core::Timezone::Offset {
181                                hours: *hours,
182                                minutes: *minutes,
183                            }) {
184                                return Err(TempsError::invalid_timezone_offset(*hours, *minutes));
185                            }
186
187                            let offset_seconds =
188                                calculate_timezone_offset_seconds(*hours, *minutes);
189                            let offset =
190                                FixedOffset::east_opt(offset_seconds).ok_or_else(|| {
191                                    TempsError::invalid_timezone_offset(*hours, *minutes)
192                                })?;
193                            offset
194                                .from_local_datetime(&naive_dt)
195                                .single()
196                                .ok_or_else(|| TempsError::ambiguous_time(ERR_AMBIGUOUS_TIME))?
197                                .with_timezone(&Local)
198                        }
199                        None => {
200                            // No timezone specified, treat as local time
201                            Local
202                                .from_local_datetime(&naive_dt)
203                                .single()
204                                .ok_or_else(|| TempsError::ambiguous_time(ERR_AMBIGUOUS_TIME))?
205                        }
206                    }
207                } else {
208                    // Date only, set time to midnight
209                    let midnight = date
210                        .and_hms_opt(0, 0, 0)
211                        .ok_or_else(|| TempsError::date_calculation(ERR_MIDNIGHT_FAILED))?;
212                    Local
213                        .from_local_datetime(&midnight)
214                        .single()
215                        .ok_or_else(|| TempsError::ambiguous_time(ERR_AMBIGUOUS_TIME))?
216                };
217
218                Ok(datetime)
219            }
220            TimeExpression::Day(day_ref) => {
221                let now = self.now();
222                match day_ref {
223                    DayReference::Today => {
224                        let midnight = now
225                            .date_naive()
226                            .and_hms_opt(0, 0, 0)
227                            .ok_or_else(|| TempsError::date_calculation(ERR_MIDNIGHT_FAILED))?;
228                        midnight
229                            .and_local_timezone(Local)
230                            .single()
231                            .ok_or_else(|| TempsError::ambiguous_time(ERR_AMBIGUOUS_TIME))
232                    }
233                    DayReference::Yesterday => {
234                        let yesterday = now - Duration::days(1);
235                        let midnight = yesterday
236                            .date_naive()
237                            .and_hms_opt(0, 0, 0)
238                            .ok_or_else(|| TempsError::date_calculation(ERR_MIDNIGHT_FAILED))?;
239                        midnight
240                            .and_local_timezone(Local)
241                            .single()
242                            .ok_or_else(|| TempsError::ambiguous_time(ERR_AMBIGUOUS_TIME))
243                    }
244                    DayReference::Tomorrow => {
245                        let tomorrow = now + Duration::days(1);
246                        let midnight = tomorrow
247                            .date_naive()
248                            .and_hms_opt(0, 0, 0)
249                            .ok_or_else(|| TempsError::date_calculation(ERR_MIDNIGHT_FAILED))?;
250                        midnight
251                            .and_local_timezone(Local)
252                            .single()
253                            .ok_or_else(|| TempsError::ambiguous_time(ERR_AMBIGUOUS_TIME))
254                    }
255                    DayReference::Weekday { day, modifier } => {
256                        let target_weekday = match day {
257                            Weekday::Monday => chrono::Weekday::Mon,
258                            Weekday::Tuesday => chrono::Weekday::Tue,
259                            Weekday::Wednesday => chrono::Weekday::Wed,
260                            Weekday::Thursday => chrono::Weekday::Thu,
261                            Weekday::Friday => chrono::Weekday::Fri,
262                            Weekday::Saturday => chrono::Weekday::Sat,
263                            Weekday::Sunday => chrono::Weekday::Sun,
264                        };
265
266                        let current_weekday = now.weekday();
267                        let current_offset = current_weekday.num_days_from_monday() as i64;
268                        let target_offset = target_weekday.num_days_from_monday() as i64;
269
270                        let days_to_add =
271                            calculate_weekday_offset(current_offset, target_offset, modifier);
272                        let target_date = now + Duration::days(days_to_add);
273
274                        let midnight = target_date
275                            .date_naive()
276                            .and_hms_opt(0, 0, 0)
277                            .ok_or_else(|| TempsError::date_calculation(ERR_MIDNIGHT_FAILED))?;
278                        midnight
279                            .and_local_timezone(Local)
280                            .single()
281                            .ok_or_else(|| TempsError::ambiguous_time(ERR_AMBIGUOUS_TIME))
282                    }
283                }
284            }
285            TimeExpression::Time(time) => {
286                let now = self.now();
287                if !is_valid_time(time.hour, time.minute, time.second, time.meridiem) {
288                    return Err(TempsError::invalid_time(
289                        time.hour,
290                        time.minute,
291                        time.second,
292                    ));
293                }
294
295                let hour = convert_12_to_24_hour(time.hour, time.meridiem.as_ref()) as u32;
296
297                Ok(now
298                    .date_naive()
299                    .and_hms_opt(hour, time.minute as u32, time.second as u32)
300                    .ok_or_else(|| TempsError::invalid_time(time.hour, time.minute, time.second))?
301                    .and_local_timezone(Local)
302                    .single()
303                    .ok_or_else(|| TempsError::ambiguous_time("Ambiguous local time"))?)
304            }
305            TimeExpression::DayTime(day_time) => {
306                // First get the day
307                let day_result = self.parse_expression(TimeExpression::Day(day_time.day))?;
308                let date = day_result.date_naive();
309
310                if !is_valid_time(
311                    day_time.time.hour,
312                    day_time.time.minute,
313                    day_time.time.second,
314                    day_time.time.meridiem,
315                ) {
316                    return Err(TempsError::invalid_time(
317                        day_time.time.hour,
318                        day_time.time.minute,
319                        day_time.time.second,
320                    ));
321                }
322
323                let hour =
324                    convert_12_to_24_hour(day_time.time.hour, day_time.time.meridiem.as_ref())
325                        as u32;
326
327                Ok(date
328                    .and_hms_opt(
329                        hour,
330                        day_time.time.minute as u32,
331                        day_time.time.second as u32,
332                    )
333                    .ok_or_else(|| {
334                        TempsError::invalid_time(
335                            day_time.time.hour,
336                            day_time.time.minute,
337                            day_time.time.second,
338                        )
339                    })?
340                    .and_local_timezone(Local)
341                    .single()
342                    .ok_or_else(|| TempsError::ambiguous_time("Ambiguous local time"))?)
343            }
344            TimeExpression::Date(date) => {
345                use chrono::NaiveDate;
346
347                NaiveDate::from_ymd_opt(date.year as i32, date.month as u32, date.day as u32)
348                    .ok_or_else(|| TempsError::invalid_date(date.year, date.month, date.day))?
349                    .and_hms_opt(0, 0, 0)
350                    .ok_or_else(|| TempsError::date_calculation(ERR_MIDNIGHT_FAILED))?
351                    .and_local_timezone(Local)
352                    .single()
353                    .ok_or_else(|| TempsError::ambiguous_time("Ambiguous local time"))
354            }
355        }
356    }
357}
358
359/// Parse a natural language time expression into a chrono `DateTime<Local>`.
360///
361/// This is a convenience function that combines parsing and time calculation
362/// in a single call.
363///
364/// # Arguments
365///
366/// * `input` - The natural language time expression to parse
367/// * `language` - The language to use for parsing
368///
369/// # Returns
370///
371/// Returns `Ok(DateTime<Local>)` if parsing succeeds, or `Err(TempsError)`
372/// if the input cannot be parsed or the date calculation fails.
373///
374/// # Examples
375///
376/// ```
377/// use temps_chrono::parse_to_datetime;
378/// use temps_core::Language;
379///
380/// // Parse English expressions
381/// let dt = parse_to_datetime("in 30 minutes", Language::English).unwrap();
382/// let dt = parse_to_datetime("tomorrow at 12:00", Language::English).unwrap();
383/// let dt = parse_to_datetime("last Monday", Language::English).unwrap();
384///
385/// // Parse German expressions  
386/// let dt = parse_to_datetime("in 30 Minuten", Language::German).unwrap();
387/// let dt = parse_to_datetime("morgen um 15:30", Language::German).unwrap();
388/// ```
389///
390/// # Errors
391///
392/// This function will return an error if:
393/// - The input cannot be parsed as a valid time expression
394/// - Date calculation results in an invalid date
395/// - The resulting time is ambiguous due to DST transitions
396pub fn parse_to_datetime(input: &str, language: Language) -> Result<DateTime<Local>> {
397    let expr = temps_core::parse(input, language)?;
398    ChronoProvider.parse_expression(expr)
399}