#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Extracted<T> {
Found(T),
NotFound,
Defaulted(T),
}
impl<T> Extracted<T> {
pub fn is_found(&self) -> bool {
matches!(self, Extracted::Found(_))
}
pub fn is_not_found(&self) -> bool {
matches!(self, Extracted::NotFound)
}
pub fn is_defaulted(&self) -> bool {
matches!(self, Extracted::Defaulted(_))
}
pub fn value(&self) -> Option<&T> {
match self {
Extracted::Found(v) | Extracted::Defaulted(v) => Some(v),
Extracted::NotFound => None,
}
}
}
#[derive(Debug)]
pub struct PartialDate {
pub day: Day,
pub month: Month,
pub year: Year,
}
#[derive(Debug)]
pub struct Day {
pub value: Extracted<u8>,
}
#[derive(Debug)]
pub struct Month {
pub number: Extracted<u8>,
pub name: Extracted<MonthName>,
}
#[derive(Debug)]
pub struct Year {
pub value: Extracted<i32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IsExpected {
Yes,
No,
#[default]
Maybe,
}
#[derive(Debug, Clone)]
pub struct DayConfig {
pub min: u8,
pub max: u8,
pub expected: IsExpected,
pub default: Option<u8>,
}
impl DayConfig {
pub fn try_as_day_candidate(&self, value: i16, digit_count: u8) -> Option<u8> {
if digit_count == 4 {
return None;
}
let as_u8 = u8::try_from(value).ok()?;
if (1..=31).contains(&value) && (self.min..=self.max).contains(&as_u8) {
Some(as_u8)
} else {
None
}
}
}
impl Default for DayConfig {
fn default() -> Self {
DayConfig {
min: 1,
max: 31,
expected: IsExpected::Maybe,
default: None,
}
}
}
impl DayConfig {
pub fn with_range(self, min: u8, max: u8) -> Self {
assert!(
min <= max,
"DayConfig::with_range min ({min}) must not exceed max ({max})"
);
DayConfig { min, max, ..self }
}
pub fn try_with_range(self, min: u8, max: u8) -> Result<Self, ConfigRangeError> {
if min > max {
return Err(ConfigRangeError::MinExceedsMax {
min: min as i32,
max: max as i32,
});
}
Ok(DayConfig { min, max, ..self })
}
pub fn with_expected(self, expected: IsExpected) -> Self {
DayConfig { expected, ..self }
}
pub fn with_default(self, default: u8) -> Self {
DayConfig {
default: Some(default),
..self
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigRangeError {
MinExceedsMax { min: i32, max: i32 },
}
#[derive(Debug, Clone)]
pub struct MonthConfig {
pub min: u8,
pub max: u8,
pub expected: IsExpected,
pub default: Option<u8>,
}
impl MonthConfig {
pub fn try_as_month_candidate(&self, value: i16, digit_count: u8) -> Option<u8> {
if digit_count == 4 {
return None;
}
let as_u8 = u8::try_from(value).ok()?;
if (1..=12).contains(&value) && (self.min..=self.max).contains(&as_u8) {
Some(as_u8)
} else {
None
}
}
}
impl Default for MonthConfig {
fn default() -> Self {
MonthConfig {
min: 1,
max: 12,
expected: IsExpected::Maybe,
default: None,
}
}
}
impl MonthConfig {
pub fn with_range(self, min: u8, max: u8) -> Self {
assert!(
min <= max,
"MonthConfig::with_range min ({min}) must not exceed max ({max})"
);
MonthConfig { min, max, ..self }
}
pub fn try_with_range(self, min: u8, max: u8) -> Result<Self, ConfigRangeError> {
if min > max {
return Err(ConfigRangeError::MinExceedsMax {
min: min as i32,
max: max as i32,
});
}
Ok(MonthConfig { min, max, ..self })
}
pub fn with_expected(self, expected: IsExpected) -> Self {
MonthConfig { expected, ..self }
}
pub fn with_default(self, default: u8) -> Self {
MonthConfig {
default: Some(default),
..self
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SlidingWindowPivot(u8);
impl SlidingWindowPivot {
pub fn new(pivot: u8) -> Self {
assert!(
pivot > 0 && pivot <= 99,
"SlidingWindowPivot must be in the range 1–99, got {pivot}"
);
SlidingWindowPivot(pivot)
}
pub fn try_new(pivot: u8) -> Result<Self, SlidingWindowPivotError> {
if pivot == 0 || pivot > 99 {
return Err(SlidingWindowPivotError::InvalidPivot(pivot));
}
Ok(SlidingWindowPivot(pivot))
}
}
impl TryFrom<u8> for SlidingWindowPivot {
type Error = SlidingWindowPivotError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
SlidingWindowPivot::try_new(value)
}
}
impl From<SlidingWindowPivot> for u8 {
fn from(pivot: SlidingWindowPivot) -> u8 {
pivot.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlidingWindowPivotError {
InvalidPivot(u8),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Century(i32);
impl Century {
pub fn new(year: i32) -> Self {
assert!(
year % 100 == 0,
"Century must be divisible by 100, got {year}"
);
Century(year)
}
pub fn try_new(year: i32) -> Result<Self, CenturyError> {
if year % 100 != 0 {
return Err(CenturyError::NotACenturyBoundary(year));
}
Ok(Century(year))
}
}
impl TryFrom<i32> for Century {
type Error = CenturyError;
fn try_from(value: i32) -> Result<Self, Self::Error> {
Century::try_new(value)
}
}
impl From<Century> for i32 {
fn from(century: Century) -> i32 {
century.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CenturyError {
NotACenturyBoundary(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TwoDigitYearExpansion {
SlidingWindow {
earliest_year: i32,
pivot: SlidingWindowPivot,
},
Always(Century),
Literal,
}
impl Default for TwoDigitYearExpansion {
fn default() -> Self {
TwoDigitYearExpansion::SlidingWindow {
earliest_year: 1950,
pivot: SlidingWindowPivot(50),
}
}
}
#[derive(Debug, Clone)]
pub struct YearConfig {
pub min: i32,
pub max: i32,
pub expected: IsExpected,
pub default: Option<i32>,
pub two_digit_expansion: TwoDigitYearExpansion,
pub single_digit_year_expansion: bool,
}
impl YearConfig {
pub fn try_as_year_candidate(&self, value: i16, digit_count: u8) -> Option<i32> {
let (effective_value, effective_digit_count) =
if digit_count == 1 && self.single_digit_year_expansion {
(value, 2u8)
} else {
(value, digit_count)
};
let expanded = match effective_digit_count {
4 => effective_value as i32,
3 => effective_value as i32,
2 => {
let raw = effective_value as i32;
match &self.two_digit_expansion {
TwoDigitYearExpansion::Literal => raw,
TwoDigitYearExpansion::Always(century) => i32::from(*century) + raw,
TwoDigitYearExpansion::SlidingWindow {
earliest_year,
pivot,
} => {
let pivot = u8::from(*pivot) as i32;
if raw < pivot {
earliest_year + (100 - pivot) + raw
} else {
earliest_year + (raw - pivot)
}
}
}
}
_ => return None,
};
if expanded >= self.min && expanded <= self.max {
Some(expanded)
} else {
None
}
}
}
impl Default for YearConfig {
fn default() -> Self {
YearConfig {
min: 0,
max: 3000,
expected: IsExpected::Maybe,
default: None,
two_digit_expansion: TwoDigitYearExpansion::default(),
single_digit_year_expansion: false,
}
}
}
impl YearConfig {
pub fn with_range(self, min: i32, max: i32) -> Self {
assert!(
min <= max,
"YearConfig::with_range min ({min}) must not exceed max ({max})"
);
YearConfig { min, max, ..self }
}
pub fn try_with_range(self, min: i32, max: i32) -> Result<Self, ConfigRangeError> {
if min > max {
return Err(ConfigRangeError::MinExceedsMax { min, max });
}
Ok(YearConfig { min, max, ..self })
}
pub fn with_expected(self, expected: IsExpected) -> Self {
YearConfig { expected, ..self }
}
pub fn with_default(self, default: i32) -> Self {
YearConfig {
default: Some(default),
..self
}
}
pub fn with_two_digit_expansion(self, two_digit_expansion: TwoDigitYearExpansion) -> Self {
YearConfig {
two_digit_expansion,
..self
}
}
pub fn with_single_digit_expansion(self, enabled: bool) -> Self {
YearConfig {
single_digit_year_expansion: enabled,
..self
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateComponent {
Day,
Month,
Year,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ComponentOrder {
pub first: DateComponent,
pub second: DateComponent,
pub third: DateComponent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ComponentOrderError {
DuplicateComponent(DateComponent),
}
impl ComponentOrder {
pub fn new(
first: DateComponent,
second: DateComponent,
third: DateComponent,
) -> Result<Self, ComponentOrderError> {
if first == second {
return Err(ComponentOrderError::DuplicateComponent(first));
}
if first == third {
return Err(ComponentOrderError::DuplicateComponent(first));
}
if second == third {
return Err(ComponentOrderError::DuplicateComponent(second));
}
Ok(ComponentOrder {
first,
second,
third,
})
}
}
impl Default for ComponentOrder {
fn default() -> Self {
ComponentOrder {
first: DateComponent::Day,
second: DateComponent::Month,
third: DateComponent::Year,
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub day: DayConfig,
pub month: MonthConfig,
pub year: YearConfig,
pub component_order: ComponentOrder,
pub no_separator: bool,
pub extra_separators: Vec<String>,
pub letter_o_substitution: bool,
}
impl Default for Config {
fn default() -> Self {
Config {
day: DayConfig::default(),
month: MonthConfig::default(),
year: YearConfig::default(),
component_order: ComponentOrder::default(),
no_separator: false,
extra_separators: Vec::new(),
letter_o_substitution: true,
}
}
}
impl Config {
pub fn with_day(self, day: DayConfig) -> Self {
Config { day, ..self }
}
pub fn with_month(self, month: MonthConfig) -> Self {
Config { month, ..self }
}
pub fn with_year(self, year: YearConfig) -> Self {
Config { year, ..self }
}
pub fn with_component_order(self, component_order: ComponentOrder) -> Self {
Config {
component_order,
..self
}
}
pub fn with_no_separator(self, no_separator: bool) -> Self {
Config {
no_separator,
..self
}
}
pub fn with_extra_separators(self, extra_separators: Vec<String>) -> Self {
Config {
extra_separators,
..self
}
}
pub fn with_letter_o_substitution(self, letter_o_substitution: bool) -> Self {
Config {
letter_o_substitution,
..self
}
}
}
#[derive(Debug, Clone)]
pub struct Input {
pub utterance: String,
pub config: Option<Config>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MonthName {
January,
February,
March,
April,
May,
June,
July,
August,
September,
October,
November,
December,
}
impl MonthName {
pub fn number(self) -> u8 {
match self {
MonthName::January => 1,
MonthName::February => 2,
MonthName::March => 3,
MonthName::April => 4,
MonthName::May => 5,
MonthName::June => 6,
MonthName::July => 7,
MonthName::August => 8,
MonthName::September => 9,
MonthName::October => 10,
MonthName::November => 11,
MonthName::December => 12,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MonthNameError {
UnrecognisedName,
NumberOutOfRange(u8),
NotAMonth,
}
impl TryFrom<u8> for MonthName {
type Error = MonthNameError;
fn try_from(n: u8) -> Result<Self, Self::Error> {
match n {
1 => Ok(MonthName::January),
2 => Ok(MonthName::February),
3 => Ok(MonthName::March),
4 => Ok(MonthName::April),
5 => Ok(MonthName::May),
6 => Ok(MonthName::June),
7 => Ok(MonthName::July),
8 => Ok(MonthName::August),
9 => Ok(MonthName::September),
10 => Ok(MonthName::October),
11 => Ok(MonthName::November),
12 => Ok(MonthName::December),
_ => Err(MonthNameError::NumberOutOfRange(n)),
}
}
}
impl TryFrom<&str> for MonthName {
type Error = MonthNameError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
let s = s.strip_suffix('.').unwrap_or(s);
if s.is_empty() {
return Err(MonthNameError::NotAMonth);
}
if s.chars().all(|c| c.is_ascii_alphabetic()) {
let lower = s.to_ascii_lowercase();
match_month_name_str(lower.as_str())
} else if s.chars().all(|c| c.is_ascii_digit()) {
let n: u8 = s.parse().map_err(|_| MonthNameError::NumberOutOfRange(0))?;
MonthName::try_from(n)
} else {
Err(MonthNameError::NotAMonth)
}
}
}
const FULL_MONTH_NAMES: &[(&str, MonthName)] = &[
("january", MonthName::January),
("february", MonthName::February),
("march", MonthName::March),
("april", MonthName::April),
("may", MonthName::May),
("june", MonthName::June),
("july", MonthName::July),
("august", MonthName::August),
("september", MonthName::September),
("october", MonthName::October),
("november", MonthName::November),
("december", MonthName::December),
];
const FUZZY_MATCH_THRESHOLD: f32 = 0.6;
fn match_month_name_str(lower: &str) -> Result<MonthName, MonthNameError> {
let exact = match lower {
"january" | "jan" => Some(MonthName::January),
"february" | "feb" => Some(MonthName::February),
"march" | "mar" => Some(MonthName::March),
"april" | "apr" => Some(MonthName::April),
"may" => Some(MonthName::May),
"june" | "jun" => Some(MonthName::June),
"july" | "jul" => Some(MonthName::July),
"august" | "aug" => Some(MonthName::August),
"september" | "sep" => Some(MonthName::September),
"october" | "oct" => Some(MonthName::October),
"november" | "nov" => Some(MonthName::November),
"december" | "dec" => Some(MonthName::December),
_ => None,
};
if let Some(month) = exact {
return Ok(month);
}
if lower.len() >= 4 {
let mut found: Option<MonthName> = None;
for (full_name, variant) in FULL_MONTH_NAMES {
if full_name.starts_with(lower) {
if found.is_some() {
found = None;
break;
}
found = Some(*variant);
}
}
if let Some(month) = found {
return Ok(month);
}
}
fuzzy_match_month(lower)
}
fn fuzzy_match_month(lower: &str) -> Result<MonthName, MonthNameError> {
use crate::levenshtein::levenshtein_ratio;
let mut best_ratio: f32 = 0.0;
let mut best_month: Option<MonthName> = None;
let mut is_tied = false;
for (full_name, variant) in FULL_MONTH_NAMES {
let ratio = levenshtein_ratio(lower, full_name);
if ratio > best_ratio {
best_ratio = ratio;
best_month = Some(*variant);
is_tied = false;
} else if (ratio - best_ratio).abs() < f32::EPSILON {
is_tied = true;
}
}
if best_ratio >= FUZZY_MATCH_THRESHOLD && !is_tied {
best_month.ok_or(MonthNameError::UnrecognisedName)
} else {
Err(MonthNameError::UnrecognisedName)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Token {
Numeric(i16, u8),
OrdinalDay(u8),
MonthName(MonthName),
}