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    valid_years.binary_search(&year).is_ok().then_some(year)
10}
11
12/// Parse a string of years separated by semicolons into a vector of u32 years.
13///
14/// The string can be either "all" (case-insensitive), a single year, or a semicolon-separated list
15/// of years (e.g. "2020;2021;2022" or "2020; 2021; 2022")
16///
17/// # Arguments
18///
19/// - `s` - Input string to parse
20/// - `valid_years` - The possible years which can be referenced in `s` (must be sorted and unique)
21///
22/// # Returns
23///
24/// A [`Vec`] of years or an error.
25///
26/// # Panics
27///
28/// If `valid_years` is unsorted or non-unique.
29pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
30    // We depend on this in `parse_and_validate_year`
31    assert!(
32        is_sorted_and_unique(valid_years),
33        "`valid_years` must be sorted and unique"
34    );
35
36    let s = s.trim();
37    ensure!(!s.is_empty(), "No years provided");
38
39    if s.eq_ignore_ascii_case("all") {
40        return Ok(Vec::from_iter(valid_years.iter().copied()));
41    }
42
43    ensure!(
44        !(s.contains(';') && s.contains("..")),
45        "Both ';' and '..' found in year string {s}. Discrete years and ranges cannot be mixed."
46    );
47
48    // We first process ranges
49    let years: Vec<_> = if s.contains("..") {
50        parse_years_range(s, valid_years)?
51    } else {
52        s.split(';')
53            .map(|y| {
54                parse_and_validate_year(y, valid_years)
55                    .with_context(|| format!("Invalid year: {y}"))
56            })
57            .try_collect()?
58    };
59
60    ensure!(
61        is_sorted_and_unique(&years),
62        "Years must be in order and unique"
63    );
64
65    Ok(years)
66}
67
68/// Parse a year string that is defined as a range, selecting the valid years within that range.
69///
70/// It should be of the form start..end. If either of the limits are omitted, they will default to
71/// the first and last years of the `valid_years`. If both limits are missing, this is equivalent to
72/// passing all.
73fn parse_years_range(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
74    // Require exactly one ".." separator so only forms start..end, start.. or ..end are allowed.
75    let parts: Vec<&str> = s.split("..").collect();
76    ensure!(
77        parts.len() == 2,
78        "Year range must be of the form 'start..end', 'start..' or '..end'. Invalid: {s}"
79    );
80    let left = parts[0].trim();
81    let right = parts[1].trim();
82
83    // If the range start is open, we assign the first valid year
84    let start = if left.is_empty() {
85        valid_years[0]
86    } else {
87        left.parse::<u32>()
88            .ok()
89            .with_context(|| format!("Invalid start year in range: {left}"))?
90    };
91
92    // If the range end is open, we assign the last valid year
93    let end = if right.is_empty() {
94        *valid_years.last().unwrap()
95    } else {
96        right
97            .parse::<u32>()
98            .ok()
99            .with_context(|| format!("Invalid end year in range: {right}"))?
100    };
101
102    ensure!(
103        end > start,
104        "End year must be bigger than start year in range {s}"
105    );
106    let years: Vec<_> = (start..=end).filter(|y| valid_years.contains(y)).collect();
107    ensure!(
108        !years.is_empty(),
109        "No valid years found in year range string {s}"
110    );
111    Ok(years)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::fixture::assert_error;
118    use rstest::rstest;
119
120    #[rstest]
121    #[case("2020", &[2020, 2021], &[2020])]
122    #[case("all", &[2020, 2021], &[2020,2021])]
123    #[case("ALL", &[2020, 2021], &[2020,2021])]
124    #[case(" ALL ", &[2020, 2021], &[2020,2021])]
125    #[case("2020;2021", &[2020, 2021], &[2020,2021])]
126    #[case("  2020;  2021", &[2020, 2021], &[2020,2021])] // whitespace should be stripped
127    #[case("2019..2026", &[2020,2025], &[2020,2025])]
128    #[case("..2023", &[2020,2025], &[2020])] // Empty start
129    #[case("2021..", &[2020,2025], &[2025])] // Empty end
130    #[case("..", &[2020,2025], &[2020,2025])]
131    fn parse_year_str_valid(
132        #[case] input: &str,
133        #[case] milestone_years: &[u32],
134        #[case] expected: &[u32],
135    ) {
136        assert_eq!(parse_year_str(input, milestone_years).unwrap(), expected);
137    }
138
139    #[rstest]
140    #[case("", &[2020], "No years provided")]
141    #[case("2021", &[2020], "Invalid year: 2021")]
142    #[case("a;2020", &[2020], "Invalid year: a")]
143    #[case("2021;2020", &[2020, 2021],"Years must be in order and unique")] // out of order
144    #[case("2021;2020;2021", &[2020, 2021],"Years must be in order and unique")] // duplicate
145    #[case("2021;2020..2021", &[2020, 2021],"Both ';' and '..' found in year string 2021;2020..2021. Discrete years and ranges cannot be mixed.")]
146    #[case("2021..2020", &[2020, 2021],"End year must be bigger than start year in range 2021..2020")] // out of order
147    #[case("2021..2024", &[2020,2025], "No valid years found in year range string 2021..2024")]
148    #[case("..2020..2025", &[2020,2025], "Year range must be of the form 'start..end', 'start..' or '..end'. Invalid: ..2020..2025")]
149    #[case("2020...2025", &[2020,2025], "Invalid end year in range: .2025")]
150    fn parse_year_str_invalid(
151        #[case] input: &str,
152        #[case] milestone_years: &[u32],
153        #[case] error_msg: &str,
154    ) {
155        assert_error!(parse_year_str(input, milestone_years), error_msg);
156    }
157}