fdate 0.2.2

Natural date input parsing
Documentation
use chrono::{Month, Weekday};
use nom::{
    IResult, Parser, branch::alt, bytes::complete::tag_no_case, character::complete::multispace1,
    combinator::verify,
};

use super::{
    common::{IntervalUnit, RelativeDirection, parse_ordinal_day, parse_weekday},
    interval::{RelativeInterval, parse_explicit_non_literal_relative_interval},
    partial_date::parse_partial_date_body,
};

pub(super) fn parse_relative_interval_date(input: &str) -> IResult<&str, RelativeIntervalDate> {
    alt((
        parse_relative_week_interval_weekday,
        parse_relative_month_interval_day_of_month,
        parse_relative_year_interval_day_of_year,
    ))
    .parse(input)
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) struct RelativeIntervalDate {
    pub direction: RelativeDirection,
    pub distance: u32,
    pub target: RelativeIntervalDateSpec,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RelativeIntervalDateSpec {
    Weekday { weekday: Weekday },
    DayOfMonth { day: u32 },
    DayOfYear { day: u32, month: Month },
}

fn parse_relative_week_interval_weekday(input: &str) -> IResult<&str, RelativeIntervalDate> {
    let (input, weekday) = parse_weekday(input)?;
    let target = RelativeIntervalDateSpec::Weekday { weekday };
    let (input, _) = multispace1(input)?;
    alt((
        tag_no_case("this week").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Future,
            distance: 0,
            target,
        }),
        tag_no_case("next week").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Future,
            distance: 1,
            target,
        }),
        tag_no_case("last week").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Past,
            distance: 1,
            target,
        }),
        verify(
            parse_explicit_non_literal_relative_interval,
            |RelativeInterval { unit, .. }| unit == &IntervalUnit::Week,
        )
        .map(|interval| RelativeIntervalDate {
            direction: interval.direction,
            distance: interval.distance,
            target,
        }),
    ))
    .parse(input)
}

fn parse_relative_month_interval_day_of_month(input: &str) -> IResult<&str, RelativeIntervalDate> {
    let (input, day) = parse_ordinal_day(input)?;
    let target = RelativeIntervalDateSpec::DayOfMonth { day };
    let (input, _) = multispace1(input)?;
    alt((
        tag_no_case("this month").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Future,
            distance: 0,
            target,
        }),
        tag_no_case("next month").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Future,
            distance: 1,
            target,
        }),
        tag_no_case("last month").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Past,
            distance: 1,
            target,
        }),
        verify(
            parse_explicit_non_literal_relative_interval,
            |RelativeInterval { unit, .. }| unit == &IntervalUnit::Month,
        )
        .map(|interval| RelativeIntervalDate {
            direction: interval.direction,
            distance: interval.distance,
            target,
        }),
    ))
    .parse(input)
}

