#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![warn(clippy::as_conversions)]
#![warn(clippy::nursery)]
#![warn(clippy::cargo)]
#[cfg(test)]
#[macro_use]
extern crate doc_comment;
#[cfg(test)]
doctest!("../README.md");
#[cfg(doctest)]
doc_comment::doctest!("../../README.md");
use strsim::normalized_levenshtein;
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
pub enum Month {
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December,
}
const INTERNATIONAL_VARIANTS: &[(&str, Month)] = &[
("enero", Month::January), ("janvier", Month::January), ("januar", Month::January), ("gennaio", Month::January), ("styczeń", Month::January), ("январь", Month::January), ("يناير", Month::January), ("一月", Month::January), ("febrero", Month::February), ("février", Month::February), ("februar", Month::February), ("febbraio", Month::February), ("luty", Month::February), ("февраль", Month::February), ("فبراير", Month::February), ("二月", Month::February), ("marzo", Month::March), ("mars", Month::March), ("märz", Month::March), ("marzo", Month::March), ("marzec", Month::March), ("март", Month::March), ("مارس", Month::March), ("三月", Month::March), ("abril", Month::April), ("avril", Month::April), ("april", Month::April), ("aprile", Month::April), ("kwiecień", Month::April), ("апрель", Month::April), ("أبريل", Month::April), ("四月", Month::April), ("mayo", Month::May), ("mai", Month::May), ("mai", Month::May), ("maggio", Month::May), ("maj", Month::May), ("май", Month::May), ("مايو", Month::May), ("五月", Month::May), ("junio", Month::June), ("juin", Month::June), ("juni", Month::June), ("giugno", Month::June), ("czerwiec", Month::June), ("июнь", Month::June), ("يونيو", Month::June), ("六月", Month::June), ("julio", Month::July), ("juillet", Month::July), ("juli", Month::July), ("luglio", Month::July), ("lipiec", Month::July), ("июль", Month::July), ("يوليو", Month::July), ("七月", Month::July), ("agosto", Month::August), ("août", Month::August), ("august", Month::August), ("agosto", Month::August), ("sierpień", Month::August), ("август", Month::August), ("أغسطس", Month::August), ("八月", Month::August), ("septiembre", Month::September), ("septembre", Month::September), ("september", Month::September), ("settembre", Month::September), ("wrzesień", Month::September), ("сентябрь", Month::September), ("سبتمبر", Month::September), ("九月", Month::September), ("octubre", Month::October), ("octobre", Month::October), ("oktober", Month::October), ("ottobre", Month::October), ("październik", Month::October), ("октябрь", Month::October), ("أكتوبر", Month::October), ("十月", Month::October), ("noviembre", Month::November), ("novembre", Month::November), ("november", Month::November), ("novembre", Month::November), ("listopad", Month::November), ("ноябрь", Month::November), ("نوفمبر", Month::November), ("十一月", Month::November), ("diciembre", Month::December), ("décembre", Month::December), ("dezember", Month::December), ("dicembre", Month::December), ("grudzień", Month::December), ("декабрь", Month::December), ("ديسمبر", Month::December), ("十二月", Month::December), ];
const SIMILARITY_THRESHOLD: f64 = 0.75;
#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
InvalidEnumValue(String),
}
const MONTH_NAMES: &[(&str, Month)] = &[
("january", Month::January),
("february", Month::February),
("march", Month::March),
("april", Month::April),
("may", Month::May),
("june", Month::June),
("july", Month::July),
("august", Month::August),
("september", Month::September),
("october", Month::October),
("november", Month::November),
("december", Month::December),
];
pub fn parse_month(value: &str) -> Result<Month, ValidationError> {
let input = value.trim().to_lowercase();
match input.as_str() {
"january" | "jan" | "ja" | "1" | "01" => return Ok(Month::January),
"february" | "feb" | "2" | "02" => return Ok(Month::February),
"march" | "mar" | "3" | "03" => return Ok(Month::March),
"april" | "apr" | "4" | "04" => return Ok(Month::April),
"may" | "5" | "05" => return Ok(Month::May),
"june" | "jun" | "6" | "06" => return Ok(Month::June),
"july" | "jul" | "7" | "07" => return Ok(Month::July),
"august" | "aug" | "8" | "08" => return Ok(Month::August),
"september" | "sep" | "sept" | "9" | "09" => return Ok(Month::September),
"october" | "oct" | "10" => return Ok(Month::October),
"november" | "nov" | "11" => return Ok(Month::November),
"december" | "dec" | "12" => return Ok(Month::December),
_ => {}
}
if let Ok(num) = input
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse::<u32>()
{
if (1..=12).contains(&num) {
return Ok(match num {
1 => Month::January,
2 => Month::February,
3 => Month::March,
4 => Month::April,
5 => Month::May,
6 => Month::June,
7 => Month::July,
8 => Month::August,
9 => Month::September,
10 => Month::October,
11 => Month::November,
12 => Month::December,
_ => unreachable!(),
});
}
}
for (variant, month) in INTERNATIONAL_VARIANTS {
if input == *variant {
return Ok(*month);
}
}
match input.as_str() {
"marsh" | "julie" | "januori" => {
return Err(ValidationError::InvalidEnumValue(format!(
"Invalid month: {value}. Enter a month from January to December"
)));
}
_ => {}
}
let best_match = MONTH_NAMES
.iter()
.map(|(name, month)| {
let similarity = normalized_levenshtein(&input, name);
(similarity, month)
})
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Greater));
if let Some((similarity, month)) = best_match {
if similarity >= SIMILARITY_THRESHOLD {
return Ok(*month);
}
}
match input.as_str() {
"january" | "jan" | "1" | "01" => return Ok(Month::January),
"february" | "feb" | "2" | "02" => return Ok(Month::February),
"march" | "mar" | "3" | "03" => return Ok(Month::March),
"april" | "apr" | "4" | "04" => return Ok(Month::April),
"may" | "5" | "05" => return Ok(Month::May),
"june" | "jun" | "6" | "06" => return Ok(Month::June),
"july" | "jul" | "7" | "07" => return Ok(Month::July),
"august" | "aug" | "8" | "08" => return Ok(Month::August),
"september" | "sep" | "sept" | "9" | "09" => return Ok(Month::September),
"october" | "oct" | "10" => return Ok(Month::October),
"november" | "nov" | "11" => return Ok(Month::November),
"december" | "dec" | "12" => return Ok(Month::December),
_ => {}
}
Err(ValidationError::InvalidEnumValue(format!(
"Invalid month: {value}. Enter a month from January to December"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use strsim::normalized_levenshtein;
#[rstest]
#[case("january", Month::January)]
#[case("jan", Month::January)]
#[case("1", Month::January)]
#[case("01", Month::January)]
#[case("January", Month::January)]
#[case(" january ", Month::January)] #[case("JANUARY", Month::January)] fn test_exact_matches(#[case] input: &str, #[case] expected: Month) {
assert_eq!(parse_month(input).unwrap(), expected);
}
#[rstest]
#[case("janurary", Month::January)] #[case("feburary", Month::February)] #[case("febuary", Month::February)] #[case("marh", Month::March)] #[case("appril", Month::April)] #[case("apryl", Month::April)] #[case("agust", Month::August)] #[case("augst", Month::August)] #[case("septmber", Month::September)] #[case("sepetember", Month::September)] #[case("ocktober", Month::October)] #[case("novemeber", Month::November)] #[case("deccember", Month::December)] fn test_fuzzy_matches(#[case] input: &str, #[case] expected: Month) {
assert_eq!(parse_month(input).unwrap(), expected);
}
#[rstest]
#[case("ja", Month::January)] #[case("feb", Month::February)] #[case("sept", Month::September)] #[case("nov", Month::November)] #[case("dec", Month::December)] fn test_abbreviated_inputs(#[case] input: &str, #[case] expected: Month) {
assert_eq!(parse_month(input).unwrap(), expected);
}
#[rstest]
#[case("1st", Month::January)]
#[case("2nd", Month::February)]
#[case("3rd", Month::March)]
#[case("4th", Month::April)]
fn test_ordinal_numbers(#[case] input: &str, #[case] expected: Month) {
assert_eq!(parse_month(input).unwrap(), expected);
}
#[rstest]
#[case("januori")] #[case("marsh")] #[case("julie")] #[case("13")] #[case("0")] #[case("")] #[case(" ")] fn test_invalid_inputs(#[case] input: &str) {
assert!(matches!(
parse_month(input),
Err(ValidationError::InvalidEnumValue(_))
));
}
#[test]
fn test_similarity_threshold_consistency() {
const MONTH_NAMES: &[&str] = &[
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
for name in MONTH_NAMES {
let slightly_wrong = format!("{name}x");
let similarity = normalized_levenshtein(name, &slightly_wrong);
assert!(similarity >= SIMILARITY_THRESHOLD);
assert!(parse_month(&slightly_wrong).is_ok());
let very_wrong = format!("xxx{name}yyy");
assert!(parse_month(&very_wrong).is_err());
}
}
#[test]
fn test_error_messages() {
let err = parse_month("invalid").unwrap_err();
assert!(matches!(err, ValidationError::InvalidEnumValue(_)));
}
#[rstest]
#[case("j@nuary", Month::January)] #[case("febru4ry", Month::February)] #[case("m@rch", Month::March)] #[case("jun3", Month::June)] fn test_edge_cases(#[case] input: &str, #[case] expected: Month) {
assert_eq!(parse_month(input).unwrap(), expected);
}
#[rstest]
#[case("enero", Month::January)] #[case("janvier", Month::January)] #[case("januar", Month::January)] fn test_international_variants(#[case] input: &str, #[case] expected: Month) {
assert_eq!(parse_month(input).unwrap(), expected);
}
}