human_date_parser/
lib.rs

1use std::fmt::Display;
2
3use ast::{
4    build_ast_from, Ago, Date, DateTime, Duration as AstDuration, In, IsoDate, Quantifier,
5    RelativeSpecifier, Time, TimeUnit,
6};
7use chrono::{
8    Datelike, Days, Duration as ChronoDuration, Month, Months, NaiveDate, NaiveDateTime,
9    NaiveTime, Weekday,
10};
11use thiserror::Error;
12
13mod ast;
14#[cfg(test)]
15mod tests;
16
17#[derive(Debug, Error)]
18pub enum ParseError {
19    #[error("Could not match input to any known format")]
20    InvalidFormat,
21    #[error("One or more errors occured when processing input")]
22    ProccessingErrors(Vec<ProcessingError>),
23    #[error(
24        "An internal library error occured. This should not happen. Please report it. Error: {0}"
25    )]
26    InternalError(#[from] InternalError),
27}
28
29#[derive(Debug, Error)]
30pub enum ProcessingError {
31    #[error("Could not build time from {hour}:{minute}")]
32    TimeHourMinute { hour: u32, minute: u32 },
33    #[error("Could not build time from {hour}:{minute}:{second}")]
34    TimeHourMinuteSecond { hour: u32, minute: u32, second: u32 },
35    #[error("Failed to add {count} {unit} to the current time")]
36    AddToNow { unit: String, count: u32 },
37    #[error("Failed to subtract {count} {unit} from the current time")]
38    SubtractFromNow { unit: String, count: u32 },
39    #[error("Failed to subtract {count} {unit} from {date}")]
40    SubtractFromDate {
41        unit: String,
42        count: u32,
43        date: NaiveDateTime,
44    },
45    #[error("Failed to add {count} {unit} to {date}")]
46    AddToDate {
47        unit: String,
48        count: u32,
49        date: NaiveDateTime,
50    },
51    #[error("{year}-{month}-{day} is not a valid date")]
52    InvalidDate { year: i32, month: u32, day: u32 },
53    #[error("Failed to parse inner human time: {0}")]
54    InnerHumanTimeParse(Box<ParseError>),
55}
56
57#[derive(Debug, Error)]
58pub enum InternalError {
59    #[error("Failed to build AST. This is a bug.")]
60    FailedToBuildAst,
61}
62
63#[derive(Debug)]
64pub enum ParseResult {
65    DateTime(NaiveDateTime),
66    Date(NaiveDate),
67    Time(NaiveTime),
68}
69
70impl Display for ParseResult {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            ParseResult::DateTime(datetime) => write!(f, "{}", datetime),
74            ParseResult::Date(date) => write!(f, "{}", date),
75            ParseResult::Time(time) => write!(f, "{}", time),
76        }
77    }
78}
79
80/// Parses a human-readable date or time string and converts it into a structured date/time format.
81///
82/// This function takes a string representing a human-readable date/time expression (e.g.,
83/// "Last Friday at 19:45") and attempts to parse it into one of three possible formats:
84/// `NaiveDateTime`, `NaiveDate`, or `NaiveTime`. The function requires a reference date (`now`)
85/// to properly resolve relative time expressions.
86///
87/// # Parameters
88///
89/// - `str`: A human-readable date/time string (e.g., "yesterday", "next Monday at 14:00").
90/// - `now`: The reference `NaiveDateTime` representing the current time, used for resolving
91///   relative expressions like "yesterday" or "next week".
92///
93/// # Returns
94///
95/// - `Ok(ParseResult::DateTime(dt))` if the input string represents a full date and time.
96/// - `Ok(ParseResult::Date(d))` if the input string represents only a date.
97/// - `Ok(ParseResult::Time(t))` if the input string represents only a time.
98/// - `Err(ParseError)` if parsing fails due to an unrecognized or invalid format.
99///
100/// # Errors
101///
102/// This function returns an error if the input string contains values that cannot be parsed
103/// into a valid date or time.
104///
105/// # Examples
106///
107/// ```
108/// use chrono::Local;
109/// use human_date_parser::{from_human_time, ParseResult};
110///
111/// let now = Local::now().naive_local();
112/// let date = from_human_time("Last Friday at 19:45", now).unwrap();
113///
114/// match date {
115///     ParseResult::DateTime(date) => println!("{date}"),
116///     _ => unreachable!(),
117/// }
118/// ```
119///
120/// ```
121/// use chrono::Local;
122/// use human_date_parser::{from_human_time, ParseResult};
123///
124/// let now = Local::now().naive_local();
125/// let date = from_human_time("Next Monday", now).unwrap();
126///
127/// match date {
128///     ParseResult::Date(date) => println!("{date}"),
129///     _ => unreachable!(),
130/// }
131/// ```
132pub fn from_human_time(str: &str, now: NaiveDateTime) -> Result<ParseResult, ParseError> {
133    let lowercase = str.to_lowercase();
134    let parsed = build_ast_from(&lowercase)?;
135
136    parse_human_time(parsed, now)
137}
138
139fn parse_human_time(parsed: ast::HumanTime, now: NaiveDateTime) -> Result<ParseResult, ParseError> {
140    match parsed {
141        ast::HumanTime::DateTime(date_time) => {
142            parse_date_time(date_time, &now).map(|dt| ParseResult::DateTime(dt))
143        }
144        ast::HumanTime::Date(date) => parse_date(date, &now)
145            .map(|date| ParseResult::Date(date))
146            .map_err(|err| ParseError::ProccessingErrors(vec![err])),
147        ast::HumanTime::Time(time) => parse_time(time)
148            .map(|time| ParseResult::Time(time))
149            .map_err(|err| ParseError::ProccessingErrors(vec![err])),
150        ast::HumanTime::In(in_ast) => parse_in(in_ast, &now)
151            .map(|time| ParseResult::DateTime(time))
152            .map_err(|err| ParseError::ProccessingErrors(vec![err])),
153        ast::HumanTime::Ago(ago) => parse_ago(ago, &now)
154            .map(|time| ParseResult::DateTime(time))
155            .map_err(|err| ParseError::ProccessingErrors(vec![err])),
156        ast::HumanTime::Now => Ok(ParseResult::DateTime(now)),
157    }
158}
159
160fn parse_date_time(date_time: DateTime, now: &NaiveDateTime) -> Result<NaiveDateTime, ParseError> {
161    let date = parse_date(date_time.date, now);
162    let time = parse_time(date_time.time);
163
164    match (date, time) {
165        (Ok(date), Ok(time)) => Ok(NaiveDateTime::new(date, time)),
166        (Ok(_), Err(time_error)) => Err(ParseError::ProccessingErrors(vec![time_error])),
167        (Err(date_error), Ok(_)) => Err(ParseError::ProccessingErrors(vec![date_error])),
168        (Err(date_error), Err(time_error)) => {
169            Err(ParseError::ProccessingErrors(vec![date_error, time_error]))
170        }
171    }
172}
173
174fn parse_date(date: Date, now: &NaiveDateTime) -> Result<NaiveDate, ProcessingError> {
175    match date {
176        Date::Today => Ok(now.date()),
177        Date::Tomorrow => {
178            now.date()
179                .checked_add_days(Days::new(1))
180                .ok_or(ProcessingError::AddToNow {
181                    unit: String::from("days"),
182                    count: 1,
183                })
184        }
185        Date::Overmorrow => {
186            now.date()
187                .checked_add_days(Days::new(2))
188                .ok_or(ProcessingError::AddToNow {
189                    unit: String::from("days"),
190                    count: 2,
191                })
192        }
193        Date::Yesterday => {
194            now.date()
195                .checked_sub_days(Days::new(1))
196                .ok_or(ProcessingError::SubtractFromNow {
197                    unit: String::from("days"),
198                    count: 1,
199                })
200        }
201        Date::IsoDate(iso_date) => parse_iso_date(iso_date),
202        Date::DayMonthYear(day, month, year) => parse_day_month_year(day, month, year as i32),
203        Date::DayMonth(day, month) => parse_day_month_year(day, month, now.year()),
204        Date::RelativeWeekWeekday(relative, weekday) => {
205            find_weekday_relative_week(relative, weekday.into(), now.date())
206        }
207        Date::RelativeWeekday(relative, weekday) => {
208            find_weekday_relative(relative, weekday.into(), now.date())
209        }
210        Date::RelativeTimeUnit(relative, time_unit) => {
211            Ok(relative_date_time_unit(relative, time_unit, now.clone())?.date())
212        }
213        Date::UpcomingWeekday(weekday) => {
214            find_weekday_relative(RelativeSpecifier::Next, weekday.into(), now.date())
215        }
216    }
217}
218
219fn parse_iso_date(iso_date: IsoDate) -> Result<NaiveDate, ProcessingError> {
220    let (year, month, day) = (iso_date.year as i32, iso_date.month, iso_date.day);
221    NaiveDate::from_ymd_opt(year, month, day).ok_or(ProcessingError::InvalidDate {
222        year,
223        month,
224        day,
225    })
226}
227
228fn parse_day_month_year(day: u32, month: Month, year: i32) -> Result<NaiveDate, ProcessingError> {
229    let month = month.number_from_month();
230    NaiveDate::from_ymd_opt(year, month, day).ok_or(ProcessingError::InvalidDate {
231        year,
232        month,
233        day,
234    })
235}
236
237fn parse_time(time: Time) -> Result<NaiveTime, ProcessingError> {
238    match time {
239        Time::HourMinute(hour, minute) => NaiveTime::from_hms_opt(hour, minute, 0)
240            .ok_or(ProcessingError::TimeHourMinute { hour, minute }),
241        Time::HourMinuteSecond(hour, minute, second) => NaiveTime::from_hms_opt(
242            hour, minute, second,
243        )
244        .ok_or(ProcessingError::TimeHourMinuteSecond {
245            hour,
246            minute,
247            second,
248        }),
249    }
250}
251
252fn parse_in(in_ast: In, now: &NaiveDateTime) -> Result<NaiveDateTime, ProcessingError> {
253    let dt = now.clone();
254    apply_duration(in_ast.0, dt, Direction::Forwards)
255}
256
257fn parse_ago(ago: Ago, now: &NaiveDateTime) -> Result<NaiveDateTime, ProcessingError> {
258    match ago {
259        Ago::AgoFromNow(ago) => {
260            let dt = now.clone();
261            apply_duration(ago, dt, Direction::Backwards)
262        }
263        Ago::AgoFromTime(ago, time) => {
264            let human_time = parse_human_time(*time, now.clone())
265                .map_err(|e| ProcessingError::InnerHumanTimeParse(Box::new(e)))?;
266            let dt = match human_time {
267                ParseResult::DateTime(dt) => dt,
268                ParseResult::Date(date) => NaiveDateTime::new(date, now.time()),
269                ParseResult::Time(time) => NaiveDateTime::new(now.date(), time),
270            };
271            apply_duration(ago, dt, Direction::Backwards)
272        }
273    }
274}
275
276#[derive(PartialEq, Eq)]
277enum Direction {
278    Forwards,
279    Backwards,
280}
281
282fn apply_duration(
283    duration: AstDuration,
284    mut dt: NaiveDateTime,
285    direction: Direction,
286) -> Result<NaiveDateTime, ProcessingError> {
287    for quant in duration.0 {
288        match quant {
289            Quantifier::Year(years) => {
290                let years = years as i32;
291                if direction == Direction::Forwards {
292                    dt = dt
293                        .with_year(dt.year() + years)
294                        .ok_or(ProcessingError::InvalidDate {
295                            year: dt.year() + years,
296                            month: dt.month(),
297                            day: dt.day(),
298                        })?;
299                } else {
300                    dt = dt
301                        .with_year(dt.year() - years)
302                        .ok_or(ProcessingError::InvalidDate {
303                            year: dt.year() - years,
304                            month: dt.month(),
305                            day: dt.day(),
306                        })?;
307                }
308            }
309            Quantifier::Month(months) => {
310                if direction == Direction::Forwards {
311                    dt = dt.checked_add_months(Months::new(months)).ok_or(
312                        ProcessingError::AddToDate {
313                            unit: "months".to_string(),
314                            count: months,
315                            date: dt,
316                        },
317                    )?
318                } else {
319                    dt = dt.checked_sub_months(Months::new(months)).ok_or(
320                        ProcessingError::SubtractFromDate {
321                            unit: "months".to_string(),
322                            count: months,
323                            date: dt,
324                        },
325                    )?
326                }
327            }
328            Quantifier::Week(weeks) => {
329                if direction == Direction::Forwards {
330                    dt = dt.checked_add_days(Days::new(weeks as u64 * 7)).ok_or(
331                        ProcessingError::AddToDate {
332                            unit: "weeks".to_string(),
333                            count: weeks,
334                            date: dt,
335                        },
336                    )?
337                } else {
338                    dt = dt.checked_sub_days(Days::new(weeks as u64 * 7)).ok_or(
339                        ProcessingError::AddToDate {
340                            unit: "weeks".to_string(),
341                            count: weeks,
342                            date: dt,
343                        },
344                    )?
345                }
346            }
347            Quantifier::Day(days) => {
348                if direction == Direction::Forwards {
349                    dt = dt.checked_add_days(Days::new(days as u64)).ok_or(
350                        ProcessingError::AddToDate {
351                            unit: "days".to_string(),
352                            count: days,
353                            date: dt,
354                        },
355                    )?
356                } else {
357                    dt = dt.checked_sub_days(Days::new(days as u64)).ok_or(
358                        ProcessingError::AddToDate {
359                            unit: "days".to_string(),
360                            count: days,
361                            date: dt,
362                        },
363                    )?
364                }
365            }
366            Quantifier::Hour(hours) => {
367                if direction == Direction::Forwards {
368                    dt = dt + ChronoDuration::hours(hours as i64)
369                } else {
370                    dt = dt - ChronoDuration::hours(hours as i64)
371                }
372            }
373            Quantifier::Minute(minutes) => {
374                if direction == Direction::Forwards {
375                    dt = dt + ChronoDuration::minutes(minutes as i64)
376                } else {
377                    dt = dt - ChronoDuration::minutes(minutes as i64)
378                }
379            }
380            Quantifier::Second(seconds) => {
381                if direction == Direction::Forwards {
382                    dt = dt + ChronoDuration::seconds(seconds as i64)
383                } else {
384                    dt = dt - ChronoDuration::seconds(seconds as i64)
385                }
386            }
387        };
388    }
389
390    Ok(dt)
391}
392
393fn relative_date_time_unit(
394    relative: RelativeSpecifier,
395    time_unit: TimeUnit,
396    now: NaiveDateTime,
397) -> Result<NaiveDateTime, ProcessingError> {
398    let quantifier = match time_unit {
399        TimeUnit::Year => Quantifier::Year(1),
400        TimeUnit::Month => Quantifier::Month(1),
401        TimeUnit::Week => Quantifier::Week(1),
402        TimeUnit::Day => Quantifier::Day(1),
403        TimeUnit::Hour | TimeUnit::Minute | TimeUnit::Second => {
404            unreachable!("Non-date time units should never be used in this function.")
405        }
406    };
407
408
409    match relative {
410        RelativeSpecifier::This => Ok(now),
411        RelativeSpecifier::Next => apply_duration(AstDuration(vec![quantifier]), now, Direction::Forwards),
412        RelativeSpecifier::Last => apply_duration(AstDuration(vec![quantifier]), now, Direction::Backwards),
413    }
414}
415
416fn find_weekday_relative_week(
417    relative: RelativeSpecifier,
418    weekday: Weekday,
419    now: NaiveDate,
420) -> Result<NaiveDate, ProcessingError> {
421    let day_offset = -(now.weekday().num_days_from_monday() as i64);
422    let week_offset = match relative {
423        RelativeSpecifier::This => 0,
424        RelativeSpecifier::Next => 1,
425        RelativeSpecifier::Last => -1,
426    } * 7;
427    let offset = day_offset + week_offset;
428
429    let now = if offset.is_positive() {
430        now.checked_add_days(Days::new(offset.unsigned_abs()))
431            .ok_or(ProcessingError::AddToNow {
432                unit: "days".to_string(),
433                count: offset.unsigned_abs() as u32,
434            })?
435    } else {
436        now.checked_sub_days(Days::new(offset.unsigned_abs()))
437            .ok_or(ProcessingError::SubtractFromNow {
438                unit: "days".to_string(),
439                count: offset.unsigned_abs() as u32,
440            })?
441    };
442
443    find_weekday_relative(RelativeSpecifier::This, weekday, now)
444}
445
446fn find_weekday_relative(
447    relative: RelativeSpecifier,
448    weekday: Weekday,
449    now: NaiveDate,
450) -> Result<NaiveDate, ProcessingError> {
451    match relative {
452        RelativeSpecifier::This | RelativeSpecifier::Next => {
453            if matches!(relative, RelativeSpecifier::This) && now.weekday() == weekday {
454                return Ok(now.clone());
455            }
456
457            let current_weekday = now.weekday().num_days_from_monday();
458            let target_weekday = weekday.num_days_from_monday();
459
460            let offset = if target_weekday > current_weekday {
461                target_weekday - current_weekday
462            } else {
463                7 - current_weekday + target_weekday
464            };
465
466            now.checked_add_days(Days::new(offset as u64))
467                .ok_or(ProcessingError::AddToNow {
468                    unit: "days".to_string(),
469                    count: offset,
470                })
471        }
472        RelativeSpecifier::Last => {
473            let current_weekday = now.weekday().num_days_from_monday();
474            let target_weekday = weekday.num_days_from_monday();
475
476            let offset = if target_weekday >= current_weekday {
477                7 + current_weekday - target_weekday
478            } else {
479                current_weekday - target_weekday
480            };
481
482            now.checked_sub_days(Days::new(offset as u64))
483                .ok_or(ProcessingError::SubtractFromNow {
484                    unit: "days".to_string(),
485                    count: offset,
486                })
487        }
488    }
489}