fn parse_relative_year_interval_day_of_year(input: &str) -> IResult<&str, RelativeIntervalDate> {
    let (input, (day, month)) = parse_partial_date_body(input)?;
    let target = RelativeIntervalDateSpec::DayOfYear { day, month };
    let (input, _) = multispace1(input)?;
    alt((
        tag_no_case("this year").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Future,
            distance: 0,
            target,
        }),
        tag_no_case("next year").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Future,
            distance: 1,
            target,
        }),
        tag_no_case("last year").map(|_| RelativeIntervalDate {
            direction: RelativeDirection::Past,
            distance: 1,
            target,
        }),
        verify(
            parse_explicit_non_literal_relative_interval,
            |RelativeInterval { unit, .. }| unit == &IntervalUnit::Year,
        )
        .map(|interval| RelativeIntervalDate {
            direction: interval.direction,
            distance: interval.distance,
            target,
        }),
    ))
    .parse(input)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_this_week_weekdays() {
        assert_eq!(
            parse_relative_week_interval_weekday("monday this week"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 0,
                    target: RelativeIntervalDateSpec::Weekday {
                        weekday: Weekday::Mon,
                    },
                },
            ))
        );
        assert_eq!(
            parse_relative_week_interval_weekday("Thu this week"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 0,
                    target: RelativeIntervalDateSpec::Weekday {
                        weekday: Weekday::Thu,
                    },
                },
            ))
        );
    }

    #[test]
    fn parses_relative_week_intervals() {
        assert_eq!(
            parse_relative_week_interval_weekday("friday in 2 weeks"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 2,
                    target: RelativeIntervalDateSpec::Weekday {
                        weekday: Weekday::Fri,
                    },
                },
            ))
        );
        assert_eq!(
            parse_relative_week_interval_weekday("tuesday a week ago"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Past,
                    distance: 1,
                    target: RelativeIntervalDateSpec::Weekday {
                        weekday: Weekday::Tue,
                    },
                },
            ))
        );
    }

    #[test]
    fn leaves_remaining_input_for_larger_parsers() {
        assert_eq!(
            parse_relative_week_interval_weekday("wednesday this week please"),
            Ok((
                " please",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 0,
                    target: RelativeIntervalDateSpec::Weekday {
                        weekday: Weekday::Wed,
                    },
                },
            ))
        );
    }

    #[test]
    fn rejects_non_week_intervals() {
        assert!(parse_relative_week_interval_weekday("monday in 2 months").is_err());
        assert!(parse_relative_week_interval_weekday("monday today").is_err());
        assert!(parse_relative_week_interval_weekday("hello this week").is_err());
    }

    #[test]
    fn parses_this_month_days() {
        assert_eq!(
            parse_relative_month_interval_day_of_month("14th this month"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 0,
                    target: RelativeIntervalDateSpec::DayOfMonth { day: 14 },
                },
            ))
        );
    }

    #[test]
    fn parses_relative_month_intervals() {
        assert_eq!(
            parse_relative_month_interval_day_of_month("17 in 2 months"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 2,
                    target: RelativeIntervalDateSpec::DayOfMonth { day: 17 },
                },
            ))
        );
        assert_eq!(
            parse_relative_month_interval_day_of_month("10 a month ago"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Past,
                    distance: 1,
                    target: RelativeIntervalDateSpec::DayOfMonth { day: 10 },
                },
            ))
        );
    }

    #[test]
    fn rejects_non_month_intervals() {
        assert!(parse_relative_month_interval_day_of_month("14th this week").is_err());
        assert!(parse_relative_month_interval_day_of_month("hello this month").is_err());
    }

    #[test]
    fn parses_this_year_days() {
        assert_eq!(
            parse_relative_year_interval_day_of_year("1st may this year"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 0,
                    target: RelativeIntervalDateSpec::DayOfYear {
                        day: 1,
                        month: Month::May,
                    },
                },
            ))
        );
    }

    #[test]
    fn parses_relative_year_intervals() {
        assert_eq!(
            parse_relative_year_interval_day_of_year("10th july in 2 years"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Future,
                    distance: 2,
                    target: RelativeIntervalDateSpec::DayOfYear {
                        day: 10,
                        month: Month::July,
                    },
                },
            ))
        );
        assert_eq!(
            parse_relative_year_interval_day_of_year("may 1 a year ago"),
            Ok((
                "",
                RelativeIntervalDate {
                    direction: RelativeDirection::Past,
                    distance: 1,
                    target: RelativeIntervalDateSpec::DayOfYear {
                        day: 1,
                        month: Month::May,
                    },
                },
            ))
        );
    }

    #[test]
    fn rejects_non_year_intervals() {
        assert!(parse_relative_year_interval_day_of_year("1st may this month").is_err());
        assert!(parse_relative_year_interval_day_of_year("14th this year").is_err());
    }

    #[test]
    fn parses_relative_interval_dates() {
        assert!(parse_relative_interval_date("monday this week").is_ok());
        assert!(parse_relative_interval_date("14th this month").is_ok());
        assert!(parse_relative_interval_date("1st may this year").is_ok());
    }
}