datasources 0.1.5

Async datasource interfaces, middleware, registry, and test doubles
Documentation
const UNCOUNTABLE: &[&str] = &["sheep", "fish", "series", "species", "deer", "news"];
const IRREGULAR: &[(&str, &str)] = &[
    ("person", "people"),
    ("child", "children"),
    ("man", "men"),
    ("woman", "women"),
    ("mouse", "mice"),
    ("goose", "geese"),
    ("tooth", "teeth"),
    ("foot", "feet"),
    ("ox", "oxen"),
];

pub struct PluralizerConverter;

impl PluralizerConverter {
    pub fn convert(&self, from: &str) -> String {
        apply(from, plural)
    }

    pub fn reverse_convert(&self, to: &str) -> String {
        apply(to, singular)
    }
}

fn apply(word: &str, transform: fn(&str) -> String) -> String {
    match word.is_empty() {
        true => String::new(),
        false => restore(word, &transform(&word.to_lowercase())),
    }
}

fn restore(original: &str, result: &str) -> String {
    match original.chars().next() {
        Some(first) if first.is_uppercase() => {
            let mut chars = result.chars();
            chars.next().unwrap().to_uppercase().collect::<String>() + chars.as_str()
        }
        _ => result.to_string(),
    }
}

fn irregular_plural(word: &str) -> Option<&'static str> {
    IRREGULAR
        .iter()
        .find(|(singular, _)| *singular == word)
        .map(|(_, plural)| *plural)
}

fn irregular_singular(word: &str) -> Option<&'static str> {
    IRREGULAR
        .iter()
        .find(|(_, plural)| *plural == word)
        .map(|(singular, _)| *singular)
}

fn plural(word: &str) -> String {
    match irregular_plural(word) {
        Some(plural) => plural.to_string(),
        None if UNCOUNTABLE.contains(&word) || irregular_singular(word).is_some() => {
            word.to_string()
        }
        None if yies(word) || sibilant_es(word) => word.to_string(),
        None if sibilant(word) => format!("{word}es"),
        None if consonant_y(word) => format!("{}ies", &word[..word.len() - 1]),
        None if word.ends_with('s') => word.to_string(),
        None => format!("{word}s"),
    }
}

fn singular(word: &str) -> String {
    match irregular_singular(word) {
        Some(singular) => singular.to_string(),
        None if UNCOUNTABLE.contains(&word) || irregular_plural(word).is_some() => word.to_string(),
        None if yies(word) => format!("{}y", &word[..word.len() - 3]),
        None if sibilant_es(word) => word[..word.len() - 2].to_string(),
        None if word.ends_with('s') && !sibilant(word) => word[..word.len() - 1].to_string(),
        None => word.to_string(),
    }
}

fn yies(word: &str) -> bool {
    word.len() >= 4 && word.ends_with("ies") && consonant(nth_last(word, 4))
}

fn consonant_y(word: &str) -> bool {
    word.len() >= 2 && word.ends_with('y') && consonant(nth_last(word, 2))
}

fn sibilant_es(word: &str) -> bool {
    word.len() > 2 && word.ends_with("es") && sibilant(&word[..word.len() - 2])
}

fn sibilant(word: &str) -> bool {
    word.ends_with("ch")
        || word.ends_with("sh")
        || word.ends_with('x')
        || word.ends_with('z')
        || word.ends_with("ss")
        || (word.len() >= 2
            && word.ends_with('s')
            && !word.ends_with("es")
            && vowel(nth_last(word, 2)))
}

fn nth_last(word: &str, n: usize) -> char {
    word.as_bytes()[word.len() - n] as char
}

fn vowel(c: char) -> bool {
    matches!(c, 'a' | 'e' | 'i' | 'o' | 'u')
}

fn consonant(c: char) -> bool {
    c.is_ascii_alphabetic() && !vowel(c)
}

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

    #[test]
    fn pluralizes_and_singularizes() {
        let pairs = [
            ("person", "people"),
            ("child", "children"),
            ("man", "men"),
            ("woman", "women"),
            ("mouse", "mice"),
            ("goose", "geese"),
            ("tooth", "teeth"),
            ("foot", "feet"),
            ("ox", "oxen"),
            ("sheep", "sheep"),
            ("fish", "fish"),
            ("series", "series"),
            ("species", "species"),
            ("deer", "deer"),
            ("news", "news"),
            ("box", "boxes"),
            ("dish", "dishes"),
            ("church", "churches"),
            ("quiz", "quizes"),
            ("kiss", "kisses"),
            ("bus", "buses"),
            ("city", "cities"),
            ("cat", "cats"),
            ("toy", "toys"),
            ("2y", "2ys"),
            ("Person", "People"),
            ("", ""),
        ];
        for (singular, plural) in pairs {
            assert_eq!(PluralizerConverter.convert(singular), plural);
            assert_eq!(PluralizerConverter.convert(plural), plural);
            assert_eq!(PluralizerConverter.reverse_convert(plural), singular);
            assert_eq!(PluralizerConverter.reverse_convert(singular), singular);
        }
    }
}