use anyhow::{bail, Context, Result};
use regex::Regex;
use serde::Deserialize;
use std::{
collections::BTreeMap,
fmt::{Debug, Display},
fs::File,
io::{BufRead, BufReader},
path::Path,
};
use crate::{units::Units, usd::Usd};
#[derive(Debug)]
struct Group {
name: String,
regex: Regex,
}
#[derive(Debug, Default)]
pub struct Report {
groups: Vec<Group>,
products: BTreeMap<String, Product>,
units: Units,
revenue: Usd,
pub sort_by_revenue: bool,
}
impl Report {
#[must_use]
pub fn new() -> Report {
Self::default()
}
pub fn read_groups(&mut self, path: impl AsRef<Path>) -> Result<()> {
let file = BufReader::new(File::open(&path)?);
for line in file.lines() {
let line = line?;
let Some((name, regex_str)) = line.split_once(" | ") else {
bail!(
"reading {:?}: bad line format (missing |): {line}",
path.as_ref(),
);
};
self.add_group(name, regex_str)?;
}
Ok(())
}
#[must_use]
pub fn product_group(&self, line_item: &str) -> Option<String> {
self.groups
.iter()
.find(|g| g.regex.is_match(line_item))
.map(|g| g.name.clone())
}
pub fn add_group(&mut self, name: &str, regex_str: &str) -> Result<()> {
self.groups.push(Group {
name: name.to_string(),
regex: Regex::new(regex_str)?,
});
Ok(())
}
pub fn read_csv(&mut self, path: impl AsRef<Path>) -> Result<()> {
let mut csv_data = csv::Reader::from_path(&path)?;
for result in csv_data.deserialize() {
let record: Record = result.with_context(|| format!("{}", path.as_ref().display()))?;
let display_name = self.product_group(&record.name).unwrap_or(record.name);
let prod = self.products.entry(display_name.clone()).or_default();
let units = record.qty;
prod.units = prod.units.strict_add(units);
self.units = self.units.strict_add(units);
let revenue = record.price.strict_mul(record.qty);
prod.revenue = prod.revenue.strict_add(revenue);
self.revenue = self.revenue.strict_add(revenue);
}
Ok(())
}
#[must_use]
pub fn products_by_unit_sales(&self) -> Vec<&str> {
let mut products: Vec<_> = self.products.keys().map(String::as_ref).collect();
products.sort_by(|a, b| {
let prod_a = self
.products
.get(*a)
.expect("product removed from map during sort");
let prod_b = self
.products
.get(*b)
.expect("product removed from map during sort");
prod_b.units.cmp(&prod_a.units)
});
products
}
#[must_use]
pub fn products_by_revenue(&self) -> Vec<&str> {
let mut products: Vec<_> = self.products.keys().map(String::as_ref).collect();
products.sort_by(|a, b| {
let prod_a = self
.products
.get(*a)
.expect("product removed from map during sort");
let prod_b = self
.products
.get(*b)
.expect("product removed from map during sort");
prod_b.revenue.cmp(&prod_a.revenue)
});
products
}
}
impl Display for Report {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let width = self
.products
.keys()
.map(String::len)
.max()
.unwrap_or_default();
writeln!(
f,
"{:width$} {:>6} {:>12}",
"Product / Group", "Units", "Revenue"
)?;
let length = width.saturating_add(20);
writeln!(f, "{:-<length$}", "")?;
let rows = if self.sort_by_revenue {
self.products_by_revenue()
} else {
self.products_by_unit_sales()
};
for name in rows {
let prod = self
.products
.get(name)
.expect("self.products can't be empty");
writeln!(f, "{name:width$} {:6} {:>12}", prod.units, prod.revenue)?;
}
writeln!(f, "{:-<length$}", "")?;
writeln!(f, "{:width$} {:6} {}", "Total", self.units, self.revenue)?;
Ok(())
}
}
#[derive(Debug, Default)]
pub struct Product {
pub units: Units,
pub revenue: Usd,
}
#[derive(Debug, Deserialize)]
pub struct Record {
#[serde(rename = "Lineitem quantity", alias = "Quantity")]
pub qty: Units,
#[serde(rename = "Lineitem name", alias = "Item Name")]
pub name: String,
#[serde(rename = "Lineitem price", alias = "Item Price ($)")]
pub price: Usd,
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
#[test]
fn read_groups_fn_correctly_parses_group_data() {
let mut report = Report::new();
report.read_groups("testdata/groups").expect("parse error");
assert_eq!(
report
.product_group("The Power of Go: Tests (Go 1.22 edition)")
.expect("group not found"),
"The Power of Go"
);
assert_eq!(
report
.product_group("For the Love of Go (Go 1.23 edition)")
.expect("group not found"),
"For the Love of Go"
);
assert_eq!(report.product_group("bogus product"), None);
}
#[test]
fn read_groups_fn_returns_error_for_bad_line_format() {
let mut report = Report::new();
assert!(report.read_groups("testdata/groups.bad").is_err());
}
#[test]
fn add_group_fn_adds_group_to_report() {
let mut report = Report::new();
report
.add_group("Foo", "foo")
.expect("regex should be valid");
assert_eq!(report.product_group("foo variant 1"), Some("Foo".into()));
}
#[test]
fn read_csv_fn_correctly_parses_squarespace_data() {
let mut report = Report::new();
report
.read_csv("testdata/squarespace.csv")
.expect("parse error");
assert_eq!(report.units, Units(17), "wrong units");
assert_eq!(
report.revenue,
Usd::from_str("3,409.15").expect("expectation should be valid Usd")
);
}
#[test]
fn read_csv_fn_correctly_parses_gumroad_data() {
let mut report = Report::new();
report
.read_csv("testdata/gumroad.csv")
.expect("parse error");
assert_eq!(report.units, Units(7), "wrong units");
assert_eq!(report.revenue, Usd::default());
}
#[test]
fn products_by_unit_sales_fn_sorts_prods_by_units() {
let mut report = Report::new();
report
.read_csv("testdata/squarespace.csv")
.expect("parse error");
assert_eq!(
report.products_by_revenue(),
vec![
"Go mentoring",
"Code For Your Life",
"For the Love of Go: Video/Book Bundle (2023 edition)",
"For the Love of Go (2023)",
"The Power of Go: Tests",
"The Power of Go: Tools",
]
);
}
#[test]
fn products_by_revenue_fn_sorts_prods_by_revenue() {
let mut report = Report::new();
report
.read_csv("testdata/squarespace.csv")
.expect("parse error");
assert_eq!(
report.products_by_unit_sales(),
vec![
"Go mentoring",
"Code For Your Life",
"For the Love of Go (2023)",
"For the Love of Go: Video/Book Bundle (2023 edition)",
"The Power of Go: Tests",
"The Power of Go: Tools",
]
);
}
}