Skip to main content

yuki_cli/
period.rs

1use crate::error::YukiError;
2
3/// Parse a period string into (start_date, end_date) as "YYYY-MM-DD" strings.
4///
5/// Supported formats:
6/// - `"YYYY"` — full calendar year
7/// - `"YYYY-QN"` — calendar quarter (Q1–Q4)
8/// - `"YYYY-MM"` — calendar month
9pub fn parse_period(period: &str) -> Result<(String, String), YukiError> {
10    let invalid = || YukiError::Config(format!("invalid period: {period}"));
11
12    // YYYY
13    if period.len() == 4 && period.chars().all(|c| c.is_ascii_digit()) {
14        let year: u32 = period.parse().map_err(|_| invalid())?;
15        return Ok((format!("{year:04}-01-01"), format!("{year:04}-12-31")));
16    }
17
18    // YYYY-QN
19    if period.len() == 7 {
20        let (year_str, rest) = period.split_at(4);
21        if let Some(q) = rest.strip_prefix("-Q") {
22            let year: u32 = year_str.parse().map_err(|_| invalid())?;
23            let quarter: u32 = q.parse().map_err(|_| invalid())?;
24            let (start_month, end_month, end_day) = match quarter {
25                1 => (1u32, 3u32, 31u32),
26                2 => (4, 6, 30),
27                3 => (7, 9, 30),
28                4 => (10, 12, 31),
29                _ => return Err(invalid()),
30            };
31            return Ok((
32                format!("{year:04}-{start_month:02}-01"),
33                format!("{year:04}-{end_month:02}-{end_day:02}"),
34            ));
35        }
36    }
37
38    // YYYY-MM
39    if period.len() == 7 {
40        let (year_str, rest) = period.split_at(4);
41        if let Some(month_str) = rest.strip_prefix('-') {
42            let year: u32 = year_str.parse().map_err(|_| invalid())?;
43            let month: u32 = month_str.parse().map_err(|_| invalid())?;
44            if month == 0 || month > 12 {
45                return Err(invalid());
46            }
47            let last_day = days_in_month(year, month);
48            return Ok((
49                format!("{year:04}-{month:02}-01"),
50                format!("{year:04}-{month:02}-{last_day:02}"),
51            ));
52        }
53    }
54
55    Err(invalid())
56}
57
58/// Return the number of days in the given month of the given year.
59fn days_in_month(year: u32, month: u32) -> u32 {
60    match month {
61        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
62        4 | 6 | 9 | 11 => 30,
63        2 => {
64            if is_leap_year(year) {
65                29
66            } else {
67                28
68            }
69        }
70        _ => unreachable!("month already validated"),
71    }
72}
73
74/// Determine whether a year is a leap year.
75fn is_leap_year(year: u32) -> bool {
76    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
77}