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#[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 #[arg(short, long)]
20 pub pay_rate: f64,
21
22 #[arg(short, long)]
24 pub gst: Option<f64>,
25
26 #[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 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 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}