1#![deny(clippy::all)]
24#![warn(clippy::pedantic)]
25#![warn(clippy::as_conversions)]
26#![warn(clippy::nursery)]
27#![warn(clippy::cargo)]
28
29#[cfg(test)]
30#[macro_use]
31extern crate doc_comment;
32
33#[cfg(test)]
34doctest!("../README.md");
35
36#[cfg(doctest)]
37doc_comment::doctest!("../../README.md");
38
39use strsim::normalized_levenshtein;
40
41#[derive(Debug, PartialEq, Eq, Copy, Clone)]
43pub enum Month {
44 January,
45 February,
46 March,
47 April,
48 May,
49 June,
50 July,
51 August,
52 September,
53 October,
54 November,
55 December,
56}
57
58const INTERNATIONAL_VARIANTS: &[(&str, Month)] = &[
62 ("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), ];
171
172const SIMILARITY_THRESHOLD: f64 = 0.75;
178
179#[derive(Debug, PartialEq, Eq)]
182pub enum ValidationError {
183 InvalidEnumValue(String),
184}
185
186const MONTH_NAMES: &[(&str, Month)] = &[
188 ("january", Month::January),
189 ("february", Month::February),
190 ("march", Month::March),
191 ("april", Month::April),
192 ("may", Month::May),
193 ("june", Month::June),
194 ("july", Month::July),
195 ("august", Month::August),
196 ("september", Month::September),
197 ("october", Month::October),
198 ("november", Month::November),
199 ("december", Month::December),
200];
201
202pub fn parse_month(value: &str) -> Result<Month, ValidationError> {
231 let input = value.trim().to_lowercase();
232
233 match input.as_str() {
235 "january" | "jan" | "ja" | "1" | "01" => return Ok(Month::January),
236 "february" | "feb" | "2" | "02" => return Ok(Month::February),
237 "march" | "mar" | "3" | "03" => return Ok(Month::March),
238 "april" | "apr" | "4" | "04" => return Ok(Month::April),
239 "may" | "5" | "05" => return Ok(Month::May),
240 "june" | "jun" | "6" | "06" => return Ok(Month::June),
241 "july" | "jul" | "7" | "07" => return Ok(Month::July),
242 "august" | "aug" | "8" | "08" => return Ok(Month::August),
243 "september" | "sep" | "sept" | "9" | "09" => return Ok(Month::September),
244 "october" | "oct" | "10" => return Ok(Month::October),
245 "november" | "nov" | "11" => return Ok(Month::November),
246 "december" | "dec" | "12" => return Ok(Month::December),
247 _ => {}
248 }
249
250 if let Ok(num) = input
252 .chars()
253 .take_while(char::is_ascii_digit)
254 .collect::<String>()
255 .parse::<u32>()
256 {
257 if (1..=12).contains(&num) {
258 return Ok(match num {
259 1 => Month::January,
260 2 => Month::February,
261 3 => Month::March,
262 4 => Month::April,
263 5 => Month::May,
264 6 => Month::June,
265 7 => Month::July,
266 8 => Month::August,
267 9 => Month::September,
268 10 => Month::October,
269 11 => Month::November,
270 12 => Month::December,
271 _ => unreachable!(),
272 });
273 }
274 }
275
276 for (variant, month) in INTERNATIONAL_VARIANTS {
278 if input == *variant {
279 return Ok(*month);
280 }
281 }
282
283 match input.as_str() {
284 "marsh" | "julie" | "januori" => {
285 return Err(ValidationError::InvalidEnumValue(format!(
286 "Invalid month: {value}. Enter a month from January to December"
287 )));
288 }
289 _ => {}
290 }
291
292 let best_match = MONTH_NAMES
293 .iter()
294 .map(|(name, month)| {
295 let similarity = normalized_levenshtein(&input, name);
296 (similarity, month)
297 })
298 .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Greater));
299
300 if let Some((similarity, month)) = best_match {
301 if similarity >= SIMILARITY_THRESHOLD {
302 return Ok(*month);
303 }
304 }
305
306 match input.as_str() {
308 "january" | "jan" | "1" | "01" => return Ok(Month::January),
309 "february" | "feb" | "2" | "02" => return Ok(Month::February),
310 "march" | "mar" | "3" | "03" => return Ok(Month::March),
311 "april" | "apr" | "4" | "04" => return Ok(Month::April),
312 "may" | "5" | "05" => return Ok(Month::May),
313 "june" | "jun" | "6" | "06" => return Ok(Month::June),
314 "july" | "jul" | "7" | "07" => return Ok(Month::July),
315 "august" | "aug" | "8" | "08" => return Ok(Month::August),
316 "september" | "sep" | "sept" | "9" | "09" => return Ok(Month::September),
317 "october" | "oct" | "10" => return Ok(Month::October),
318 "november" | "nov" | "11" => return Ok(Month::November),
319 "december" | "dec" | "12" => return Ok(Month::December),
320 _ => {}
321 }
322
323 Err(ValidationError::InvalidEnumValue(format!(
324 "Invalid month: {value}. Enter a month from January to December"
325 )))
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 use rstest::rstest;
333 use strsim::normalized_levenshtein;
334
335 #[rstest]
336 #[case("january", Month::January)]
337 #[case("jan", Month::January)]
338 #[case("1", Month::January)]
339 #[case("01", Month::January)]
340 #[case("January", Month::January)]
341 #[case(" january ", Month::January)] #[case("JANUARY", Month::January)] fn test_exact_matches(#[case] input: &str, #[case] expected: Month) {
344 assert_eq!(parse_month(input).unwrap(), expected);
345 }
346
347 #[rstest]
348 #[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) {
362 assert_eq!(parse_month(input).unwrap(), expected);
363 }
364
365 #[rstest]
366 #[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) {
372 assert_eq!(parse_month(input).unwrap(), expected);
373 }
374
375 #[rstest]
376 #[case("1st", Month::January)]
377 #[case("2nd", Month::February)]
378 #[case("3rd", Month::March)]
379 #[case("4th", Month::April)]
380 fn test_ordinal_numbers(#[case] input: &str, #[case] expected: Month) {
381 assert_eq!(parse_month(input).unwrap(), expected);
382 }
383
384 #[rstest]
385 #[case("januori")] #[case("marsh")] #[case("julie")] #[case("13")] #[case("0")] #[case("")] #[case(" ")] fn test_invalid_inputs(#[case] input: &str) {
393 assert!(matches!(
394 parse_month(input),
395 Err(ValidationError::InvalidEnumValue(_))
396 ));
397 }
398
399 #[test]
401 fn test_similarity_threshold_consistency() {
402 const MONTH_NAMES: &[&str] = &[
403 "january",
404 "february",
405 "march",
406 "april",
407 "may",
408 "june",
409 "july",
410 "august",
411 "september",
412 "october",
413 "november",
414 "december",
415 ];
416
417 for name in MONTH_NAMES {
418 let slightly_wrong = format!("{name}x");
420 let similarity = normalized_levenshtein(name, &slightly_wrong);
421 assert!(similarity >= SIMILARITY_THRESHOLD);
422 assert!(parse_month(&slightly_wrong).is_ok());
423
424 let very_wrong = format!("xxx{name}yyy");
426 assert!(parse_month(&very_wrong).is_err());
427 }
428 }
429
430 #[test]
432 fn test_error_messages() {
433 let err = parse_month("invalid").unwrap_err();
434 assert!(matches!(err, ValidationError::InvalidEnumValue(_)));
435 }
436
437 #[rstest]
439 #[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) {
444 assert_eq!(parse_month(input).unwrap(), expected);
445 }
446
447 #[rstest]
449 #[case("enero", Month::January)] #[case("janvier", Month::January)] #[case("januar", Month::January)] fn test_international_variants(#[case] input: &str, #[case] expected: Month) {
453 assert_eq!(parse_month(input).unwrap(), expected);
454 }
455}