use std::str::FromStr;
use crate::entities::{Error, Money, Result};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use strum_macros::Display;
#[derive(Debug, Display, Default, PartialEq, ValueEnum, Copy, Clone, Serialize, Deserialize)]
#[clap(rename_all = "kebab-case")]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum BillingPeriod {
#[default]
Daily,
Weekly,
Every2Weeks,
Monthly,
}
impl FromStr for BillingPeriod {
type Err = Error;
fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
match s {
"d" | "daily" => Ok(Self::Daily),
"w" | "weekly" => Ok(Self::Weekly),
"2" | "2weeks" => Ok(Self::Every2Weeks),
"m" | "monthly" => Ok(Self::Monthly),
_ => Err(Error::CouldNotParseBillingPeriod(s.to_string())),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) enum WorkPeriod {
Minutes(u32),
Hours(u32),
Days(u32),
Weeks(u32),
}
impl std::fmt::Display for WorkPeriod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (num, unit) = match self {
WorkPeriod::Minutes(m) => {
if *m > 1 {
(m.to_string(), " min")
} else {
(String::new(), "min")
}
}
WorkPeriod::Hours(h) => {
if *h > 1 {
(h.to_string(), " hrs")
} else {
(String::new(), "hour")
}
}
WorkPeriod::Days(d) => {
if *d > 1 {
(d.to_string(), " days")
} else {
(String::new(), "day")
}
}
WorkPeriod::Weeks(w) => {
if *w > 1 {
(w.to_string(), " weeks")
} else {
(String::new(), "week")
}
}
};
write!(f, "{num}{unit}")
}
}
fn parse_work_period(s: &str, num_work_periods: u32) -> std::result::Result<WorkPeriod, String> {
match s.to_lowercase().as_str() {
"min" | "minute" | "minutes" => Ok(WorkPeriod::Minutes(num_work_periods)),
"hour" | "hours" | "h" => Ok(WorkPeriod::Hours(num_work_periods)),
"day" | "days" | "d" => Ok(WorkPeriod::Days(num_work_periods)),
"week" | "weeks" | "w" => Ok(WorkPeriod::Weeks(num_work_periods)),
other => Err(format!("Unknown Period: '{other}'")),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Rate {
pub(crate) work_period: WorkPeriod,
pub(crate) amount: Money,
}
#[derive(Debug)]
struct Price<'a> {
currency: &'a str,
amount: f64,
duration: u32,
unit: &'a str,
}
fn parse_price(input: &str) -> Option<Price<'_>> {
let (left, right) = input.split_once('/')?;
if left.is_empty() || right.is_empty() {
return None;
}
let (currency, amount_str) = if let Some(first) = left.chars().next() {
if !first.is_ascii_alphanumeric() {
(&left[..first.len_utf8()], &left[first.len_utf8()..])
} else {
let idx = left.find(|c: char| c.is_ascii_alphabetic())?;
(&left[idx..], &left[..idx])
}
} else {
return None;
};
let amount = amount_str.parse().ok()?;
let idx = right
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(right.len());
let (duration, unit) = if idx == 0 {
(1, right)
} else {
(right[..idx].parse().ok()?, &right[idx..])
};
Some(Price {
currency,
amount,
duration,
unit,
})
}
impl FromStr for Rate {
type Err = Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
if let Some(price) = parse_price(s) {
let currency = price.currency.to_ascii_uppercase().parse()?;
let amount = Money::from_decimal(price.amount.try_into().unwrap(), currency);
let work_period = parse_work_period(price.unit, price.duration)
.map_err(|_| Error::CouldNotParseRate("Invalid parse period".into(), s.into()))?;
Ok(Rate {
work_period,
amount,
})
} else {
Err(Error::CouldNotParseRate("Invalid".into(), s.to_string()))
}
}
}
pub(crate) fn split_value_unit(s: &str) -> Result<(u32, &str)> {
let unit_start = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
let num_work_periods: u32 = if unit_start == 0 {
1
} else {
s[..unit_start]
.parse()
.map_err(|_| Error::CouldNotParseRate("Invalid period count".into(), s.into()))?
};
Ok((num_work_periods, &s[unit_start..]))
}
impl std::fmt::Display for Rate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.amount, self.work_period)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_price() {
let p = parse_price("$80/hour").unwrap();
assert_eq!(p.currency, "$");
assert_eq!(p.amount, 80.0);
assert_eq!(p.duration, 1);
assert_eq!(p.unit, "hour");
let p = parse_price("100EUR/10h").unwrap();
assert_eq!(p.currency, "EUR");
assert_eq!(p.amount, 100.0);
assert_eq!(p.duration, 10);
assert_eq!(p.unit, "h");
let p = parse_price("€50/day").unwrap();
assert_eq!(p.currency, "€");
assert_eq!(p.amount, 50.0);
assert_eq!(p.duration, 1);
assert_eq!(p.unit, "day");
let p = parse_price("12.5USD/30min").unwrap();
assert_eq!(p.currency, "USD");
assert_eq!(p.amount, 12.5);
assert_eq!(p.duration, 30);
assert_eq!(p.unit, "min");
}
#[test]
fn test_parse_price_invalid() {
assert!(parse_price("").is_none());
assert!(parse_price("80").is_none()); assert!(parse_price("$/hour").is_none()); assert!(parse_price("EUR/10h").is_none()); assert!(parse_price("80/10h").is_none()); assert!(parse_price("$80/").is_none()); }
}