use bigdecimal::{BigDecimal, FromPrimitive, ToPrimitive, Zero};
use chrono::{DateTime, Datelike, Duration, FixedOffset, Local, NaiveDate, Utc};
use lazy_static::lazy_static;
use regex::Regex;
use slug::slugify;
use std::collections::{BTreeSet, HashMap};
use std::error::Error;
use std::fmt;
use std::str::FromStr;
use wasm_bindgen::prelude::*;
const SCALE: i64 = 2;
#[wasm_bindgen]
pub fn costoflife_per_diem(s: &str) -> f32 {
match TxRecord::from_str(s) {
Ok(v) => v.per_diem().to_f32().unwrap(),
Err(_) => -1.0,
}
}
#[wasm_bindgen]
pub fn costoflife_greetings() -> f32 {
42.0
}
type Result<T> = std::result::Result<T, CostOfLifeError>;
#[derive(Debug, Clone)]
pub enum CostOfLifeError {
InvalidLifetimeFormat(String),
InvalidDateFormat(String),
InvalidAmount(String),
GenericError(String),
}
impl fmt::Display for CostOfLifeError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self)
}
}
impl From<chrono::ParseError> for CostOfLifeError {
fn from(error: chrono::ParseError) -> Self {
CostOfLifeError::InvalidDateFormat(error.to_string())
}
}
impl From<bigdecimal::ParseBigDecimalError> for CostOfLifeError {
fn from(error: bigdecimal::ParseBigDecimalError) -> Self {
CostOfLifeError::InvalidDateFormat(error.to_string())
}
}
impl Error for CostOfLifeError {}
lazy_static! {
static ref RE_CURRENCY: Regex = Regex::new(r"(\d+(\.\d{2})?)\p{Currency_Symbol}").unwrap();
static ref RE_HASHTAG: Regex = Regex::new(r"^[#\.]([a-zA-Z][0-9a-zA-Z_-]*)$").unwrap();
static ref RE_LIFETIME: Regex =
Regex::new(r"(([1-9]{1}[0-9]*)([dwmy]))(([1-9]{1}[0-9]*)x)?").unwrap();
static ref RE_DATE: Regex = Regex::new(r"([0-3][0-9][0-1][0-9][1-9][0-9])").unwrap();
}
fn extract_amount(input: &str) -> Option<&str> {
RE_CURRENCY
.captures(input)
.and_then(|c| c.get(1).map(|m| m.as_str()))
}
fn extract_hashtag(text: &str) -> Option<&str> {
RE_HASHTAG
.captures(text)
.and_then(|c| c.get(1).map(|m| m.as_str()))
}
fn extract_date(text: &str) -> Result<NaiveDate> {
let ds = RE_DATE
.captures(text)
.and_then(|c| c.get(1).map(|m| m.as_str()));
match ds {
Some(d) => date_from_str(d),
None => Ok(today()),
}
}
fn extract_lifetime(text: &str) -> (&str, i64, i64) {
match RE_LIFETIME.captures(text) {
Some(c) => (
c.get(3).map_or("d", |unit| unit.as_str()),
c.get(2).map_or(1, |a| a.as_str().parse::<i64>().unwrap()),
c.get(5).map_or(1, |r| r.as_str().parse::<i64>().unwrap()),
),
None => ("d", 1, 1),
}
}
#[derive(Debug, Copy, Clone)]
pub enum Lifetime {
SingleDay,
Year { amount: i64, times: i64 },
Month { amount: i64, times: i64 },
Week { amount: i64, times: i64 },
Day { amount: i64, times: i64 },
}
impl Lifetime {
pub fn get_days_since(&self, since: &NaiveDate) -> i64 {
match self {
Self::Month { amount, times } => {
let nm = since.month() + (times * amount) as u32;
let (y, m) = (since.year() as u32 + nm / 12, nm % 12);
let (y, m, d) = (y as i32, m, since.day());
let end = NaiveDate::from_ymd(y, m, d);
end.signed_duration_since(*since).num_days()
}
Self::Year { amount, times } => {
let ny = since.year() + (times * amount) as i32;
let end = NaiveDate::from_ymd(ny, since.month(), since.day());
end.signed_duration_since(*since).num_days()
}
Self::Week { amount, times } => amount * 7 * times,
Self::Day { amount, times } => amount * times,
Self::SingleDay => 1,
}
}
fn get_days_approx(&self) -> f64 {
match self {
Self::Year { amount, times } => 365.25 * f64::from_i64(amount * times).unwrap(),
Self::Month { amount, times } => 30.44 * f64::from_i64(amount * times).unwrap(),
Self::Week { amount, times } => 7.0 * f64::from_i64(amount * times).unwrap(),
Self::Day { amount, times } => f64::from_i64(amount * times).unwrap(),
Self::SingleDay => 1.0,
}
}
pub fn get_repeats(&self) -> i64 {
match self {
Self::Year { times, .. } => *times,
Self::Week { times, .. } => *times,
Self::Day { times, .. } => *times,
Self::Month { times, .. } => *times,
Self::SingleDay => 1,
}
}
}
impl FromStr for Lifetime {
type Err = CostOfLifeError;
fn from_str(s: &str) -> Result<Lifetime> {
let (period, amount, times) = extract_lifetime(s);
match period {
"w" => Ok(Lifetime::Week { amount, times }),
"y" => Ok(Lifetime::Year { amount, times }),
"m" => Ok(Lifetime::Month { amount, times }),
_ => Ok(Lifetime::Day { amount, times }),
}
}
}
impl PartialEq for Lifetime {
fn eq(&self, other: &Self) -> bool {
self.get_days_approx() == other.get_days_approx()
}
}
impl fmt::Display for Lifetime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Year { amount, times } => write!(f, "{}y{}x", amount, times),
Self::Month { amount, times } => write!(f, "{}m{}x", amount, times),
Self::Week { amount, times } => write!(f, "{}w{}x", amount, times),
Self::Day { amount, times } => write!(f, "{}d{}x", amount, times),
Self::SingleDay => write!(f, "1d1x"),
}
}
}
#[derive(Debug, Clone)]
pub struct TxRecord {
name: String,
tags: HashMap<String, String>,
amount: BigDecimal,
starts_on: NaiveDate,
lifetime: Lifetime,
recorded_at: DateTime<FixedOffset>,
src: Option<String>,
}
impl TxRecord {
pub fn get_name(&self) -> &str {
&self.name[..]
}
pub fn get_tags(&self) -> Vec<String> {
self.tags
.values()
.map(String::from)
.collect::<BTreeSet<String>>()
.into_iter()
.collect()
}
pub fn get_amount(&self) -> BigDecimal {
self.amount.with_scale(SCALE)
}
pub fn get_lifetime(&self) -> &Lifetime {
&self.lifetime
}
pub fn get_starts_on(&self) -> NaiveDate {
self.starts_on
}
pub fn get_recorded_at(&self) -> &DateTime<FixedOffset> {
&self.recorded_at
}
pub fn get_recorded_at_rfc3339(&self) -> String {
self.recorded_at.to_rfc3339()
}
pub fn amount_is_total(&self) -> bool {
self.lifetime.get_repeats() > 1
}
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.contains_key(&slugify(&tag))
}
pub fn get_amount_total(&self) -> BigDecimal {
BigDecimal::from_i64(self.lifetime.get_repeats()).unwrap() * &self.amount
}
pub fn get_duration_days(&self) -> i64 {
self.lifetime.get_days_since(&self.starts_on)
}
pub fn per_diem(&self) -> BigDecimal {
self.per_diem_raw().with_scale(SCALE)
}
pub fn per_diem_raw(&self) -> BigDecimal {
let duration_days = BigDecimal::from_i64(self.get_duration_days()).unwrap();
self.get_amount_total() / duration_days
}
pub fn get_progress(&self, d: &Option<NaiveDate>) -> f64 {
let d = match d {
Some(d) => *d,
None => today(),
};
let (start, end) = (self.starts_on, self.get_ends_on());
if d <= start {
return 0.0;
}
if d >= end {
return 1.0;
}
let n = (end - start).num_days() as f64;
let y = (d - start).num_days() as f64;
y / n
}
pub fn get_ends_on(&self) -> NaiveDate {
self.starts_on + Duration::days(self.lifetime.get_days_since(&self.starts_on) - 1)
}
pub fn is_active_on(&self, target: &NaiveDate) -> bool {
self.starts_on <= *target && *target <= self.get_ends_on()
}
pub fn to_string_record(&self) -> String {
match &self.src {
Some(s) => {
format!(
"{}::{}::{}\n",
self.get_recorded_at_rfc3339(),
self.get_starts_on(),
s
)
}
None => format!(
"{}::{}::{} {}€ {} {}\n",
self.get_recorded_at_rfc3339(),
self.get_starts_on(),
self.get_name(),
self.get_amount(),
self.get_lifetime(),
self.get_tags()
.iter()
.map(|t| format!("#{}", t))
.collect::<Vec<String>>()
.join(" ")
),
}
}
pub fn from_string_record(s: &str) -> Result<TxRecord> {
let abc = s.trim().splitn(3, "::").collect::<Vec<&str>>();
let mut tx = Self::from_str(abc[2])?;
tx.starts_on = NaiveDate::from_str(abc[1])?;
tx.recorded_at = DateTime::parse_from_rfc3339(abc[0])?;
Ok(tx)
}
pub fn new(name: &str, amount: &str) -> Result<TxRecord> {
TxRecord::from(
name,
Vec::new(),
amount,
today(),
Lifetime::SingleDay,
now_local(),
None,
)
}
pub fn from(
name: &str,
tags: Vec<&str>,
amount: &str,
starts_on: NaiveDate,
lifetime: Lifetime,
recorded_at: DateTime<FixedOffset>,
src: Option<&str>,
) -> Result<TxRecord> {
let tx = TxRecord {
name: String::from(name.trim()),
tags: tags
.iter()
.map(|v| (slugify(v), String::from(*v)))
.collect(),
amount: BigDecimal::from_str(amount)?,
lifetime,
recorded_at,
starts_on,
src: match src {
Some(s) => Some(String::from(s)),
_ => None,
},
};
if tx.get_amount() <= BigDecimal::zero() {
return Err(CostOfLifeError::InvalidAmount(
format! {"amount should be a positive number: {}", amount},
));
}
Ok(tx)
}
pub fn from_str(s: &str) -> Result<TxRecord> {
let mut name: Vec<&str> = Vec::new();
let mut amount = "0";
let mut lifetime = Lifetime::SingleDay;
let mut tags: Vec<&str> = Vec::new();
let mut starts_on = today();
for t in s.split_whitespace() {
if RE_CURRENCY.is_match(&t) {
if let Some(a) = extract_amount(t) {
amount = a
}
} else if RE_HASHTAG.is_match(&t) {
if let Some(x) = extract_hashtag(&t) {
tags.push(x);
}
} else if RE_LIFETIME.is_match(t) {
lifetime = t.parse::<Lifetime>()?;
} else if RE_DATE.is_match(t) {
starts_on = extract_date(t)?;
} else {
name.push(&t)
}
}
TxRecord::from(
&name.join(" "),
tags,
amount,
starts_on,
lifetime,
now_local(),
Some(s),
)
}
}
impl FromStr for TxRecord {
type Err = CostOfLifeError;
fn from_str(s: &str) -> Result<TxRecord> {
TxRecord::from_str(s)
}
}
impl fmt::Display for TxRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)
}
}
impl PartialEq for TxRecord {
fn eq(&self, other: &Self) -> bool {
self.name.eq(&other.name)
&& self.tags.eq(&other.tags)
&& self.amount.eq(&other.amount)
&& self.starts_on.eq(&other.starts_on)
&& self.lifetime.eq(&other.lifetime)
}
}
pub fn cost_of_life<'a, I>(txs: I, on: &NaiveDate) -> BigDecimal
where
I: Iterator<Item = &'a TxRecord>,
{
txs.filter(|tx| tx.is_active_on(on))
.map(|tx| tx.per_diem_raw())
.sum::<BigDecimal>()
.with_scale(SCALE)
}
pub fn parse_amount(v: &str) -> Result<BigDecimal> {
Ok(BigDecimal::from_str(v)?)
}
pub fn today() -> NaiveDate {
Utc::today().naive_utc()
}
pub fn now_local() -> DateTime<FixedOffset> {
DateTime::from(Local::now())
}
pub fn date(d: u32, m: u32, y: i32) -> NaiveDate {
NaiveDate::from_ymd(y, m, d)
}
pub fn date_from_str(s: &str) -> Result<NaiveDate> {
let formats = vec!["%d%m%y", "%d.%m.%y", "%d/%m/%y", "%d/%m/%Y", "%d.%m.%Y"];
for f in formats {
let r = NaiveDate::parse_from_str(s, f);
if !r.is_err() {
return Ok(r.unwrap());
}
}
Err(CostOfLifeError::InvalidDateFormat(format!(
"date not recognized: {}",
s
)))
}
#[cfg(test)]
pub mod wasm_tests {
use wasm_bindgen_test::*;
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
#[test]
#[wasm_bindgen_test]
fn test_greetings() {
assert_eq!(super::costoflife_greetings(), 42.0);
}
#[test]
#[wasm_bindgen_test]
fn test_per_diem() {
assert_eq!(super::costoflife_per_diem("20€ rent"), 20.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_tx() {
let tests = vec![
(
TxRecord::from_str("Something we bought 1000€ #nice #living 100d"),
(
Ok(()),
"Something we bought",
today(),
(today() + Duration::days(99)),
100,
vec![("nice", true), ("living", true), ("car", false)],
(today(), true),
parse_amount("10").unwrap(),
(Some(today()), 0.0 as f64),
),
),
(
"Something we bought 1000€ #nice #living 100d".parse::<TxRecord>(),
(
Ok(()),
"Something we bought",
today(),
(today() + Duration::days(99)),
100,
vec![("nice", true), ("living", true), ("car", false)],
(today(), true),
parse_amount("10").unwrap(),
(Some(today()), 0.0 as f64),
),
),
(
TxRecord::from_str("we bought nothing #nice #living 100d"),
(
Err(()),
"Something we bought",
today(),
(today() + Duration::days(99)),
100,
vec![("nice", true), ("living", true), ("car", false)],
(today(), true),
parse_amount("10").unwrap(),
(Some(today()), 0.0 as f64),
),
),
(
TxRecord::from_str("Rent 1729€ 1m12x 010118 #rent"),
(
Ok(()),
"Rent",
date(1, 1, 2018),
(date(31, 12, 2018)),
365,
vec![("home", false), ("rent", true)],
(today(), false),
parse_amount("56.84").unwrap(),
(None, 1.0 as f64),
),
),
(
TxRecord::from_str("Rent#2018 1729€ 1m12x 320118 #rent"),
(
Err(()),
"Rent#2018",
date(1, 1, 2018),
(date(31, 12, 2018)),
365,
vec![("home", false), ("rent", true), ("#2018", false)],
(today(), false),
parse_amount("58.84").unwrap(),
(None, 1.0 as f64),
),
),
(
TxRecord::from_str("Mobile internet 9.99€ 210421 1w4x #internet"),
(
Ok(()),
"Mobile internet",
date(21, 4, 2021),
(date(18, 5, 2021)),
28,
vec![("internet", true)],
(date(12, 5, 2021), true),
parse_amount("1.42").unwrap(),
(Some(date(5, 5, 2021)), 0.5185185185185185 as f64),
),
),
(
TxRecord::from(
"Car",
vec!["transportation", "lifestyle"],
"100000",
date(01, 01, 2010),
Lifetime::Year {
amount: 20,
times: 1,
},
now_local(),
None,
),
(
Ok(()),
"Car",
date(01, 01, 2010),
(date(31, 12, 2029)),
7305,
vec![
("nice", false),
("living", false),
("car", false),
("transportation", true),
("lifestyle", true),
],
(date(01, 01, 2030), false),
parse_amount("13.68").unwrap(),
(Some(date(01, 10, 2020)), 0.537513691128149 as f64),
),
),
(
TxRecord::new("Building", "1000000"),
(
Ok(()),
"Building",
today(),
today(),
1,
vec![
("nice", false),
("living", false),
("car", false),
("transportation", false),
("lifestyle", false),
],
(today(), true),
parse_amount("1000000").unwrap(),
(None, 0.0 as f64),
),
),
(
TxRecord::new("Building", "not a number"),
(
Err(()),
"Building",
today(),
today(),
1,
vec![
("nice", false),
("living", false),
("car", false),
("transportation", false),
("lifestyle", false),
],
(today(), true),
parse_amount("1000000").unwrap(),
(None, 0.0 as f64),
),
),
];
for (i, t) in tests.iter().enumerate() {
println!("test_getters#{}", i);
let (res, expected) = t;
let (result, name, starts_on, ends_on, duration, tags, status, per_diem, progress_test) =
expected;
assert_eq!(res.is_err(), result.is_err());
if res.is_err() {
continue;
}
let got = res.as_ref().unwrap();
assert_eq!(got.get_name(), *name);
assert_eq!(got.get_name(), got.to_string());
assert_eq!(got.get_starts_on(), *starts_on);
assert_eq!(got.get_ends_on(), *ends_on);
assert_eq!(got.get_duration_days(), *duration);
tags.iter()
.for_each(|(tag, exists)| assert_eq!(got.has_tag(tag), *exists));
let (target_date, is_active) = status;
assert_eq!(got.is_active_on(&target_date), *is_active);
assert_eq!(got.per_diem(), *per_diem);
let (on_date, progress) = progress_test;
assert_eq!(got.get_progress(on_date), *progress);
let txs = got.to_string_record();
let txr = TxRecord::from_string_record(&txs).unwrap();
assert_eq!(*got, txr);
}
}
#[test]
fn test_lifetime() {
let tests = vec![
(
("1d1x", today(), 1, "1d1x"),
Lifetime::Day {
amount: 1,
times: 1,
},
),
(
("10d1x", today(), 10, "10d1x"),
Lifetime::Day {
amount: 10,
times: 1,
},
),
(
("10d10x", today(), 100, "10d10x"),
Lifetime::Day {
amount: 10,
times: 10,
},
),
(
("1w", today(), 7, "1w1x"),
Lifetime::Week {
amount: 1,
times: 1,
},
),
(
("7w", today(), 49, "7w1x"),
Lifetime::Week {
amount: 7,
times: 1,
},
),
(
("10w10x", today(), 700, "10w10x"),
Lifetime::Week {
amount: 10,
times: 10,
},
),
(
("20y", date(1, 1, 2020), 7305, "20y1x"),
Lifetime::Year {
amount: 20,
times: 1,
},
),
(
("1y20x", date(1, 1, 2020), 7305, "1y20x"),
Lifetime::Year {
amount: 1,
times: 20,
},
),
(
("20y", date(1, 1, 2021), 7305, "20y1x"),
Lifetime::Year {
amount: 20,
times: 1,
},
),
(
("1y", date(1, 1, 2020), 366, "1y1x"),
Lifetime::Year {
amount: 1,
times: 1,
},
),
(
("1y", date(1, 1, 2021), 365, "1y1x"),
Lifetime::Year {
amount: 1,
times: 1,
},
),
(
("1m", date(1, 1, 2021), 31, "1m1x"),
Lifetime::Month {
amount: 1,
times: 1,
},
),
(
("12m", date(1, 1, 2021), 365, "12m1x"),
Lifetime::Month {
amount: 12,
times: 1,
},
),
(
("1m12x", date(1, 1, 2021), 365, "1m12x"),
Lifetime::Month {
amount: 1,
times: 12,
},
),
(
("", today(), 1, "1d1x"),
Lifetime::Day {
amount: 1,
times: 1,
},
),
];
for (i, t) in tests.iter().enumerate() {
println!("test_parse_lifetime#{}", i);
let (lifetime_spec, lifetime_exp) = t;
let (input_str, start_date, duration_days, to_str) = lifetime_spec;
assert_eq!(
input_str
.parse::<Lifetime>()
.expect("test_parse_lifetime error"),
*lifetime_exp,
);
assert_eq!(lifetime_exp.get_days_since(start_date), *duration_days);
assert_eq!(lifetime_exp.to_string(), *to_str);
}
}
#[test]
fn test_parsers() {
let r = date_from_str("27/12/2020");
assert_eq!(r.unwrap(), date(27, 12, 2020));
let r = date_from_str("30/02/2020");
assert_eq!(r.is_err(), true);
let r = date_from_str("30/02/20");
assert_eq!(r.is_err(), true);
let r = date_from_str("30.01.20");
assert_eq!(r.unwrap(), date(30, 1, 2020));
let r = date_from_str("30/01/20");
assert_eq!(r.unwrap(), date(30, 1, 2020));
let r = date_from_str("30/01/2020");
assert_eq!(r.unwrap(), date(30, 1, 2020));
let r = extract_date("invalid date");
assert_eq!(r.unwrap(), today());
}
#[test]
fn test_costoflife() {
let txs = vec![
TxRecord::new("Test#1", "10.2311321").unwrap(),
TxRecord::new("Test#2", "10.5441231").unwrap(),
TxRecord::new("Test#3", "70.199231321").unwrap(),
];
assert_eq!(
cost_of_life(txs.iter(), &today()),
parse_amount("90.97").unwrap()
);
}
}