muse2/
year.rs

1//! Code for working with years.
2use crate::input::is_sorted_and_unique;
3use anyhow::{Context, Result, ensure};
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    ensure!(
48        !(s.contains(';') && s.contains("..")),
49        "Both ';' and '..' found in year string {s}. Discrete years and ranges cannot be mixed."
50    );
51
52    // We first process ranges
53    let years: Vec<_> = if s.contains("..") {
54        parse_years_range(s, valid_years)?
55    } else {
56        s.split(';')
57            .map(|y| {
58                parse_and_validate_year(y, valid_years)
59                    .with_context(|| format!("Invalid year: {y}"))
60            })
61            .try_collect()?
62    };
63
64    ensure!(
65        is_sorted_and_unique(&years),
66        "Years must be in order and unique"
67    );
68
69    Ok(years)
70}
71
72/// Parse a year string that is defined as a range, selecting the valid years within that range.
73///
74/// It should be of the form start..end. If either of the limits are omitted, they will default to
75/// the first and last years of the `valid_years`. If both limits are missing, this is equivalent to
76/// passing all.
77fn parse_years_range(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
78    // Require exactly one ".." separator so only forms start..end, start.. or ..end are allowed.
79    let parts: Vec<&str> = s.split("..").collect();
80    ensure!(
81        parts.len() == 2,
82        "Year range must be of the form 'start..end', 'start..' or '..end'. Invalid: {s}"
83    );
84    let left = parts[0].trim();
85    let right = parts[1].trim();
86
87    // If the range start is open, we assign the first valid year
88    let start = if left.is_empty() {
89        valid_years[0]
90    } else {
91        left.parse::<u32>()
92            .ok()
93            .with_context(|| format!("Invalid start year in range: {left}"))?
94    };
95
96    // If the range end is open, we assign the last valid year
97    let end = if right.is_empty() {
98        *valid_years.last().unwrap()
99    } else {
100        right
101            .parse::<u32>()
102            .ok()
103            .with_context(|| format!("Invalid end year in range: {right}"))?
104    };
105
106    ensure!(
107        end > start,
108        "End year must be biger than start year in range {s}"
109    );
110    let years: Vec<_> = (start..=end).filter(|y| valid_years.contains(y)).collect();
111    ensure!(
112        !years.is_empty(),
113        "No valid years found in year range string {s}"
114    );
115    Ok(years)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::fixture::assert_error;
122    use rstest::rstest;
123
124    #[rstest]
125    #[case("2020", &[2020, 2021], &[2020])]
126    #[case("all", &[2020, 2021], &[2020,2021])]
127    #[case("ALL", &[2020, 2021], &[2020,2021])]
128    #[case(" ALL ", &[2020, 2021], &[2020,2021])]
129    #[case("2020;2021", &[2020, 2021], &[2020,2021])]
130    #[case("  2020;  2021", &[2020, 2021], &[2020,2021])] // whitespace should be stripped
131    #[case("2019..2026", &[2020,2025], &[2020,2025])]
132    #[case("..2023", &[2020,2025], &[2020])] // Empty start
133    #[case("2021..", &[2020,2025], &[2025])] // Empty end
134    #[case("..", &[2020,2025], &[2020,2025])]
135    fn test_parse_year_str_valid(
136        #[case] input: &str,
137        #[case] milestone_years: &[u32],
138        #[case] expected: &[u32],
139    ) {
140        assert_eq!(parse_year_str(input, milestone_years).unwrap(), expected);
141    }
142
143    #[rstest]
144    #[case("", &[2020], "No years provided")]
145    #[case("2021", &[2020], "Invalid year: 2021")]
146    #[case("a;2020", &[2020], "Invalid year: a")]
147    #[case("2021;2020", &[2020, 2021],"Years must be in order and unique")] // out of order
148    #[case("2021;2020;2021", &[2020, 2021],"Years must be in order and unique")] // duplicate
149    #[case("2021;2020..2021", &[2020, 2021],"Both ';' and '..' found in year string 2021;2020..2021. Discrete years and ranges cannot be mixed.")]
150    #[case("2021..2020", &[2020, 2021],"End year must be biger than start year in range 2021..2020")] // out of order
151    #[case("2021..2024", &[2020,2025], "No valid years found in year range string 2021..2024")]
152    #[case("..2020..2025", &[2020,2025], "Year range must be of the form 'start..end', 'start..' or '..end'. Invalid: ..2020..2025")]
153    #[case("2020...2025", &[2020,2025], "Invalid end year in range: .2025")]
154    fn test_parse_year_str_invalid(
155        #[case] input: &str,
156        #[case] milestone_years: &[u32],
157        #[case] error_msg: &str,
158    ) {
159        assert_error!(parse_year_str(input, milestone_years), error_msg);
160    }
161}