libbarto 0.6.1

A websocket based job scheduling system library for bartos, bartoc, barto-cli
Documentation
// Copyright (c) 2025 barto developers
//
// Licensed under the Apache License, Version 2.0
// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0> or the MIT
// license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
// option. All files in the project carrying such notice may not be copied,
// modified, or distributed except according to those terms.

use std::{
    fmt::{Display, Formatter},
    str::FromStr,
    sync::LazyLock,
};

use anyhow::{Error, Result};
use regex::Regex;

use crate::{
    error::Error::InvalidYear,
    realtime::cv::{ConstrainedValue, ConstrainedValueParser},
};

static YEAR_RANGE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^(-?\d+|\+?\d+)\.\.(-?\d+|\+?\d+)$").expect("invalid year range regex")
});
static YEAR_REPETITION_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^(-?\d+|\+?\d+)(\.\.(-?\d+|\+?\d+))?\/(\+?\d+)$")
        .expect("invalid year repetition regex")
});

/// A year constraint for realtime schedules (`i32::MIN..=i32::MAX`)
pub type Year = ConstrainedValue<i32>;

impl Default for Year {
    fn default() -> Self {
        Year::All
    }
}

impl TryFrom<&str> for Year {
    type Error = Error;

    fn try_from(s: &str) -> Result<Self> {
        Year::parse(s)
    }
}

impl FromStr for Year {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        Year::try_from(s)
    }
}

impl ConstrainedValueParser<'_, i32> for Year {
    fn invalid(s: &str) -> Error {
        InvalidYear(s.to_string()).into()
    }

    fn all() -> Self {
        Year::All
    }

    fn rand() -> Self {
        unreachable!("Year does not support 'R' for random value")
    }

    fn repetition_regex() -> Regex {
        YEAR_REPETITION_RE.clone()
    }

    fn range_regex() -> Regex {
        YEAR_RANGE_RE.clone()
    }

    fn rep(start: i32, end: Option<i32>, rep: u8) -> Self {
        Year::Repetition { start, end, rep }
    }

    fn range(first: i32, second: i32) -> Self {
        Year::Range(first, second)
    }

    fn specific(values: Vec<i32>) -> Self {
        Year::Specific(values)
    }
}

impl Display for Year {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Year::All => write!(f, "*"),
            Year::Specific(values) => {
                let s = values
                    .iter()
                    .map(ToString::to_string)
                    .collect::<Vec<String>>()
                    .join(",");
                write!(f, "{s}")
            }
            Year::Range(first, second) => write!(f, "{first}..{second}"),
            Year::Repetition { start, end, rep } => {
                if let Some(end) = end {
                    write!(f, "{start}..{end}/{rep}")
                } else {
                    write!(f, "{start}/{rep}")
                }
            }
        }
    }
}

#[cfg(test)]
pub(crate) mod test {
    use std::{cmp::Ordering, fmt::Write as _, sync::LazyLock};

    use anyhow::Result;
    use proptest::{
        prelude::{any, proptest},
        prop_assume, prop_compose,
    };
    use rand::{Rng as _, rng};
    use regex::Regex;

    use crate::realtime::cv::{ConstrainedValueMatcher as _, ConstrainedValueParser};

    use super::{YEAR_RANGE_RE, YEAR_REPETITION_RE, Year};

    pub(crate) static VALID_I32_RE: LazyLock<Regex> =
        LazyLock::new(|| Regex::new(r"^(-?|\+?)\d+$").expect("invalid at least 4 digits regex"));

    // Valid strategies
    prop_compose! {
        pub(crate) fn arb_year() (year in any::<i32>(), sign in any::<bool>()) -> (String, i32) {
            let year_str = if sign && year >= 0 {
                format!("+{year}")
            } else {
                year.to_string()
            };
            (year_str, year)
        }
    }

    prop_compose! {
        pub(crate) fn arb_valid_year_range()(first in any::<i32>(), second in any::<i32>()) -> (String, i32, i32) {
            if first <= second {
                (format!("{first}..{second}"), first, second)
            } else {
                (format!("{second}..{first}"), second, first)
            }
        }
    }

    prop_compose! {
        fn arb_valid_repetition()(s in arb_valid_year_range(), rep in any::<u8>(), sign in any::<bool>()) -> (String, i32, i32, u8) {
            let (mut prefix, min, max) = s;
            let rep = if rep == 0 { 1 } else { rep };
            let rep_str = if sign {
                format!("+{rep}")
            } else {
                rep.to_string()
            };
            write!(prefix, "/{rep_str}").unwrap();
            (prefix, min, max, rep)
        }
    }

    prop_compose! {
        fn arb_valid_repetition_no_end()(first in any::<i32>(), rep in any::<u8>()) -> String {
            let mut prefix = format!("{first}");
            let rep = if rep == 0 { 1 } else { rep };
            write!(prefix, "/{rep}").unwrap();
            prefix
        }
    }

