pint_rs/
lib.rs

1use anyhow::{Context, Result};
2use chrono::Duration;
3use clap::Parser;
4use csv::Reader;
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8#[cfg(test)]
9mod tests;
10
11/// Generates an invoice from a CSV file
12#[derive(Parser, Debug)]
13#[command(author, version, about)]
14#[command(
15    help_template = "{about-section}\nAuthor: {author-with-newline}Version: {version}\n\n{usage-heading}\n{usage}\n\n{all-args}"
16)]
17pub struct Args {
18    /// The pay rate for the invoice
19    #[arg(short, long)]
20    pub pay_rate: f64,
21
22    /// The GST percentage for the invoice
23    #[arg(short, long)]
24    pub gst: Option<f64>,
25
26    /// The CSV file to read from
27    #[arg(short, long, value_name = "FILE")]
28    pub file: PathBuf,
29}
30
31fn round_to_hundredth(num: f64) -> f64 {
32    (num * 100.0).round() / 100.0
33}
34
35#[derive(Debug, PartialEq)]
36pub struct InvoiceBuilder {
37    project_hours_logged: HashMap<String, f64>,
38    pay_rate: f64,
39    gst_rate: f64,
40}
41
42#[derive(Debug, PartialEq)]
43pub struct Invoice {
44    project_hours_logged: HashMap<String, f64>,
45    total_time: f64,
46    subtotal: f64,
47    gst: f64,
48    total: f64,
49
50    gst_rate: f64,
51    pay_rate: f64,
52}
53
54impl InvoiceBuilder {
55    pub fn new(args: &Args) -> Self {
56        Self {
57            project_hours_logged: HashMap::new(),
58            pay_rate: args.pay_rate,
59            gst_rate: args.gst.unwrap_or(0.0),
60        }
61    }
62
63    pub fn build(&self) -> Invoice {
64        let total_time = round_to_hundredth(self.project_hours_logged.iter().map(|(_, t)| t).sum());
65
66        let subtotal = round_to_hundredth(total_time * self.pay_rate);
67        let gst = round_to_hundredth(subtotal * self.gst_rate);
68
69        let total = subtotal + gst;
70
71        Invoice {
72            project_hours_logged: self.project_hours_logged.clone(),
73            total_time,
74            subtotal,
75            gst,
76            total,
77
78            gst_rate: self.gst_rate,
79            pay_rate: self.pay_rate,
80        }
81    }
82
83    pub fn add_project_duration(&mut self, project: &str, duration: &Duration) -> &mut Self {
84        if let Some(time) = self.project_hours_logged.get_mut(project) {
85            *time += round_to_hundredth(duration.num_seconds() as f64 / 3600.0)
86        } else {
87            self.project_hours_logged.insert(
88                project.to_owned(),
89                round_to_hundredth(duration.num_seconds() as f64 / 3600.0),
90            );
91        }
92
93        self
94    }
95
96    pub fn collect_time_entries(&mut self, entries: &[(String, Duration)]) -> &mut Self {
97        for (project, duration) in entries {
98            self.add_project_duration(&project, duration);
99        }
100
101        self
102    }
103
104    pub fn import_csv(&mut self, file: &PathBuf) -> Result<&mut Self> {
105        let contents = std::fs::read(file)
106            .with_context(|| format!("Unable to read from given file \"{:?}\"", file))?;
107
108        let mut reader = csv::ReaderBuilder::new()
109            .has_headers(true)
110            .from_reader(contents.as_slice());
111
112        let entries =
113            Self::parse_csv_entries(&mut reader).context("Unable to parse CSV entries")?;
114        self.collect_time_entries(&entries);
115
116        Ok(self)
117    }
118
119    fn parse_duration_str(str: &str) -> Result<Duration> {
120        let time_parts: Vec<&str> = str.split(':').collect();
121
122        let hours: i64 = time_parts[0]
123            .parse()
124            .with_context(|| format!("Unable to parse hour string \"{}\"", time_parts[0]))?;
125        let minutes: i64 = time_parts[1]
126            .parse()
127            .with_context(|| format!("Unable to parse minutes string \"{}\"", time_parts[1]))?;
128        let seconds: i64 = time_parts[2]
129            .parse()
130            .with_context(|| format!("Unable to parse seconds string \"{}\"", time_parts[2]))?;
131
132        let duration =
133            Duration::hours(hours) + Duration::minutes(minutes) + Duration::seconds(seconds);
134
135        Ok(duration)
136    }
137
138    fn parse_csv_entries(reader: &mut Reader<&[u8]>) -> Result<Vec<(String, Duration)>> {
139        let entries: Vec<(String, Duration)> = reader
140            .records()
141            .filter_map(|r| r.ok())
142            .flat_map(|r| {
143                Ok::<(String, Duration), anyhow::Error>((
144                    r[0].to_owned(),
145                    Self::parse_duration_str(&r[3])
146                        .with_context(|| format!("Unable to parse duration \"{}\"", &r[3]))?,
147                ))
148            })
149            .collect();
150
151        Ok(entries)
152    }
153}
154
155impl std::fmt::Display for Invoice {
156    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
157        let mut output = String::new();
158
159        // Format the time entries
160        output.push_str(&format!("{:<30} {:>10}\n", "Project", "Hours"));
161        output.push_str(&format!("{:-<41}\n", ""));
162        for (project, hours) in &self.project_hours_logged {
163            output.push_str(&format!("{:<30} {:>10.2}\n", project, hours));
164        }
165
166        // Format the totals
167        output.push_str(&format!(
168            "\n{:<30} {:>10.2}\n\n",
169            "Total Time (h)", self.total_time
170        ));
171        output.push_str(&format!(
172            "{:<30} {:>10.2}\n",
173            &format!("Subtotal at ${}/hr", self.pay_rate),
174            self.subtotal
175        ));
176        output.push_str(&format!(
177            "{:<30} {:>10.2}\n",
178            &format!("GST at {}%", self.gst_rate * 100.0),
179            self.gst
180        ));
181        output.push_str(&format!("{:<30} {:>10.2}\n", "TOTAL", self.total));
182
183        write!(f, "{}", output)
184    }
185}