1use std::str::FromStr;
20
21use crate::date::{Date, Weekday, YearMonth, YearQuarter};
22
23#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25#[error("invalid date format: \"{0}\"")]
26pub struct DateParseError(pub String);
27
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30#[error("invalid period format: \"{0}\"")]
31pub struct PeriodParseError(pub String);
32
33#[derive(Debug, Copy, Clone, PartialEq, Eq)]
35pub struct Period {
36 pub from: Date,
37 pub to: Date,
38}
39
40impl Period {
41 #[must_use]
42 pub fn new(from: Date, to: Date) -> Self {
43 Self { from, to }
44 }
45
46 #[must_use]
47 pub fn single_day(date: Date) -> Self {
48 Self {
49 from: date,
50 to: date,
51 }
52 }
53
54 #[must_use]
55 pub fn render(&self) -> String {
56 if self.from == self.to {
57 self.from.to_string()
58 } else {
59 format!("{} to {}", self.from, self.to)
60 }
61 }
62
63 #[must_use]
65 pub fn contains(self, date: Date) -> bool {
66 date >= self.from && date <= self.to
67 }
68}
69
70pub fn try_parse_date(input: &str, today: Date) -> Result<Date, DateParseError> {
82 let input = input.trim();
83
84 if let Ok(date) = Date::from_str(input) {
86 return Ok(date);
87 }
88
89 if let Some(date) = parse_last_weekday(input, today) {
91 return Ok(date);
92 }
93
94 if let Some(date) = parse_next_weekday(input, today) {
96 return Ok(date);
97 }
98
99 if let Some(date) = parse_relative(input, today) {
101 return Ok(date);
102 }
103
104 Err(DateParseError(input.to_owned()))
105}
106
107#[must_use]
117pub fn parse_date(input: &str, today: Date) -> Date {
118 try_parse_date(input, today).unwrap_or(today)
119}
120
121pub fn try_parse_period(input: &str, today: Date) -> Result<Period, PeriodParseError> {
130 let input = input.trim();
131
132 if let Some(ym) = YearMonth::parse(input) {
134 return Ok(Period::new(ym.first_day(), ym.last_day()));
135 }
136
137 if let Some(quarter) = YearQuarter::parse(input) {
139 return Ok(Period::new(quarter.first_day(), quarter.last_day()));
140 }
141
142 match input.to_lowercase().as_str() {
144 "last week" => {
145 let week_start = today.latest_sunday().sub_days(6);
146 let week_end = today.latest_sunday();
147 Ok(Period::new(week_start, week_end))
148 }
149 "this week" => {
150 let week_start = today.latest_sunday().add_days(1);
151 let week_end = today.latest_sunday().add_days(7);
152 Ok(Period::new(week_start, week_end))
153 }
154 "last month" => {
155 let last_month_date = today.sub_months(1u32);
156 let month_start = last_month_date.first_of_month();
157 let month_end = month_start.last_day_of_month();
158 Ok(Period::new(month_start, month_end))
159 }
160 "this month" => {
161 let month_start = today.first_of_month();
162 let month_end = month_start.last_day_of_month();
163 Ok(Period::new(month_start, month_end))
164 }
165 "yesterday" => {
166 let yesterday = today.sub_days(1);
167 Ok(Period::single_day(yesterday))
168 }
169 _ => Err(PeriodParseError(input.to_owned())),
170 }
171}
172
173#[must_use]
180pub fn parse_period(input: &str, today: Date) -> Period {
181 try_parse_period(input, today).unwrap_or_else(|_| Period::single_day(today))
182}
183
184fn parse_last_weekday(input: &str, today: Date) -> Option<Date> {
186 let input = input.trim().to_lowercase();
187
188 if !input.starts_with("last ") {
189 return None;
190 }
191
192 let weekday_str = &input[5..]; let weekday = parse_weekday_name(weekday_str)?;
194 Some(today.last_weekday(weekday))
195}
196
197fn parse_next_weekday(input: &str, today: Date) -> Option<Date> {
199 let input = input.trim().to_lowercase();
200
201 if !input.starts_with("next ") {
202 return None;
203 }
204
205 let weekday_str = &input[5..]; let weekday = parse_weekday_name(weekday_str)?;
207 Some(today.next_weekday(weekday))
208}
209
210fn parse_weekday_name(name: &str) -> Option<Weekday> {
212 match name {
213 "monday" => Some(Weekday::Monday),
214 "tuesday" => Some(Weekday::Tuesday),
215 "wednesday" => Some(Weekday::Wednesday),
216 "thursday" => Some(Weekday::Thursday),
217 "friday" => Some(Weekday::Friday),
218 "saturday" => Some(Weekday::Saturday),
219 "sunday" => Some(Weekday::Sunday),
220 _ => None,
221 }
222}
223
224#[derive(Debug, Copy, Clone, PartialEq, Eq)]
226enum Direction {
227 Forward,
228 Backward,
229}
230
231#[derive(Debug, Copy, Clone, PartialEq, Eq)]
233enum TimeUnit {
234 Days,
235 Weeks,
236 Months,
237 Years,
238}
239
240impl TimeUnit {
241 fn parse(unit: &str) -> Option<Self> {
242 match unit {
243 "d" | "day" | "days" => Some(Self::Days),
244 "w" | "week" | "weeks" => Some(Self::Weeks),
245 "month" | "months" => Some(Self::Months),
246 "y" | "year" | "years" => Some(Self::Years),
247 _ => None,
248 }
249 }
250}
251
252#[derive(Debug, Copy, Clone, PartialEq, Eq)]
254struct ParsedDuration {
255 direction: Direction,
256 unit: TimeUnit,
257 count: usize,
258}
259
260impl ParsedDuration {
261 fn apply(self, today: Date) -> Date {
262 let count_u32 = u32::try_from(self.count).unwrap_or(u32::MAX);
263 match (self.direction, self.unit) {
264 (Direction::Forward, TimeUnit::Days) => today.add_days(self.count),
265 (Direction::Forward, TimeUnit::Weeks) => today.add_days(self.count * 7),
266 (Direction::Forward, TimeUnit::Months) => today.add_months(count_u32),
267 (Direction::Forward, TimeUnit::Years) => today.add_years(count_u32),
268 (Direction::Backward, TimeUnit::Days) => today.sub_days(self.count),
269 (Direction::Backward, TimeUnit::Weeks) => today.sub_days(self.count * 7),
270 (Direction::Backward, TimeUnit::Months) => today.sub_months(count_u32),
271 (Direction::Backward, TimeUnit::Years) => today.sub_years(count_u32),
272 }
273 }
274}
275
276fn parse_duration(input: &str) -> Option<ParsedDuration> {
281 let input = input.trim().to_lowercase();
282
283 let (direction, middle) = if let Some(rest) = input.strip_prefix("in ") {
284 (Direction::Forward, rest.trim())
285 } else if let Some(rest) = input.strip_suffix(" ago") {
286 (Direction::Backward, rest.trim())
287 } else {
288 return None;
289 };
290
291 let (count, unit) = parse_count_and_unit(middle)?;
292
293 Some(ParsedDuration {
294 direction,
295 unit,
296 count,
297 })
298}
299
300fn parse_count_and_unit(input: &str) -> Option<(usize, TimeUnit)> {
302 let parts: Vec<&str> = input.split_whitespace().collect();
303
304 if parts.len() == 2 {
305 let count: usize = parts[0].parse().ok()?;
307 let unit = TimeUnit::parse(parts[1])?;
308 Some((count, unit))
309 } else if parts.len() == 1 {
310 let s = parts[0];
312 let num_end = s.find(|c: char| !c.is_ascii_digit())?;
313 let count: usize = s[..num_end].parse().ok()?;
314 let unit_str = &s[num_end..];
315 if unit_str.is_empty() {
316 return None;
317 }
318 let unit = TimeUnit::parse(unit_str)?;
319 Some((count, unit))
320 } else {
321 None
322 }
323}
324
325fn parse_relative(input: &str, today: Date) -> Option<Date> {
331 let input = input.trim().to_lowercase();
332
333 if input == "today" {
335 return Some(today);
336 }
337 if input == "yesterday" {
338 return Some(today.sub_days(1));
339 }
340 if input == "tomorrow" {
341 return Some(today.add_days(1));
342 }
343
344 if input == "next week" {
346 return Some(today.add_days(7));
347 }
348 if input == "next month" {
349 return Some(today.add_months(1u32));
350 }
351
352 if let Some(duration) = parse_duration(&input) {
354 return Some(duration.apply(today));
355 }
356
357 None
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use pretty_assertions::assert_eq;
364 use rstest::rstest;
365
366 use crate::date::Weekday;
367
368 const TODAY: &str = "2026-01-13"; fn today() -> Date {
371 Date::from_str_unchecked(TODAY)
372 }
373
374 #[test]
379 fn period_render_single_day() {
380 let period = Period::single_day(today());
381 assert_eq!(period.render(), "2026-01-13");
382 }
383
384 #[test]
385 fn period_render_range() {
386 let from = Date::from_str_unchecked("2026-01-01");
387 let to = Date::from_str_unchecked("2026-01-31");
388 let period = Period::new(from, to);
389 assert_eq!(period.render(), "2026-01-01 to 2026-01-31");
390 }
391
392 #[rstest]
393 #[case::inside_range("2026-01-15", true)]
394 #[case::at_from_edge("2026-01-01", true)]
395 #[case::at_to_edge("2026-01-31", true)]
396 #[case::before_range("2025-12-31", false)]
397 #[case::after_range("2026-02-01", false)]
398 fn period_contains(#[case] date: &str, #[case] expected: bool) {
399 let period = Period::new(
400 Date::from_str_unchecked("2026-01-01"),
401 Date::from_str_unchecked("2026-01-31"),
402 );
403 let date = Date::from_str_unchecked(date);
404 assert_eq!(period.contains(date), expected);
405 }
406
407 #[rstest]
412 #[case::iso_date("2025-12-31", "2025-12-31")]
413 #[case::today("today", "2026-01-13")]
415 #[case::yesterday("yesterday", "2026-01-12")]
417 #[case::one_week_ago("1 week ago", "2026-01-06")]
418 #[case::two_weeks_ago("2 weeks ago", "2025-12-30")]
419 #[case::one_month_ago("1 month ago", "2025-12-13")]
420 #[case::two_months_ago("2 months ago", "2025-11-13")]
421 #[case::one_year_ago("1 year ago", "2025-01-13")]
422 #[case::two_years_ago("2 years ago", "2024-01-13")]
423 #[case::one_y_ago("1y ago", "2025-01-13")]
424 #[case::one_day_ago("1 day ago", "2026-01-12")]
425 #[case::three_days_ago("3 days ago", "2026-01-10")]
426 #[case::seven_d_ago("7d ago", "2026-01-06")]
427 #[case::thirty_d_ago("30d ago", "2025-12-14")]
428 #[case::tomorrow("tomorrow", "2026-01-14")]
430 #[case::in_3_days("in 3 days", "2026-01-16")]
431 #[case::in_7d("in 7d", "2026-01-20")]
432 #[case::in_2_weeks("in 2 weeks", "2026-01-27")]
433 #[case::in_1_year("in 1 year", "2027-01-13")]
434 #[case::in_2y("in 2y", "2028-01-13")]
435 #[case::next_week("next week", "2026-01-20")]
436 #[case::next_month("next month", "2026-02-13")]
437 fn parse_date_formats(#[case] input: &str, #[case] expected: &str) {
438 let expected = Date::from_str_unchecked(expected);
439 assert_eq!(parse_date(input, today()), expected);
440 }
441
442 #[rstest]
443 #[case::bare_7d("7d")]
444 #[case::bare_30d("30d")]
445 #[case::bare_1d("1d")]
446 #[case::year_month("2025-12")] fn try_parse_date_returns_error(#[case] input: &str) {
448 assert!(try_parse_date(input, today()).is_err());
449 }
450
451 #[rstest]
452 #[case::last_friday("last friday", "2026-01-09")]
454 #[case::last_monday("last monday", "2026-01-12")]
456 #[case::last_sunday("last sunday", "2026-01-11")]
458 #[case::last_saturday("last saturday", "2026-01-10")]
460 #[case::last_tuesday("last tuesday", "2026-01-06")]
462 #[case::last_wednesday("last wednesday", "2026-01-07")]
464 #[case::last_thursday("last thursday", "2026-01-08")]
466 fn parse_date_last_weekday(#[case] input: &str, #[case] expected: &str) {
467 let expected = Date::from_str_unchecked(expected);
468 assert_eq!(parse_date(input, today()), expected);
469 }
470
471 #[rstest]
472 #[case::next_friday("next friday", "2026-01-16")]
474 #[case::next_monday("next monday", "2026-01-19")]
476 #[case::next_sunday("next sunday", "2026-01-18")]
478 #[case::next_saturday("next saturday", "2026-01-17")]
480 #[case::next_tuesday("next tuesday", "2026-01-20")]
482 #[case::next_wednesday("next wednesday", "2026-01-14")]
484 #[case::next_thursday("next thursday", "2026-01-15")]
486 fn parse_date_next_weekday(#[case] input: &str, #[case] expected: &str) {
487 let expected = Date::from_str_unchecked(expected);
488 assert_eq!(parse_date(input, today()), expected);
489 }
490
491 #[test]
492 fn parse_date_invalid_returns_today() {
493 assert_eq!(parse_date("invalid", today()), today());
494 assert_eq!(parse_date("", today()), today());
495 }
496
497 #[rstest]
502 #[case::year_month("2025-12", "2025-12-01", "2025-12-31")]
503 #[case::year_month_feb("2025-02", "2025-02-01", "2025-02-28")]
504 #[case::year_month_feb_leap("2024-02", "2024-02-01", "2024-02-29")]
505 fn parse_period_year_month(
506 #[case] input: &str,
507 #[case] expected_from: &str,
508 #[case] expected_to: &str,
509 ) {
510 let period = parse_period(input, today());
511 assert_eq!(period.from, Date::from_str_unchecked(expected_from));
512 assert_eq!(period.to, Date::from_str_unchecked(expected_to));
513 }
514
515 #[rstest]
516 #[case::q1("2025-Q1", "2025-01-01", "2025-03-31")]
517 #[case::q2("2025-Q2", "2025-04-01", "2025-06-30")]
518 #[case::q3("2025-Q3", "2025-07-01", "2025-09-30")]
519 #[case::q4("2025-Q4", "2025-10-01", "2025-12-31")]
520 #[case::lowercase("2025-q1", "2025-01-01", "2025-03-31")]
521 fn parse_period_quarter(
522 #[case] input: &str,
523 #[case] expected_from: &str,
524 #[case] expected_to: &str,
525 ) {
526 let period = parse_period(input, today());
527 assert_eq!(period.from, Date::from_str_unchecked(expected_from));
528 assert_eq!(period.to, Date::from_str_unchecked(expected_to));
529 }
530
531 #[test]
532 fn parse_period_this_month() {
533 let period = parse_period("this month", today());
535 assert_eq!(period.from, Date::from_str_unchecked("2026-01-01"));
536 assert_eq!(period.to, Date::from_str_unchecked("2026-01-31"));
537 }
538
539 #[test]
540 fn parse_period_last_month() {
541 let period = parse_period("last month", today());
543 assert_eq!(period.from, Date::from_str_unchecked("2025-12-01"));
544 assert_eq!(period.to, Date::from_str_unchecked("2025-12-31"));
545 }
546
547 #[test]
548 fn parse_period_this_week() {
549 let period = parse_period("this week", today());
553 assert_eq!(period.from, Date::from_str_unchecked("2026-01-12"));
554 assert_eq!(period.to, Date::from_str_unchecked("2026-01-18"));
555 }
556
557 #[test]
558 fn parse_period_last_week() {
559 let period = parse_period("last week", today());
563 assert_eq!(period.from, Date::from_str_unchecked("2026-01-05"));
564 assert_eq!(period.to, Date::from_str_unchecked("2026-01-11"));
565 }
566
567 #[test]
568 fn parse_period_yesterday() {
569 let period = parse_period("yesterday", today());
570 assert_eq!(period.from, Date::from_str_unchecked("2026-01-12"));
571 assert_eq!(period.to, Date::from_str_unchecked("2026-01-12"));
572 }
573
574 #[test]
575 fn parse_period_invalid_returns_today() {
576 let period = parse_period("invalid", today());
577 assert_eq!(period.from, today());
578 assert_eq!(period.to, today());
579 }
580
581 #[rstest]
586 #[case::january("2025-01-15", "2025-01-31")]
587 #[case::february_non_leap("2025-02-10", "2025-02-28")]
588 #[case::february_leap("2024-02-10", "2024-02-29")]
589 #[case::march("2025-03-01", "2025-03-31")]
590 #[case::april("2025-04-30", "2025-04-30")]
591 #[case::june("2025-06-15", "2025-06-30")]
592 #[case::december("2025-12-01", "2025-12-31")]
593 #[case::already_last_day("2025-01-31", "2025-01-31")]
594 fn last_day_of_month(#[case] input: &str, #[case] expected: &str) {
595 let date = Date::from_str_unchecked(input);
596 let expected = Date::from_str_unchecked(expected);
597 assert_eq!(date.last_day_of_month(), expected);
598 }
599
600 #[rstest]
605 #[case::last_friday(Weekday::Friday, "2026-01-09")]
607 #[case::last_monday(Weekday::Monday, "2026-01-12")]
609 #[case::last_sunday(Weekday::Sunday, "2026-01-11")]
611 #[case::last_saturday(Weekday::Saturday, "2026-01-10")]
613 #[case::last_tuesday(Weekday::Tuesday, "2026-01-06")]
615 #[case::last_wednesday(Weekday::Wednesday, "2026-01-07")]
617 #[case::last_thursday(Weekday::Thursday, "2026-01-08")]
619 fn last_weekday(#[case] weekday: Weekday, #[case] expected: &str) {
620 let expected = Date::from_str_unchecked(expected);
621 assert_eq!(today().last_weekday(weekday), expected);
622 }
623
624 #[rstest]
629 #[case::one_month("2026-01-13", 1, "2025-12-13")]
630 #[case::two_months("2026-01-13", 2, "2025-11-13")]
631 #[case::twelve_months("2026-01-13", 12, "2025-01-13")]
632 #[case::across_year("2025-03-15", 4, "2024-11-15")]
633 #[case::month_end("2025-01-31", 1, "2024-12-31")]
635 #[case::overflow_to_feb("2025-03-31", 1, "2025-02-28")]
637 #[case::overflow_to_feb_leap("2024-03-31", 1, "2024-02-29")]
638 fn sub_months(#[case] input: &str, #[case] months: u32, #[case] expected: &str) {
639 let date = Date::from_str_unchecked(input);
640 let expected = Date::from_str_unchecked(expected);
641 assert_eq!(date.sub_months(months), expected);
642 }
643
644 #[rstest]
645 #[case::one_year("2026-01-13", 1, "2025-01-13")]
646 #[case::two_years("2026-01-13", 2, "2024-01-13")]
647 #[case::ten_years("2026-01-13", 10, "2016-01-13")]
648 #[case::leap_day_to_non_leap("2024-02-29", 1, "2023-02-28")]
650 #[case::leap_day_to_leap("2024-02-29", 4, "2020-02-29")]
652 fn sub_years(#[case] input: &str, #[case] years: u32, #[case] expected: &str) {
653 let date = Date::from_str_unchecked(input);
654 let expected = Date::from_str_unchecked(expected);
655 assert_eq!(date.sub_years(years), expected);
656 }
657}