jobber 1.1.2-alpha

Minimalistic console work time tracker
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() {
            // z.B. "$80"
            (&left[..first.len_utf8()], &left[first.len_utf8()..])
        } else {
            // z.B. "100EUR"
            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)> {
    // Period: optional number + unit ("15min", "hour")
    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()); // kein '/'
        assert!(parse_price("$/hour").is_none()); // Betrag fehlt
        assert!(parse_price("EUR/10h").is_none()); // Betrag fehlt
        assert!(parse_price("80/10h").is_none()); // Währung fehlt
        assert!(parse_price("$80/").is_none()); // Zeiteinheit fehlt
    }
}