1use std::collections::HashSet;
4
5use serde::Serialize;
6
7use crate::duration::format_duration_human;
8use crate::models::entry::TimeEntry;
9use crate::models::project::Project;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum GroupBy {
14 Project,
16 Tag,
18}
19
20impl GroupBy {
21 pub fn from_str_value(s: &str) -> Result<Self, String> {
23 match s.to_lowercase().as_str() {
24 "project" => Ok(Self::Project),
25 "tag" => Ok(Self::Tag),
26 _ => Err(format!("unknown group-by: '{s}' (use 'project' or 'tag')")),
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ReportFormat {
34 Table,
36 Markdown,
38 Csv,
40 Json,
42}
43
44impl ReportFormat {
45 pub fn from_str_value(s: &str) -> Result<Self, String> {
47 match s.to_lowercase().as_str() {
48 "table" => Ok(Self::Table),
49 "markdown" | "md" => Ok(Self::Markdown),
50 "csv" => Ok(Self::Csv),
51 "json" => Ok(Self::Json),
52 _ => Err(format!(
53 "unknown format: '{s}' (use 'table', 'markdown', 'csv', or 'json')"
54 )),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize)]
61pub struct ReportRow {
62 pub group: String,
64 pub total_secs: i64,
66 pub entry_count: usize,
68 pub earnings_cents: Option<i64>,
70}
71
72struct GroupAccum {
74 total_secs: i64,
75 entry_count: usize,
76 earnings_cents: Option<i64>,
78}
79
80impl GroupAccum {
81 fn new() -> Self {
83 Self {
84 total_secs: 0,
85 entry_count: 0,
86 earnings_cents: Some(0),
87 }
88 }
89
90 fn add(&mut self, duration: i64, hourly_rate_cents: Option<i64>) {
92 self.total_secs += duration;
93 self.entry_count += 1;
94 match (self.earnings_cents, hourly_rate_cents) {
95 (Some(acc), Some(rate)) => {
96 self.earnings_cents = Some(acc + duration * rate / 3600);
97 }
98 _ => {
99 self.earnings_cents = None;
101 }
102 }
103 }
104
105 fn into_row(self, group: String) -> ReportRow {
107 let earnings = match self.earnings_cents {
109 Some(0) if self.entry_count > 0 => None,
110 other => other,
111 };
112 ReportRow {
113 group,
114 total_secs: self.total_secs,
115 entry_count: self.entry_count,
116 earnings_cents: earnings,
117 }
118 }
119}
120
121pub struct ReportResult {
123 pub rows: Vec<ReportRow>,
125 pub unique_total_secs: i64,
127 pub unique_entry_count: usize,
129}
130
131pub fn generate_report(entries: &[(TimeEntry, Project)], group_by: &GroupBy) -> ReportResult {
133 let mut groups: std::collections::BTreeMap<String, GroupAccum> =
134 std::collections::BTreeMap::new();
135
136 let mut seen_ids: HashSet<String> = HashSet::new();
138 let mut unique_total_secs: i64 = 0;
139
140 for (entry, project) in entries {
141 let duration = entry.computed_duration_secs().unwrap_or(0);
142
143 let entry_id = entry.id.as_str().to_owned();
145 if seen_ids.insert(entry_id) {
146 unique_total_secs += duration;
147 }
148
149 match group_by {
150 GroupBy::Project => {
151 groups
152 .entry(project.name.clone())
153 .or_insert_with(GroupAccum::new)
154 .add(duration, project.hourly_rate_cents);
155 }
156 GroupBy::Tag => {
157 if entry.tags.is_empty() {
158 groups
159 .entry("(untagged)".to_string())
160 .or_insert_with(GroupAccum::new)
161 .add(duration, project.hourly_rate_cents);
162 } else {
163 for tag in &entry.tags {
164 groups
165 .entry(tag.clone())
166 .or_insert_with(GroupAccum::new)
167 .add(duration, project.hourly_rate_cents);
168 }
169 }
170 }
171 }
172 }
173
174 let rows: Vec<ReportRow> = groups
175 .into_iter()
176 .map(|(group, accum)| accum.into_row(group))
177 .collect();
178
179 ReportResult {
180 rows,
181 unique_total_secs,
182 unique_entry_count: seen_ids.len(),
183 }
184}
185
186pub fn format_report(result: &ReportResult, format: &ReportFormat) -> String {
188 match format {
189 ReportFormat::Table => format_table(result),
190 ReportFormat::Markdown => format_markdown(result),
191 ReportFormat::Csv => format_csv(&result.rows),
192 ReportFormat::Json => format_json(&result.rows),
193 }
194}
195
196fn escape_csv(field: &str) -> String {
198 if field.contains(',') || field.contains('"') || field.contains('\n') {
199 format!("\"{}\"", field.replace('"', "\"\""))
200 } else {
201 field.to_string()
202 }
203}
204
205fn escape_markdown(field: &str) -> String {
207 field.replace('|', "\\|").replace('\n', " ")
208}
209
210fn format_table(result: &ReportResult) -> String {
212 if result.rows.is_empty() {
213 return "No entries found.\n".to_string();
214 }
215
216 let formatted: Vec<(String, String, String, String)> = result
218 .rows
219 .iter()
220 .map(|row| {
221 let earnings = match row.earnings_cents {
222 Some(c) => format!("${}.{:02}", c / 100, c % 100),
223 None => "\u{2014}".to_string(),
224 };
225 (
226 row.group.clone(),
227 format_duration_human(row.total_secs),
228 row.entry_count.to_string(),
229 earnings,
230 )
231 })
232 .collect();
233
234 let total_time = format_duration_human(result.unique_total_secs);
235 let total_entries = result.unique_entry_count.to_string();
236
237 let gw = formatted
239 .iter()
240 .map(|(g, _, _, _)| g.len())
241 .chain(std::iter::once("GROUP".len()))
242 .chain(std::iter::once("Total".len()))
243 .max()
244 .unwrap_or(5);
245 let tw = formatted
246 .iter()
247 .map(|(_, t, _, _)| t.len())
248 .chain(std::iter::once("TIME".len()))
249 .chain(std::iter::once(total_time.len()))
250 .max()
251 .unwrap_or(4);
252 let ew = formatted
253 .iter()
254 .map(|(_, _, e, _)| e.len())
255 .chain(std::iter::once("ENTRIES".len()))
256 .chain(std::iter::once(total_entries.len()))
257 .max()
258 .unwrap_or(7);
259 let rw = formatted
260 .iter()
261 .map(|(_, _, _, r)| r.len())
262 .chain(std::iter::once("EARNINGS".len()))
263 .max()
264 .unwrap_or(8);
265
266 let mut out = String::new();
267
268 out.push_str(&format!(
270 " {:<gw$} {:>tw$} {:>ew$} {:>rw$}\n",
271 "GROUP", "TIME", "ENTRIES", "EARNINGS",
272 ));
273
274 for (group, time, entries, earnings) in &formatted {
276 out.push_str(&format!(
277 " {:<gw$} {:>tw$} {:>ew$} {:>rw$}\n",
278 group, time, entries, earnings,
279 ));
280 }
281
282 out.push_str(&format!(
284 " {:<gw$} {:>tw$} {:>ew$} {:>rw$}\n",
285 "Total", total_time, total_entries, "",
286 ));
287
288 out
289}
290
291fn format_markdown(result: &ReportResult) -> String {
293 let mut out = String::new();
294 out.push_str("| Group | Time | Entries | Earnings |\n");
295 out.push_str("|-------|------|---------|----------|\n");
296
297 for row in &result.rows {
298 let earnings = match row.earnings_cents {
299 Some(c) => format!("${}.{:02}", c / 100, c % 100),
300 None => "\u{2014}".to_string(),
301 };
302 out.push_str(&format!(
303 "| {} | {} | {} | {} |\n",
304 escape_markdown(&row.group),
305 format_duration_human(row.total_secs),
306 row.entry_count,
307 earnings,
308 ));
309 }
310
311 out.push_str(&format!(
312 "| **Total** | **{}** | **{}** | |\n",
313 format_duration_human(result.unique_total_secs),
314 result.unique_entry_count,
315 ));
316
317 out
318}
319
320fn format_csv(rows: &[ReportRow]) -> String {
322 let mut out = String::from("group,time_secs,time_human,entries,earnings_cents\n");
323 for row in rows {
324 let earnings = row
325 .earnings_cents
326 .map(|c| c.to_string())
327 .unwrap_or_default();
328 out.push_str(&format!(
329 "{},{},{},{},{}\n",
330 escape_csv(&row.group),
331 row.total_secs,
332 format_duration_human(row.total_secs),
333 row.entry_count,
334 earnings,
335 ));
336 }
337 out
338}
339
340fn format_json(rows: &[ReportRow]) -> String {
342 serde_json::to_string_pretty(rows).unwrap_or_else(|_| "[]".to_string())
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::models::entry::{EntrySource, TimeEntry};
349 use crate::models::project::{Project, ProjectSource, ProjectStatus};
350 use crate::models::types::{EntryId, ProjectId};
351 use std::path::PathBuf;
352 use time::OffsetDateTime;
353
354 fn make_entry(project: &Project, duration: i64, tags: Vec<&str>) -> (TimeEntry, Project) {
355 let now = OffsetDateTime::now_utc();
356 let entry = TimeEntry {
357 id: EntryId::new(),
358 project_id: project.id.clone(),
359 session_id: None,
360 start: now,
361 end: Some(now + time::Duration::seconds(duration)),
362 duration_secs: Some(duration),
363 source: EntrySource::Manual,
364 notes: None,
365 tags: tags.into_iter().map(String::from).collect(),
366 created_at: now,
367 updated_at: now,
368 };
369 (entry, project.clone())
370 }
371
372 fn make_project(name: &str, rate: Option<i64>) -> Project {
373 let now = OffsetDateTime::now_utc();
374 Project {
375 id: ProjectId::new(),
376 name: name.to_string(),
377 paths: vec![PathBuf::from(format!("/home/user/{name}"))],
378 tags: vec![],
379 hourly_rate_cents: rate,
380 status: ProjectStatus::Active,
381 source: ProjectSource::Manual,
382 created_at: now,
383 updated_at: now,
384 }
385 }
386
387 #[test]
388 fn group_by_project() {
389 let p1 = make_project("app-1", Some(15000));
390 let p2 = make_project("app-2", None);
391
392 let entries = vec![
393 make_entry(&p1, 3600, vec![]),
394 make_entry(&p1, 1800, vec![]),
395 make_entry(&p2, 7200, vec![]),
396 ];
397
398 let result = generate_report(&entries, &GroupBy::Project);
399 assert_eq!(result.rows.len(), 2);
400 assert_eq!(result.rows[0].group, "app-1");
401 assert_eq!(result.rows[0].total_secs, 5400);
402 assert_eq!(result.rows[0].entry_count, 2);
403 assert_eq!(result.rows[0].earnings_cents, Some(22500)); assert_eq!(result.rows[1].group, "app-2");
405 assert_eq!(result.rows[1].earnings_cents, None);
406 }
407
408 #[test]
409 fn group_by_tag() {
410 let p = make_project("app", None);
411 let entries = vec![
412 make_entry(&p, 3600, vec!["frontend", "client"]),
413 make_entry(&p, 1800, vec!["frontend"]),
414 make_entry(&p, 900, vec![]),
415 ];
416
417 let result = generate_report(&entries, &GroupBy::Tag);
418 assert_eq!(result.rows.len(), 3); let untagged = result
421 .rows
422 .iter()
423 .find(|r| r.group == "(untagged)")
424 .unwrap();
425 assert_eq!(untagged.total_secs, 900);
426
427 let frontend = result.rows.iter().find(|r| r.group == "frontend").unwrap();
428 assert_eq!(frontend.total_secs, 5400); let client = result.rows.iter().find(|r| r.group == "client").unwrap();
431 assert_eq!(client.total_secs, 3600);
432 }
433
434 #[test]
435 fn tag_earnings_from_project_rate() {
436 let p = make_project("app", Some(10000)); let entries = vec![
438 make_entry(&p, 3600, vec!["frontend"]), make_entry(&p, 1800, vec!["frontend"]), ];
441
442 let result = generate_report(&entries, &GroupBy::Tag);
443 let frontend = result.rows.iter().find(|r| r.group == "frontend").unwrap();
444 assert_eq!(frontend.earnings_cents, Some(15000)); }
446
447 #[test]
448 fn deduplicated_totals_for_tags() {
449 let p = make_project("app", None);
450 let entries = vec![make_entry(&p, 3600, vec!["frontend", "client"])];
452
453 let result = generate_report(&entries, &GroupBy::Tag);
454 assert_eq!(result.rows.len(), 2); assert_eq!(result.unique_total_secs, 3600); assert_eq!(result.unique_entry_count, 1); }
458
459 #[test]
460 fn format_csv_output() {
461 let result = ReportResult {
462 rows: vec![ReportRow {
463 group: "app".to_string(),
464 total_secs: 5400,
465 entry_count: 2,
466 earnings_cents: Some(22500),
467 }],
468 unique_total_secs: 5400,
469 unique_entry_count: 2,
470 };
471 let csv = format_report(&result, &ReportFormat::Csv);
472 assert!(csv.contains("group,time_secs,time_human,entries,earnings_cents"));
473 assert!(csv.contains("app,5400,1h 30m,2,22500"));
474 }
475
476 #[test]
477 fn format_csv_escapes_commas() {
478 let result = ReportResult {
479 rows: vec![ReportRow {
480 group: "my,app".to_string(),
481 total_secs: 3600,
482 entry_count: 1,
483 earnings_cents: None,
484 }],
485 unique_total_secs: 3600,
486 unique_entry_count: 1,
487 };
488 let csv = format_report(&result, &ReportFormat::Csv);
489 assert!(csv.contains("\"my,app\""));
490 }
491
492 #[test]
493 fn format_json_output() {
494 let result = ReportResult {
495 rows: vec![ReportRow {
496 group: "app".to_string(),
497 total_secs: 3600,
498 entry_count: 1,
499 earnings_cents: None,
500 }],
501 unique_total_secs: 3600,
502 unique_entry_count: 1,
503 };
504 let json = format_report(&result, &ReportFormat::Json);
505 assert!(json.contains("\"group\": \"app\""));
506 assert!(json.contains("\"total_secs\": 3600"));
507 assert!(json.contains("\"earnings_cents\": null"));
508 }
509
510 #[test]
511 fn format_markdown_output() {
512 let result = ReportResult {
513 rows: vec![ReportRow {
514 group: "app".to_string(),
515 total_secs: 3600,
516 entry_count: 1,
517 earnings_cents: Some(15000),
518 }],
519 unique_total_secs: 3600,
520 unique_entry_count: 1,
521 };
522 let md = format_report(&result, &ReportFormat::Markdown);
523 assert!(md.contains("| app | 1h | 1 | $150.00 |"));
524 assert!(md.contains("| **Total**"));
525 }
526
527 #[test]
528 fn format_markdown_escapes_pipes() {
529 let result = ReportResult {
530 rows: vec![ReportRow {
531 group: "a|b".to_string(),
532 total_secs: 60,
533 entry_count: 1,
534 earnings_cents: None,
535 }],
536 unique_total_secs: 60,
537 unique_entry_count: 1,
538 };
539 let md = format_report(&result, &ReportFormat::Markdown);
540 assert!(md.contains("a\\|b"));
541 }
542
543 #[test]
544 fn format_table_output() {
545 let result = ReportResult {
546 rows: vec![ReportRow {
547 group: "app".to_string(),
548 total_secs: 3600,
549 entry_count: 1,
550 earnings_cents: Some(15000),
551 }],
552 unique_total_secs: 3600,
553 unique_entry_count: 1,
554 };
555 let table = format_report(&result, &ReportFormat::Table);
556 assert!(table.contains("GROUP"));
557 assert!(table.contains("app"));
558 assert!(table.contains("1h"));
559 assert!(table.contains("$150.00"));
560 assert!(table.contains("Total"));
561 }
562
563 #[test]
564 fn format_table_empty() {
565 let result = ReportResult {
566 rows: vec![],
567 unique_total_secs: 0,
568 unique_entry_count: 0,
569 };
570 let table = format_report(&result, &ReportFormat::Table);
571 assert_eq!(table, "No entries found.\n");
572 }
573}