Skip to main content

rapport_temporal/
query.rs

1//! Date query parsing for CLI applications.
2//!
3//! Provides flexible date and period parsing for command-line flags like
4//! `--date`, `--from`, `--to`, and `--in`. Designed for AI agent consumption.
5//!
6//! # Supported Formats
7//!
8//! ## Single Date (`parse_date`)
9//! - ISO dates: `2025-12-31`
10//! - Present: `today`
11//! - Past: `yesterday`, `last friday`, `7 days ago`, `7d ago`, `1 week ago`
12//! - Future: `tomorrow`, `next monday`, `in 3 days`, `in 7d`, `next week`, `next month`
13//!
14//! ## Period (`parse_period`)
15//! - Year-month: `2025-12` (full month range)
16//! - Quarter: `2025-Q1`
17//! - Natural language: `this week`, `last week`, `this month`, `last month`, `yesterday`
18
19use std::str::FromStr;
20
21use crate::date::{Date, Weekday, YearMonth, YearQuarter};
22
23/// Error returned when a date string cannot be parsed.
24#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25#[error("invalid date format: \"{0}\"")]
26pub struct DateParseError(pub String);
27
28/// Error returned when a period string cannot be parsed.
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30#[error("invalid period format: \"{0}\"")]
31pub struct PeriodParseError(pub String);
32
33/// A date range representing a reporting period.
34#[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    /// Check if a date falls within this period (inclusive on both ends).
64    #[must_use]
65    pub fn contains(self, date: Date) -> bool {
66        date >= self.from && date <= self.to
67    }
68}
69
70/// Try to parse a single date string. Handles:
71/// - ISO dates: `2025-12-31`
72/// - Present: `today`
73/// - Past: `yesterday`, `last friday`, `7 days ago`, `7d ago`, `1 week ago`
74/// - Future: `tomorrow`, `next monday`, `in 3 days`, `in 7d`, `next week`, `next month`
75///
76/// Note: Year-month formats like `2025-12` are periods, not dates. Use `try_parse_period` instead.
77///
78/// # Errors
79///
80/// Returns `DateParseError` if the input cannot be parsed.
81pub fn try_parse_date(input: &str, today: Date) -> Result<Date, DateParseError> {
82    let input = input.trim();
83
84    // Try ISO date format first (YYYY-MM-DD)
85    if let Ok(date) = Date::from_str(input) {
86        return Ok(date);
87    }
88
89    // Try last weekday (e.g., "last friday")
90    if let Some(date) = parse_last_weekday(input, today) {
91        return Ok(date);
92    }
93
94    // Try next weekday (e.g., "next monday")
95    if let Some(date) = parse_next_weekday(input, today) {
96        return Ok(date);
97    }
98
99    // Try relative expressions (e.g., "yesterday", "1 week ago", "in 3 days")
100    if let Some(date) = parse_relative(input, today) {
101        return Ok(date);
102    }
103
104    Err(DateParseError(input.to_owned()))
105}
106
107/// Parse a single date string. Handles:
108/// - ISO dates: `2025-12-31`
109/// - Present: `today`
110/// - Past: `yesterday`, `last friday`, `7 days ago`, `7d ago`, `1 week ago`
111/// - Future: `tomorrow`, `next monday`, `in 3 days`, `in 7d`, `next week`, `next month`
112///
113/// Note: Year-month formats like `2025-12` are periods, not dates. Use `parse_period` instead.
114///
115/// Returns `today` if the input cannot be parsed.
116#[must_use]
117pub fn parse_date(input: &str, today: Date) -> Date {
118    try_parse_date(input, today).unwrap_or(today)
119}
120
121/// Try to parse period strings for `--in` flag:
122/// - Year-month: `2025-12`
123/// - Quarter: `2025-Q1`
124/// - Natural language: `last week`, `this week`, `last month`, `this month`, `yesterday`
125///
126/// # Errors
127///
128/// Returns `PeriodParseError` if the input cannot be parsed.
129pub fn try_parse_period(input: &str, today: Date) -> Result<Period, PeriodParseError> {
130    let input = input.trim();
131
132    // Try year-month (YYYY-MM)
133    if let Some(ym) = YearMonth::parse(input) {
134        return Ok(Period::new(ym.first_day(), ym.last_day()));
135    }
136
137    // Try quarter (YYYY-Q#)
138    if let Some(quarter) = YearQuarter::parse(input) {
139        return Ok(Period::new(quarter.first_day(), quarter.last_day()));
140    }
141
142    // Natural language periods
143    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/// Parse period strings for `--in` flag:
174/// - Year-month: `2025-12`
175/// - Quarter: `2025-Q1`
176/// - Natural language: `last week`, `this week`, `last month`, `this month`, `yesterday`
177///
178/// Returns a single-day period of `today` if the input cannot be parsed.
179#[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
184/// Parse "last WEEKDAY" expressions (e.g., "last friday").
185fn 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..]; // Remove "last "
193    let weekday = parse_weekday_name(weekday_str)?;
194    Some(today.last_weekday(weekday))
195}
196
197/// Parse "next WEEKDAY" expressions (e.g., "next monday").
198fn 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..]; // Remove "next "
206    let weekday = parse_weekday_name(weekday_str)?;
207    Some(today.next_weekday(weekday))
208}
209
210/// Parse a weekday name string into a Weekday.
211fn 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/// Direction of a duration offset.
225#[derive(Debug, Copy, Clone, PartialEq, Eq)]
226enum Direction {
227    Forward,
228    Backward,
229}
230
231/// Time unit for duration parsing.
232#[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/// A parsed duration with direction, unit, and count.
253#[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
276/// Parse a duration expression like "in 3 days" or "7d ago".
277///
278/// Returns `Some(ParsedDuration)` if input has "in " prefix or " ago" suffix.
279/// Returns `None` if neither marker is present.
280fn 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
300/// Parse count and unit from strings like "7 days" or "7d".
301fn 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        // "7 days" format
306        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        // "7d" format - extract number and suffix
311        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
325/// Parse relative time expressions.
326///
327/// Past: `yesterday`, `7 days ago`, `7d ago`, `1 week ago`, `1 month ago`, `1 year ago`
328/// Present: `today`
329/// Future: `tomorrow`, `in 3 days`, `in 7d`, `in 2 weeks`, `next week`, `next month`
330fn parse_relative(input: &str, today: Date) -> Option<Date> {
331    let input = input.trim().to_lowercase();
332
333    // Handle "today", "yesterday" and "tomorrow"
334    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    // Handle "next week" and "next month"
345    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    // Handle duration expressions like "in 3 days" or "7 days ago"
353    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"; // A Tuesday
369
370    fn today() -> Date {
371        Date::from_str_unchecked(TODAY)
372    }
373
374    // ==========================================================================
375    // Period
376    // ==========================================================================
377
378    #[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    // ==========================================================================
408    // parse_date
409    // ==========================================================================
410
411    #[rstest]
412    #[case::iso_date("2025-12-31", "2025-12-31")]
413    // Present
414    #[case::today("today", "2026-01-13")]
415    // Backward dates
416    #[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    // Forward dates
429    #[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")] // Year-month is a period, not a date
447    fn try_parse_date_returns_error(#[case] input: &str) {
448        assert!(try_parse_date(input, today()).is_err());
449    }
450
451    #[rstest]
452    // "last friday" from Tuesday = 4 days ago (2026-01-09)
453    #[case::last_friday("last friday", "2026-01-09")]
454    // "last monday" from Tuesday = 1 day ago (2026-01-12)
455    #[case::last_monday("last monday", "2026-01-12")]
456    // "last sunday" from Tuesday = 2 days ago (2026-01-11)
457    #[case::last_sunday("last sunday", "2026-01-11")]
458    // "last saturday" from Tuesday = 3 days ago (2026-01-10)
459    #[case::last_saturday("last saturday", "2026-01-10")]
460    // "last tuesday" from Tuesday = 7 days ago (not today!)
461    #[case::last_tuesday("last tuesday", "2026-01-06")]
462    // "last wednesday" from Tuesday = 6 days ago (2026-01-07)
463    #[case::last_wednesday("last wednesday", "2026-01-07")]
464    // "last thursday" from Tuesday = 5 days ago (2026-01-08)
465    #[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    // "next friday" from Tuesday = 3 days ahead (2026-01-16)
473    #[case::next_friday("next friday", "2026-01-16")]
474    // "next monday" from Tuesday = 6 days ahead (2026-01-19)
475    #[case::next_monday("next monday", "2026-01-19")]
476    // "next sunday" from Tuesday = 5 days ahead (2026-01-18)
477    #[case::next_sunday("next sunday", "2026-01-18")]
478    // "next saturday" from Tuesday = 4 days ahead (2026-01-17)
479    #[case::next_saturday("next saturday", "2026-01-17")]
480    // "next tuesday" from Tuesday = 7 days ahead (not today!)
481    #[case::next_tuesday("next tuesday", "2026-01-20")]
482    // "next wednesday" from Tuesday = 1 day ahead (2026-01-14)
483    #[case::next_wednesday("next wednesday", "2026-01-14")]
484    // "next thursday" from Tuesday = 2 days ahead (2026-01-15)
485    #[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    // ==========================================================================
498    // parse_period
499    // ==========================================================================
500
501    #[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        // Today is 2026-01-13
534        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        // Today is 2026-01-13, last month is December 2025
542        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        // Today is 2026-01-13 (Tuesday)
550        // Latest Sunday is 2026-01-11
551        // This week: Mon 2026-01-12 to Sun 2026-01-18
552        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        // Today is 2026-01-13 (Tuesday)
560        // Latest Sunday is 2026-01-11
561        // Last week: Mon 2026-01-05 to Sun 2026-01-11
562        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    // ==========================================================================
582    // Date::last_day_of_month (testing via the method we added)
583    // ==========================================================================
584
585    #[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    // ==========================================================================
601    // Date::last_weekday (testing via the method we added)
602    // ==========================================================================
603
604    #[rstest]
605    // "last friday" from Tuesday = 4 days ago (2026-01-09)
606    #[case::last_friday(Weekday::Friday, "2026-01-09")]
607    // "last monday" from Tuesday = 1 day ago (2026-01-12)
608    #[case::last_monday(Weekday::Monday, "2026-01-12")]
609    // "last sunday" from Tuesday = 2 days ago (2026-01-11)
610    #[case::last_sunday(Weekday::Sunday, "2026-01-11")]
611    // "last saturday" from Tuesday = 3 days ago (2026-01-10)
612    #[case::last_saturday(Weekday::Saturday, "2026-01-10")]
613    // "last tuesday" from Tuesday = 7 days ago (not today!)
614    #[case::last_tuesday(Weekday::Tuesday, "2026-01-06")]
615    // "last wednesday" from Tuesday = 6 days ago (2026-01-07)
616    #[case::last_wednesday(Weekday::Wednesday, "2026-01-07")]
617    // "last thursday" from Tuesday = 5 days ago (2026-01-08)
618    #[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    // ==========================================================================
625    // Date::sub_months (testing via the method we added)
626    // ==========================================================================
627
628    #[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    // Edge case: Jan 31 - 1 month = Dec 31 (not Jan 31 of prev year)
634    #[case::month_end("2025-01-31", 1, "2024-12-31")]
635    // Edge case: Mar 31 - 1 month = Feb 28 (Feb doesn't have 31 days)
636    #[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    // Edge case: Feb 29 - 1 year = Feb 28 (non-leap year)
649    #[case::leap_day_to_non_leap("2024-02-29", 1, "2023-02-28")]
650    // Edge case: Feb 29 - 4 years = Feb 29 (still a leap year)
651    #[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}