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}