use std::time::Duration;
use crate::i18n::{Arg, I18n};
pub(crate) const LONGEST: u64 = 99 * 3600 + 59 * 60 + 59;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DurationUnit {
Hours,
Minutes,
Seconds,
}
impl DurationUnit {
pub(crate) const ALL: [Self; 3] = [Self::Hours, Self::Minutes, Self::Seconds];
pub(crate) fn seconds(self) -> u64 {
match self {
Self::Hours => 3600,
Self::Minutes => 60,
Self::Seconds => 1,
}
}
fn stem(self) -> &'static str {
match self {
Self::Hours => "hour",
Self::Minutes => "minute",
Self::Seconds => "second",
}
}
fn below(self) -> Option<Self> {
match self {
Self::Hours => Some(Self::Minutes),
Self::Minutes => Some(Self::Seconds),
Self::Seconds => None,
}
}
pub(crate) fn short(self, i18n: &I18n) -> String {
i18n.translate(&format!("quvyta.duration.{}s", self.stem()), &[])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DurationError {
Empty,
Character(char),
UnknownUnit(String),
BadNumber(String),
MissingNumber(String),
MissingUnit(String),
RepeatedUnit(DurationUnit),
BadClock(String),
TooLarge,
}
impl DurationError {
#[must_use]
pub fn message(&self, i18n: &I18n) -> String {
let (key, text) = match self {
Self::Empty => ("empty", String::new()),
Self::Character(c) => ("character", c.to_string()),
Self::UnknownUnit(word) => ("unit", word.clone()),
Self::BadNumber(number) => ("number", number.clone()),
Self::MissingNumber(word) => ("no-number", word.clone()),
Self::MissingUnit(number) => ("no-unit", number.clone()),
Self::RepeatedUnit(unit) => {
("repeated", i18n.translate(&format!("quvyta.duration.{}-name", unit.stem()), &[]))
}
Self::BadClock(text) => ("clock", text.clone()),
Self::TooLarge => ("too-large", write(Duration::from_secs(LONGEST), true, i18n)),
};
i18n.translate(&format!("quvyta.duration.{key}"), &[("text", Arg::Text(text))])
}
}
pub fn parse_duration(text: &str, i18n: &I18n) -> Result<Duration, DurationError> {
let text = text.trim();
if text.is_empty() {
return Err(DurationError::Empty);
}
let seconds = if text.contains(':') { clock(text)? } else { with_units(&tokens(text)?, i18n)? };
if seconds > u128::from(LONGEST) {
return Err(DurationError::TooLarge);
}
Ok(Duration::from_secs(u64::try_from(seconds).unwrap_or(LONGEST)))
}
pub(crate) fn write(duration: Duration, seconds: bool, i18n: &I18n) -> String {
let total = duration.as_secs();
let parts = [total / 3600, total / 60 % 60, total % 60];
let shown = if seconds { 3 } else { 2 };
let written: Vec<String> = DurationUnit::ALL[..shown]
.iter()
.zip(parts)
.filter(|(_, value)| *value > 0)
.map(|(unit, value)| format!("{value} {}", unit.short(i18n)))
.collect();
if written.is_empty() { format!("0 {}", DurationUnit::Minutes.short(i18n)) } else { written.join(" ") }
}
const MAX_DIGITS: usize = 12;
#[derive(Debug)]
struct Number {
text: String,
whole: u128,
fraction: String,
}
impl Number {
fn seconds(&self, unit: DurationUnit) -> u128 {
let unit = u128::from(unit.seconds());
let digits = &self.fraction[..self.fraction.len().min(9)];
let scale = 10u128.pow(u32::try_from(digits.len()).unwrap_or(0));
let fraction: u128 = digits.parse().unwrap_or(0);
self.whole * unit + (fraction * unit + scale / 2) / scale
}
}
#[derive(Debug)]
enum Token {
Number(Number),
Word(String),
}
fn tokens(text: &str) -> Result<Vec<Token>, DurationError> {
let chars: Vec<char> = text.chars().collect();
let is_mark = |c: char| c == '.' || c == ',';
let digit_at = |i: usize| chars.get(i).is_some_and(char::is_ascii_digit);
let mut out = Vec::new();
let mut i = 0;
while let Some(&c) = chars.get(i) {
let start = i;
if c.is_ascii_digit() || (is_mark(c) && digit_at(i + 1)) {
let run = |i: &mut usize| {
while digit_at(*i) {
*i += 1;
}
};
run(&mut i);
let whole_end = i;
let mut marks = 0;
while chars.get(i).is_some_and(|c| is_mark(*c)) && digit_at(i + 1) {
marks += 1;
i += 1;
run(&mut i);
}
let written: String = chars[start..i].iter().collect();
let whole: String = chars[start..whole_end].iter().collect();
if marks > 1 || whole.is_empty() {
return Err(DurationError::BadNumber(written));
}
if whole.trim_start_matches('0').len() > MAX_DIGITS {
return Err(DurationError::TooLarge);
}
let fraction = if marks == 1 { chars[whole_end + 1..i].iter().collect() } else { String::new() };
out.push(Token::Number(Number { text: written, whole: whole.parse().unwrap_or(0), fraction }));
} else if c.is_alphabetic() {
while chars.get(i).is_some_and(|c| c.is_alphabetic()) {
i += 1;
}
out.push(Token::Word(chars[start..i].iter().collect()));
} else if c.is_whitespace() || is_mark(c) {
i += 1;
} else {
return Err(DurationError::Character(c));
}
}
Ok(out)
}
fn fold(word: &str) -> String {
word.chars()
.flat_map(|c| match c {
'İ' | 'I' | 'ı' => vec!['i'],
c => c.to_lowercase().collect(),
})
.collect()
}
fn unit_of(word: &str, i18n: &I18n) -> Option<DurationUnit> {
let word = fold(word);
let names = |unit: DurationUnit| format!("quvyta.duration.{}-words", unit.stem());
let matches = |list: &str| list.split(',').any(|candidate| fold(candidate.trim()) == word);
let active = DurationUnit::ALL.into_iter().find(|unit| matches(&i18n.translate(&names(*unit), &[])));
active.or_else(|| {
DurationUnit::ALL.into_iter().find(|unit| i18n.in_every_locale(&names(*unit)).iter().any(|list| matches(list)))
})
}
fn with_units(tokens: &[Token], i18n: &I18n) -> Result<u128, DurationError> {
if tokens.is_empty() {
return Err(DurationError::Empty);
}
let mut total = 0u128;
let mut seen = Vec::new();
let mut last = None;
let mut waiting: Option<&Number> = None;
let mut add = |number: &Number, unit: DurationUnit, seen: &mut Vec<DurationUnit>| {
if seen.contains(&unit) {
return Err(DurationError::RepeatedUnit(unit));
}
seen.push(unit);
total += number.seconds(unit);
Ok(())
};
for token in tokens {
match token {
Token::Number(number) => {
if let Some(previous) = waiting {
return Err(DurationError::MissingUnit(previous.text.clone()));
}
waiting = Some(number);
}
Token::Word(word) => {
let unit = unit_of(word, i18n).ok_or_else(|| DurationError::UnknownUnit(word.clone()))?;
let number = waiting.take().ok_or_else(|| DurationError::MissingNumber(word.clone()))?;
add(number, unit, &mut seen)?;
last = Some(unit);
}
}
}
if let Some(number) = waiting {
let unit = match last {
None => DurationUnit::Minutes,
Some(unit) => unit.below().ok_or_else(|| DurationError::MissingUnit(number.text.clone()))?,
};
add(number, unit, &mut seen)?;
}
Ok(total)
}
fn clock(text: &str) -> Result<u128, DurationError> {
let bad = || DurationError::BadClock(text.to_owned());
let parts: Vec<&str> = text.split(':').map(str::trim).collect();
if !(2..=3).contains(&parts.len())
|| parts.iter().any(|part| part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()))
{
return Err(bad());
}
if parts[0].trim_start_matches('0').len() > MAX_DIGITS {
return Err(DurationError::TooLarge);
}
let mut total: u128 = parts[0].parse::<u128>().map_err(|_| bad())? * 3600;
for (part, unit) in parts[1..].iter().zip([60u128, 1]) {
let value: u128 = part.parse().map_err(|_| bad())?;
if part.len() > 2 || value >= 60 {
return Err(bad());
}
total += value * unit;
}
Ok(total)
}