muse2/
year.rs

1//! Code for working with years.
2use crate::input::is_sorted_and_unique;
3use anyhow::{ensure, Context, Result};
4use itertools::Itertools;
5
6/// Parse a single year from a string and check it is in `valid_years`
7fn parse_and_validate_year(s: &str, valid_years: &[u32]) -> Option<u32> {
8    let year = s.trim().parse::<u32>().ok()?;
9    if valid_years.binary_search(&year).is_ok() {
10        Some(year)
11    } else {
12        None
13    }
14}
15
16/// Parse a string of years separated by semicolons into a vector of u32 years.
17///
18/// The string can be either "all" (case-insensitive), a single year, or a semicolon-separated list
19/// of years (e.g. "2020;2021;2022" or "2020; 2021; 2022")
20///
21/// # Arguments
22///
23/// - `s` - Input string to parse
24/// - `valid_years` - The possible years which can be referenced in `s` (must be sorted and unique)
25///
26/// # Returns
27///
28/// A [`Vec`] of years or an error.
29///
30/// # Panics
31///
32/// If `valid_years` is unsorted or non-unique.
33pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
34    // We depend on this in `parse_and_validate_year`
35    assert!(
36        is_sorted_and_unique(valid_years),
37        "`valid_years` must be sorted and unique"
38    );
39
40    let s = s.trim();
41    ensure!(!s.is_empty(), "No years provided");
42
43    if s.eq_ignore_ascii_case("all") {
44        return Ok(Vec::from_iter(valid_years.iter().copied()));
45    }
46
47    let years: Vec<_> = s
48        .split(";")
49        .map(|y| {
50            parse_and_validate_year(y, valid_years).with_context(|| format!("Invalid year: {y}"))
51        })
52        .try_collect()?;
53
54    ensure!(
55        is_sorted_and_unique(&years),
56        "Years must be in order and unique"
57    );
58
59    Ok(years)
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::fixture::assert_error;
66    use rstest::rstest;
67
68    #[rstest]
69    #[case("2020", &[2020, 2021], &[2020])]
70    #[case("all", &[2020, 2021], &[2020,2021])]
71    #[case("ALL", &[2020, 2021], &[2020,2021])]
72    #[case(" ALL ", &[2020, 2021], &[2020,2021])]
73    #[case("2020;2021", &[2020, 2021], &[2020,2021])]
74    #[case("  2020;  2021", &[2020, 2021], &[2020,2021])] // whitespace should be stripped
75    fn test_parse_year_str_valid(
76        #[case] input: &str,
77        #[case] milestone_years: &[u32],
78        #[case] expected: &[u32],
79    ) {
80        assert_eq!(parse_year_str(input, milestone_years).unwrap(), expected);
81    }
82
83    #[rstest]
84    #[case("", &[2020], "No years provided")]
85    #[case("2021", &[2020], "Invalid year: 2021")]
86    #[case("a;2020", &[2020], "Invalid year: a")]
87    #[case("2021;2020", &[2020, 2021],"Years must be in order and unique")] // out of order
88    #[case("2021;2020;2021", &[2020, 2021],"Years must be in order and unique")] // duplicate
89    fn test_parse_year_str_invalid(
90        #[case] input: &str,
91        #[case] milestone_years: &[u32],
92        #[case] error_msg: &str,
93    ) {
94        assert_error!(parse_year_str(input, milestone_years), error_msg);
95    }
96}