Skip to main content

muse2/input/
year.rs

1//! Code for working with years.
2use super::{is_sorted_and_unique, is_sorted_and_unique_with, parse_range_parts, partition};
3use anyhow::{Context, Result, bail, ensure};
4use itertools::Itertools;
5use std::ops::RangeInclusive;
6
7/// Return any valid years in the specified range
8fn get_valid_years_in_range(
9    range: &RangeInclusive<u32>,
10    valid_years: &[u32],
11) -> impl Iterator<Item = u32> {
12    valid_years
13        .iter()
14        .copied()
15        .filter(move |year| range.contains(year))
16}
17
18/// Parse a string of years separated by semicolons into a vector of u32 years.
19///
20/// The string can be either "all" (case-insensitive) or year ranges (optionally) separated with
21/// semicolons. A year range can be a single year (e.g. 2020) or a range with a start year and/or
22/// end year (e.g. 2020.., ..2020, 2020..2025).
23///
24/// # Arguments
25///
26/// - `s` - Input string to parse
27/// - `valid_years` - The possible years which can be referenced in `s` (must be sorted and unique)
28///
29/// # Returns
30///
31/// A [`Vec`] of years or an error.
32///
33/// # Panics
34///
35/// If `valid_years` is empty, unsorted or contains duplicates.
36pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
37    assert!(!valid_years.is_empty(), "`valid_years` cannot be empty");
38    assert!(
39        is_sorted_and_unique(valid_years),
40        "`valid_years` must be sorted and unique"
41    );
42
43    let s = s.trim();
44    ensure!(!s.is_empty(), "No years provided");
45
46    if s.eq_ignore_ascii_case("all") {
47        return Ok(Vec::from_iter(valid_years.iter().copied()));
48    }
49
50    // Get ranges of years, separated by semicolons. Note that a range can be a single year.
51    let ranges: Vec<_> = s
52        .split(';')
53        .map(|s| {
54            let (start, end) = partition(s, "..").unwrap_or((s, s));
55            parse_range_parts(
56                start,
57                end,
58                u32::MIN..=u32::MAX,
59                *valid_years.first().unwrap(),
60                *valid_years.last().unwrap(),
61            )
62            .with_context(|| format!("Invalid year range: {s}"))
63        })
64        .try_collect()?;
65
66    ensure!(
67        is_sorted_and_unique_with(ranges.iter(), |a, b| {
68            a.start() < b.start() && a.end() < b.start()
69        }),
70        "Year ranges must be sorted and non-overlapping"
71    );
72
73    let mut years = Vec::new();
74    for range in ranges {
75        let old_len = years.len();
76        years.extend(get_valid_years_in_range(&range, valid_years));
77
78        // No valid years in range
79        if years.len() == old_len {
80            // For readability, provide different error messages for single year vs range
81            if range.start() == range.end() {
82                bail!("Invalid year: {}", range.start());
83            }
84            bail!(
85                "No valid years in year range: {}..{}",
86                range.start(),
87                range.end()
88            );
89        }
90    }
91
92    Ok(years)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::fixture::assert_error;
99    use rstest::rstest;
100
101    #[rstest]
102    #[case("2020", &[2020, 2021], &[2020])]
103    #[case("all", &[2020, 2021], &[2020, 2021])]
104    #[case("ALL", &[2020, 2021], &[2020, 2021])]
105    #[case(" ALL ", &[2020, 2021], &[2020, 2021])]
106    #[case("2020;2021", &[2020, 2021], &[2020, 2021])]
107    #[case("  2020;  2021", &[2020, 2021], &[2020, 2021])] // whitespace should be stripped
108    #[case("2019..2026", &[2020, 2025], &[2020, 2025])]
109    #[case("..2023", &[2020, 2025], &[2020])] // Empty start
110    #[case("2021..", &[2020, 2025], &[2025])] // Empty end
111    #[case("2020;2021..2022", &[2020, 2021, 2022], &[2020, 2021, 2022])] // Can have multiple ranges
112    fn parse_year_str_valid(
113        #[case] input: &str,
114        #[case] milestone_years: &[u32],
115        #[case] expected: &[u32],
116    ) {
117        assert_eq!(parse_year_str(input, milestone_years).unwrap(), expected);
118    }
119
120    #[rstest]
121    #[case("", &[2020], "No years provided")]
122    #[case("2021", &[2020], "Invalid year: 2021")]
123    #[case("a;2020", &[2020], "Invalid year range: a")]
124    #[case("2021;2020", &[2020, 2021], "Year ranges must be sorted and non-overlapping")] // out of order
125    #[case("2021;2020;2021", &[2020, 2021], "Year ranges must be sorted and non-overlapping")] // duplicate
126    #[case("2021..2020", &[2020, 2021], "Invalid year range: 2021..2020")] // out of order
127    #[case("2021..2024", &[2020, 2025], "No valid years in year range: 2021..2024")]
128    #[case("..2020..2025", &[2020, 2025], "Invalid year range: ..2020..2025")]
129    #[case("2020...2025", &[2020, 2025], "Invalid year range: 2020...2025")]
130    #[case("..", &[2020, 2025], "Invalid year range: ..")]
131    fn parse_year_str_invalid(
132        #[case] input: &str,
133        #[case] milestone_years: &[u32],
134        #[case] error_msg: &str,
135    ) {
136        assert_error!(parse_year_str(input, milestone_years), error_msg);
137    }
138}