use std::str::FromStr;
use crate::date::{Date, Weekday, YearMonth, YearQuarter};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid date format: \"{0}\"")]
pub struct DateParseError(pub String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid period format: \"{0}\"")]
pub struct PeriodParseError(pub String);
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Period {
pub from: Date,
pub to: Date,
}
impl Period {
#[must_use]
pub fn new(from: Date, to: Date) -> Self {
Self { from, to }
}
#[must_use]
pub fn single_day(date: Date) -> Self {
Self {
from: date,
to: date,
}
}
#[must_use]
pub fn render(&self) -> String {
if self.from == self.to {
self.from.to_string()
} else {
format!("{} to {}", self.from, self.to)
}
}
#[must_use]
pub fn contains(self, date: Date) -> bool {
date >= self.from && date <= self.to
}
}
pub fn try_parse_date(input: &str, today: Date) -> Result<Date, DateParseError> {
let input = input.trim();
if let Ok(date) = Date::from_str(input) {
return Ok(date);
}
if let Some(date) = parse_last_weekday(input, today) {
return Ok(date);
}
if let Some(date) = parse_next_weekday(input, today) {
return Ok(date);
}
if let Some(date) = parse_relative(input, today) {
return Ok(date);
}
Err(DateParseError(input.to_owned()))
}
#[must_use]
pub fn parse_date(input: &str, today: Date) -> Date {
try_parse_date(input, today).unwrap_or(today)
}
pub fn try_parse_period(input: &str, today: Date) -> Result<Period, PeriodParseError> {
let input = input.trim();
if let Some(ym) = YearMonth::parse(input) {
return Ok(Period::new(ym.first_day(), ym.last_day()));
}
if let Some(quarter) = YearQuarter::parse(input) {
return Ok(Period::new(quarter.first_day(), quarter.last_day()));
}
match input.to_lowercase().as_str() {
"last week" => {
let week_start = today.latest_sunday().sub_days(6);
let week_end = today.latest_sunday();
Ok(Period::new(week_start, week_end))
}
"this week" => {
let week_start = today.latest_sunday().add_days(1);
let week_end = today.latest_sunday().add_days(7);
Ok(Period::new(week_start, week_end))
}
"last month" => {
let last_month_date = today.sub_months(1u32);
let month_start = last_month_date.first_of_month();
let month_end = month_start.last_day_of_month();
Ok(Period::new(month_start, month_end))
}
"this month" => {
let month_start = today.first_of_month();
let month_end = month_start.last_day_of_month();
Ok(Period::new(month_start, month_end))
}
"yesterday" => {
let yesterday = today.sub_days(1);
Ok(Period::single_day(yesterday))
}
_ => Err(PeriodParseError(input.to_owned())),
}
}
#[must_use]
pub fn parse_period(input: &str, today: Date) -> Period {
try_parse_period(input, today).unwrap_or_else(|_| Period::single_day(today))
}
fn parse_last_weekday(input: &str, today: Date) -> Option<Date> {
let input = input.trim().to_lowercase();
if !input.starts_with("last ") {
return None;
}
let weekday_str = &input[5..]; let weekday = parse_weekday_name(weekday_str)?;
Some(today.last_weekday(weekday))
}
fn parse_next_weekday(input: &str, today: Date) -> Option<Date> {
let input = input.trim().to_lowercase();
if !input.starts_with("next ") {
return None;
}
let weekday_str = &input[5..]; let weekday = parse_weekday_name(weekday_str)?;
Some(today.next_weekday(weekday))
}
fn parse_weekday_name(name: &str) -> Option<Weekday> {
match name {
"monday" => Some(Weekday::Monday),
"tuesday" => Some(Weekday::Tuesday),
"wednesday" => Some(Weekday::Wednesday),
"thursday" => Some(Weekday::Thursday),
"friday" => Some(Weekday::Friday),
"saturday" => Some(Weekday::Saturday),
"sunday" => Some(Weekday::Sunday),
_ => None,
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum Direction {
Forward,
Backward,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum TimeUnit {
Days,
Weeks,
Months,
Years,
}
impl TimeUnit {
fn parse(unit: &str) -> Option<Self> {
match unit {
"d" | "day" | "days" => Some(Self::Days),
"w" | "week" | "weeks" => Some(Self::Weeks),
"month" | "months" => Some(Self::Months),
"y" | "year" | "years" => Some(Self::Years),
_ => None,
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct ParsedDuration {
direction: Direction,
unit: TimeUnit,
count: usize,
}
impl ParsedDuration {
fn apply(self, today: Date) -> Date {
let count_u32 = u32::try_from(self.count).unwrap_or(u32::MAX);
match (self.direction, self.unit) {
(Direction::Forward, TimeUnit::Days) => today.add_days(self.count),
(Direction::Forward, TimeUnit::Weeks) => today.add_days(self.count * 7),
(Direction::Forward, TimeUnit::Months) => today.add_months(count_u32),
(Direction::Forward, TimeUnit::Years) => today.add_years(count_u32),
(Direction::Backward, TimeUnit::Days) => today.sub_days(self.count),
(Direction::Backward, TimeUnit::Weeks) => today.sub_days(self.count * 7),
(Direction::Backward, TimeUnit::Months) => today.sub_months(count_u32),
(Direction::Backward, TimeUnit::Years) => today.sub_years(count_u32),
}
}
}
fn parse_duration(input: &str) -> Option<ParsedDuration> {
let input = input.trim().to_lowercase();
let (direction, middle) = if let Some(rest) = input.strip_prefix("in ") {
(Direction::Forward, rest.trim())
} else if let Some(rest) = input.strip_suffix(" ago") {
(Direction::Backward, rest.trim())
} else {
return None;
};
let (count, unit) = parse_count_and_unit(middle)?;
Some(ParsedDuration {
direction,
unit,
count,
})
}
fn parse_count_and_unit(input: &str) -> Option<(usize, TimeUnit)> {
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.len() == 2 {
let count: usize = parts[0].parse().ok()?;
let unit = TimeUnit::parse(parts[1])?;
Some((count, unit))
} else if parts.len() == 1 {
let s = parts[0];
let num_end = s.find(|c: char| !c.is_ascii_digit())?;
let count: usize = s[..num_end].parse().ok()?;
let unit_str = &s[num_end..];
if unit_str.is_empty() {
return None;
}
let unit = TimeUnit::parse(unit_str)?;
Some((count, unit))
} else {
None
}
}
fn parse_relative(input: &str, today: Date) -> Option<Date> {
let input = input.trim().to_lowercase();
if input == "today" {
return Some(today);
}
if input == "yesterday" {
return Some(today.sub_days(1));
}
if input == "tomorrow" {
return Some(today.add_days(1));
}
if input == "next week" {
return Some(today.add_days(7));
}
if input == "next month" {
return Some(today.add_months(1u32));
}
if let Some(duration) = parse_duration(&input) {
return Some(duration.apply(today));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
use crate::date::Weekday;
const TODAY: &str = "2026-01-13";
fn today() -> Date {
Date::from_str_unchecked(TODAY)
}
#[test]
fn period_render_single_day() {
let period = Period::single_day(today());
assert_eq!(period.render(), "2026-01-13");
}
#[test]
fn period_render_range() {
let from = Date::from_str_unchecked("2026-01-01");
let to = Date::from_str_unchecked("2026-01-31");
let period = Period::new(from, to);
assert_eq!(period.render(), "2026-01-01 to 2026-01-31");
}
#[rstest]
#[case::inside_range("2026-01-15", true)]
#[case::at_from_edge("2026-01-01", true)]
#[case::at_to_edge("2026-01-31", true)]
#[case::before_range("2025-12-31", false)]
#[case::after_range("2026-02-01", false)]
fn period_contains(#[case] date: &str, #[case] expected: bool) {
let period = Period::new(
Date::from_str_unchecked("2026-01-01"),
Date::from_str_unchecked("2026-01-31"),
);
let date = Date::from_str_unchecked(date);
assert_eq!(period.contains(date), expected);
}
#[rstest]
#[case::iso_date("2025-12-31", "2025-12-31")]
#[case::today("today", "2026-01-13")]
#[case::yesterday("yesterday", "2026-01-12")]
#[case::one_week_ago("1 week ago", "2026-01-06")]
#[case::two_weeks_ago("2 weeks ago", "2025-12-30")]
#[case::one_month_ago("1 month ago", "2025-12-13")]
#[case::two_months_ago("2 months ago", "2025-11-13")]
#[case::one_year_ago("1 year ago", "2025-01-13")]
#[case::two_years_ago("2 years ago", "2024-01-13")]
#[case::one_y_ago("1y ago", "2025-01-13")]
#[case::one_day_ago("1 day ago", "2026-01-12")]
#[case::three_days_ago("3 days ago", "2026-01-10")]
#[case::seven_d_ago("7d ago", "2026-01-06")]
#[case::thirty_d_ago("30d ago", "2025-12-14")]
#[case::tomorrow("tomorrow", "2026-01-14")]
#[case::in_3_days("in 3 days", "2026-01-16")]
#[case::in_7d("in 7d", "2026-01-20")]
#[case::in_2_weeks("in 2 weeks", "2026-01-27")]
#[case::in_1_year("in 1 year", "2027-01-13")]
#[case::in_2y("in 2y", "2028-01-13")]
#[case::next_week("next week", "2026-01-20")]
#[case::next_month("next month", "2026-02-13")]
fn parse_date_formats(#[case] input: &str, #[case] expected: &str) {
let expected = Date::from_str_unchecked(expected);
assert_eq!(parse_date(input, today()), expected);
}
#[rstest]
#[case::bare_7d("7d")]
#[case::bare_30d("30d")]
#[case::bare_1d("1d")]
#[case::year_month("2025-12")] fn try_parse_date_returns_error(#[case] input: &str) {
assert!(try_parse_date(input, today()).is_err());
}
#[rstest]
#[case::last_friday("last friday", "2026-01-09")]
#[case::last_monday("last monday", "2026-01-12")]
#[case::last_sunday("last sunday", "2026-01-11")]
#[case::last_saturday("last saturday", "2026-01-10")]
#[case::last_tuesday("last tuesday", "2026-01-06")]
#[case::last_wednesday("last wednesday", "2026-01-07")]
#[case::last_thursday("last thursday", "2026-01-08")]
fn parse_date_last_weekday(#[case] input: &str, #[case] expected: &str) {
let expected = Date::from_str_unchecked(expected);
assert_eq!(parse_date(input, today()), expected);
}
#[rstest]
#[case::next_friday("next friday", "2026-01-16")]
#[case::next_monday("next monday", "2026-01-19")]
#[case::next_sunday("next sunday", "2026-01-18")]
#[case::next_saturday("next saturday", "2026-01-17")]
#[case::next_tuesday("next tuesday", "2026-01-20")]
#[case::next_wednesday("next wednesday", "2026-01-14")]
#[case::next_thursday("next thursday", "2026-01-15")]
fn parse_date_next_weekday(#[case] input: &str, #[case] expected: &str) {
let expected = Date::from_str_unchecked(expected);
assert_eq!(parse_date(input, today()), expected);
}
#[test]
fn parse_date_invalid_returns_today() {
assert_eq!(parse_date("invalid", today()), today());
assert_eq!(parse_date("", today()), today());
}
#[rstest]
#[case::year_month("2025-12", "2025-12-01", "2025-12-31")]
#[case::year_month_feb("2025-02", "2025-02-01", "2025-02-28")]
#[case::year_month_feb_leap("2024-02", "2024-02-01", "2024-02-29")]
fn parse_period_year_month(
#[case] input: &str,
#[case] expected_from: &str,
#[case] expected_to: &str,
) {
let period = parse_period(input, today());
assert_eq!(period.from, Date::from_str_unchecked(expected_from));
assert_eq!(period.to, Date::from_str_unchecked(expected_to));
}
#[rstest]
#[case::q1("2025-Q1", "2025-01-01", "2025-03-31")]
#[case::q2("2025-Q2", "2025-04-01", "2025-06-30")]
#[case::q3("2025-Q3", "2025-07-01", "2025-09-30")]
#[case::q4("2025-Q4", "2025-10-01", "2025-12-31")]
#[case::lowercase("2025-q1", "2025-01-01", "2025-03-31")]
fn parse_period_quarter(
#[case] input: &str,
#[case] expected_from: &str,
#[case] expected_to: &str,
) {
let period = parse_period(input, today());
assert_eq!(period.from, Date::from_str_unchecked(expected_from));
assert_eq!(period.to, Date::from_str_unchecked(expected_to));
}
#[test]
fn parse_period_this_month() {
let period = parse_period("this month", today());
assert_eq!(period.from, Date::from_str_unchecked("2026-01-01"));
assert_eq!(period.to, Date::from_str_unchecked("2026-01-31"));
}
#[test]
fn parse_period_last_month() {
let period = parse_period("last month", today());
assert_eq!(period.from, Date::from_str_unchecked("2025-12-01"));
assert_eq!(period.to, Date::from_str_unchecked("2025-12-31"));
}
#[test]
fn parse_period_this_week() {
let period = parse_period("this week", today());
assert_eq!(period.from, Date::from_str_unchecked("2026-01-12"));
assert_eq!(period.to, Date::from_str_unchecked("2026-01-18"));
}
#[test]
fn parse_period_last_week() {
let period = parse_period("last week", today());
assert_eq!(period.from, Date::from_str_unchecked("2026-01-05"));
assert_eq!(period.to, Date::from_str_unchecked("2026-01-11"));
}
#[test]
fn parse_period_yesterday() {
let period = parse_period("yesterday", today());
assert_eq!(period.from, Date::from_str_unchecked("2026-01-12"));
assert_eq!(period.to, Date::from_str_unchecked("2026-01-12"));
}
#[test]
fn parse_period_invalid_returns_today() {
let period = parse_period("invalid", today());
assert_eq!(period.from, today());
assert_eq!(period.to, today());
}
#[rstest]
#[case::january("2025-01-15", "2025-01-31")]
#[case::february_non_leap("2025-02-10", "2025-02-28")]
#[case::february_leap("2024-02-10", "2024-02-29")]
#[case::march("2025-03-01", "2025-03-31")]
#[case::april("2025-04-30", "2025-04-30")]
#[case::june("2025-06-15", "2025-06-30")]
#[case::december("2025-12-01", "2025-12-31")]
#[case::already_last_day("2025-01-31", "2025-01-31")]
fn last_day_of_month(#[case] input: &str, #[case] expected: &str) {
let date = Date::from_str_unchecked(input);
let expected = Date::from_str_unchecked(expected);
assert_eq!(date.last_day_of_month(), expected);
}
#[rstest]
#[case::last_friday(Weekday::Friday, "2026-01-09")]
#[case::last_monday(Weekday::Monday, "2026-01-12")]
#[case::last_sunday(Weekday::Sunday, "2026-01-11")]
#[case::last_saturday(Weekday::Saturday, "2026-01-10")]
#[case::last_tuesday(Weekday::Tuesday, "2026-01-06")]
#[case::last_wednesday(Weekday::Wednesday, "2026-01-07")]
#[case::last_thursday(Weekday::Thursday, "2026-01-08")]
fn last_weekday(#[case] weekday: Weekday, #[case] expected: &str) {
let expected = Date::from_str_unchecked(expected);
assert_eq!(today().last_weekday(weekday), expected);
}
#[rstest]
#[case::one_month("2026-01-13", 1, "2025-12-13")]
#[case::two_months("2026-01-13", 2, "2025-11-13")]
#[case::twelve_months("2026-01-13", 12, "2025-01-13")]
#[case::across_year("2025-03-15", 4, "2024-11-15")]
#[case::month_end("2025-01-31", 1, "2024-12-31")]
#[case::overflow_to_feb("2025-03-31", 1, "2025-02-28")]
#[case::overflow_to_feb_leap("2024-03-31", 1, "2024-02-29")]
fn sub_months(#[case] input: &str, #[case] months: u32, #[case] expected: &str) {
let date = Date::from_str_unchecked(input);
let expected = Date::from_str_unchecked(expected);
assert_eq!(date.sub_months(months), expected);
}
#[rstest]
#[case::one_year("2026-01-13", 1, "2025-01-13")]
#[case::two_years("2026-01-13", 2, "2024-01-13")]
#[case::ten_years("2026-01-13", 10, "2016-01-13")]
#[case::leap_day_to_non_leap("2024-02-29", 1, "2023-02-28")]
#[case::leap_day_to_leap("2024-02-29", 4, "2020-02-29")]
fn sub_years(#[case] input: &str, #[case] years: u32, #[case] expected: &str) {
let date = Date::from_str_unchecked(input);
let expected = Date::from_str_unchecked(expected);
assert_eq!(date.sub_years(years), expected);
}
}