os-identifier 0.3.3

Resolve product / release names of operating systems used by endoflife.date into canonical names.
Documentation
use regex::Regex;

//
// Returns true if a string consists of multiple parts separated by '-'.
// or not separated at all
// String does not use any other separator.
// . is not considered a separator
//
pub(crate) fn is_subdivided_by_dashes_only(s: &str) -> bool {
    // Return false if string is empty
    if s.is_empty() {
        return false;
    }

    // Return false if there are invalid separators present
    let forbidden_separators = [' ', '_', ',', ';', '/', '\\', ':', '|', '+'];
    if s.chars().any(|ch| forbidden_separators.contains(&ch)) {
        return false;
    }

    // If splitting by dash gives more than one part, it's separated by dashes
    // and no other separators are present
    s.split('-').count() > 0
}

//
pub fn contains_any_word(haystack: &str, words: &[&str]) -> bool {
    let pattern = words
        .iter()
        .map(|w| regex::escape(w))
        .collect::<Vec<_>>()
        .join("|");
    let regex_pattern = format!(r"\b({})\b", pattern);
    let re = Regex::new(&regex_pattern).unwrap();
    re.is_match(haystack)
}

pub fn find_number_with_digits(input: &str, digits: usize) -> Option<String> {
    let pattern = format!(r"\b\d{{{}}}\b", digits);
    let re = Regex::new(&pattern).unwrap();
    re.find(input).map(|m| m.as_str().to_string())
}

pub fn resolve_build_to_release(build: &str, map: phf::Map<&'static str, &'static [&'static str]>) -> Result<String, String> {
    if let Some(release) = map.get(build) {
        Ok(release.get(0).unwrap().to_string())
    } else {
        Err(format!("Build '{}' does not exist.", build))
    }
}

pub fn identify_release(input: &str, pattern: &str) -> Option<String> {
    let pattern = format!(r"\b({})\b", pattern);
    let re = Regex::new(&pattern).unwrap();
    re.find(input).map(|m| m.as_str().to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_subdivided_by_dashes_only() {
        let label1 = "windows-11-24h2-iot-lts";
        let label2 = "windows-11-24h2-iot lts";
        let label3 = "2025";
        let label4 = "8.1";

        assert_eq!(is_subdivided_by_dashes_only(label1), true);
        assert_eq!(is_subdivided_by_dashes_only(label2), false);
        assert_eq!(is_subdivided_by_dashes_only(label3), true);
        assert_eq!(is_subdivided_by_dashes_only(label4), true);
    }

    #[test]
    fn test_contains_any_word_true() {
        let label1 = "Windows 11 Professional Edition";
        let label2 = "Windows 11 Pro 24H2";
        let word = ["Professional Edition", "Professional", "Pro"];

        assert_eq!(contains_any_word(label1, &word), true);
        assert_eq!(contains_any_word(label2, &word), true);
    }

    #[test]
    fn test_contains_any_word_false() {
        let label1 = "Windows 11 Professional Edition";
        let label2 = "Windows 11 Pro 24H2";
        let word = ["Enterprise Edition", "Enterprise"];

        assert_eq!(contains_any_word(label1, &word), false);
        assert_eq!(contains_any_word(label2, &word), false);
    }

    #[test]
    fn test_find_number_with_digits_some() {
        let label1 = "Windows 11 Professional Edition 26100";
        let label2 = "Windows 11 Professional Edition 26100.4533";

        assert_eq!(find_number_with_digits(label1, 5), Some(String::from("26100")));
        assert_eq!(find_number_with_digits(label2, 5), Some(String::from("26100")));
    }

    #[test]
    fn test_find_number_with_digits_none() {
        let label1 = "Windows 11 Professional Edition 26100";

        assert_eq!(find_number_with_digits(label1, 4), None);
    }

    #[test]
    fn test_identify_release_some() {
        let label1 = "Windows 11 Professional Edition 24H2";

        assert_eq!(identify_release(label1, "22H2|24H2"), Some(String::from("24H2")));
    }

    #[test]
    fn test_identify_release_none() {
        let label1 = "Windows 11 Professional Edition 25H2";

        assert_eq!(identify_release(label1, "22H2|24H2"), None);
    }
}