    // Invalid strategies
    prop_compose! {
        fn arb_invalid_range()(mut first in any::<i32>(), second in any::<i32>()) -> String {
            if first == second {
                first += 1;
            }
            match first.cmp(&second) {
                Ordering::Less | Ordering::Equal => format!("{second}..{first}"),
                Ordering::Greater => format!("{first}..{second}"),
            }
        }
    }

    prop_compose! {
        fn arb_invalid_repetition()(s in arb_invalid_range(), rep in any::<u8>()) -> String {
            let mut prefix = s;
            write!(prefix, "/{rep}").unwrap();
            prefix
        }
    }

    prop_compose! {
        fn arb_invalid_repetition_zero_rep()(s in arb_valid_year_range()) -> String {
            let (mut prefix, _, _) = s;
            write!(prefix, "/0").unwrap();
            prefix
        }
    }

    // Valid inputs
    proptest! {
        #[test]
        fn arb_year_works(s in arb_year()) {
            let (year, _) = s;
            assert!(Year::try_from(year.as_str()).is_ok());
            assert!(year.parse::<Year>().is_ok());
        }

        #[test]
        fn arb_valid_year_range_works(s in arb_valid_year_range()) {
            let (s, _, _) = s;
            assert!(Year::try_from(s.as_str()).is_ok());
            assert!(s.parse::<Year>().is_ok());
        }

        #[test]
        fn arb_valid_year_repetition_works(s in arb_valid_repetition()) {
            let (prefix, _, _, _) = s;
            assert!(Year::try_from(prefix.as_str()).is_ok());
            assert!(prefix.parse::<Year>().is_ok());
        }

        #[test]
        fn arb_valid_year_repetition_no_end_works(s in arb_valid_repetition_no_end()) {
            assert!(Year::try_from(s.as_str()).is_ok());
            assert!(s.parse::<Year>().is_ok());
        }
    }

    // Invalid inputs
    proptest! {
        #[test]
        fn random_input_errors(s in "\\PC*") {
            prop_assume!(!VALID_I32_RE.is_match(s.as_str()));
            prop_assume!(!YEAR_REPETITION_RE.is_match(s.as_str()));
            prop_assume!(!YEAR_RANGE_RE.is_match(s.as_str()));
            prop_assume!(s.as_str() != "*");
            assert!(Year::try_from(s.as_str()).is_err());
            assert!(s.parse::<Year>().is_err());
        }

        #[test]
        fn arb_invalid_year_range_errors(s in arb_invalid_range()) {
            assert!(Year::try_from(s.as_str()).is_err());
            assert!(s.parse::<Year>().is_err());
        }

        #[test]
        fn arb_invalid_year_repetition_errors(s in arb_invalid_repetition()) {
            assert!(Year::try_from(s.as_str()).is_err());
            assert!(s.parse::<Year>().is_err());
        }

        #[test]
        fn arb_invalid_year_repetition_zero_rep_errors(s in arb_invalid_repetition_zero_rep()) {
            assert!(Year::try_from(s.as_str()).is_err());
            assert!(s.parse::<Year>().is_err());
        }

        #[test]
        fn any_valid_range_matches(s in arb_valid_year_range()) {
            let (range_str, min, max) = s;
            match Year::try_from(range_str.as_str()) {
                Err(e) => panic!("valid range '{range_str}' failed to parse: {e}"),
                Ok(year_range) => for _ in 0..256 {
                    let in_range = rng().random_range(min..=max);
                    assert!(year_range.matches(in_range), "day {in_range} should match range '{range_str}'");
                    if min > i32::MIN {
                        let below = rng().random_range(i32::MIN..min);
                        assert!(!year_range.matches(below), "day {below} should not match range '{range_str}'");
                    }
                    if max + 1 < i32::MAX {
                        let above = rng().random_range((max + 1)..=i32::MAX);
                        assert!(!year_range.matches(above), "day {above} should not match range '{range_str}'");
                    }
                },
            }
        }
    }

    #[test]
    fn empty_string_errors() {
        assert!(Year::try_from("").is_err());
        assert!("".parse::<Year>().is_err());
    }

    #[test]
    fn all() -> Result<()> {
        assert_eq!(Year::All, Year::try_from("*")?);
        assert_eq!(Year::All, "*".parse::<Year>()?);
        Ok(())
    }

    #[test]
    #[should_panic = "internal error: entered unreachable code: Year does not support 'R' for random value"]
    fn rand_panics() {
        assert!(!Year::allow_rand());
        let _blah = Year::rand();
    }

    #[test]
    fn default_is_all() {
        let default_year = Year::default();
        assert_eq!(Year::All, default_year);
    }

    #[test]
    fn display_works() -> Result<()> {
        let year = Year::try_from("2020..2025")?;
        assert_eq!(year.to_string(), "2020..2025");

        let year = Year::try_from("2020/2")?;
        assert_eq!(year.to_string(), "2020/2");

        let year = Year::try_from("2020..2025/3")?;
        assert_eq!(year.to_string(), "2020..2025/3");

        let year = Year::try_from("2021,2023,2025")?;
        assert_eq!(year.to_string(), "2021,2023,2025");

        let year = Year::All;
        assert_eq!(year.to_string(), "*");

        Ok(())
    }
}