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}