use std::collections::HashSet;
use serde::Serialize;
use crate::duration::format_duration_human;
use crate::models::entry::TimeEntry;
use crate::models::project::Project;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GroupBy {
Project,
Tag,
}
impl GroupBy {
pub fn from_str_value(s: &str) -> Result<Self, String> {
match s.to_lowercase().as_str() {
"project" => Ok(Self::Project),
"tag" => Ok(Self::Tag),
_ => Err(format!("unknown group-by: '{s}' (use 'project' or 'tag')")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReportFormat {
Table,
Markdown,
Csv,
Json,
}
impl ReportFormat {
pub fn from_str_value(s: &str) -> Result<Self, String> {
match s.to_lowercase().as_str() {
"table" => Ok(Self::Table),
"markdown" | "md" => Ok(Self::Markdown),
"csv" => Ok(Self::Csv),
"json" => Ok(Self::Json),
_ => Err(format!(
"unknown format: '{s}' (use 'table', 'markdown', 'csv', or 'json')"
)),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ReportRow {
pub group: String,
pub total_secs: i64,
pub entry_count: usize,
pub earnings_cents: Option<i64>,
}
struct GroupAccum {
total_secs: i64,
entry_count: usize,
earnings_cents: Option<i64>,
}
impl GroupAccum {
fn new() -> Self {
Self {
total_secs: 0,
entry_count: 0,
earnings_cents: Some(0),
}
}
fn add(&mut self, duration: i64, hourly_rate_cents: Option<i64>) {
self.total_secs += duration;
self.entry_count += 1;
match (self.earnings_cents, hourly_rate_cents) {
(Some(acc), Some(rate)) => {
self.earnings_cents = Some(acc + duration * rate / 3600);
}
_ => {
self.earnings_cents = None;
}
}
}
fn into_row(self, group: String) -> ReportRow {
let earnings = match self.earnings_cents {
Some(0) if self.entry_count > 0 => None,
other => other,
};
ReportRow {
group,
total_secs: self.total_secs,
entry_count: self.entry_count,
earnings_cents: earnings,
}
}
}
pub struct ReportResult {
pub rows: Vec<ReportRow>,
pub unique_total_secs: i64,
pub unique_entry_count: usize,
}
pub fn generate_report(entries: &[(TimeEntry, Project)], group_by: &GroupBy) -> ReportResult {
let mut groups: std::collections::BTreeMap<String, GroupAccum> =
std::collections::BTreeMap::new();
let mut seen_ids: HashSet<String> = HashSet::new();
let mut unique_total_secs: i64 = 0;
for (entry, project) in entries {
let duration = entry.computed_duration_secs().unwrap_or(0);
let entry_id = entry.id.as_str().to_owned();
if seen_ids.insert(entry_id) {
unique_total_secs += duration;
}
match group_by {
GroupBy::Project => {
groups
.entry(project.name.clone())
.or_insert_with(GroupAccum::new)
.add(duration, project.hourly_rate_cents);
}
GroupBy::Tag => {
if entry.tags.is_empty() {
groups
.entry("(untagged)".to_string())
.or_insert_with(GroupAccum::new)
.add(duration, project.hourly_rate_cents);
} else {
for tag in &entry.tags {
groups
.entry(tag.clone())
.or_insert_with(GroupAccum::new)
.add(duration, project.hourly_rate_cents);
}
}
}
}
}
let rows: Vec<ReportRow> = groups
.into_iter()
.map(|(group, accum)| accum.into_row(group))
.collect();
ReportResult {
rows,
unique_total_secs,
unique_entry_count: seen_ids.len(),
}
}
pub fn format_report(result: &ReportResult, format: &ReportFormat) -> String {
match format {
ReportFormat::Table => format_table(result),
ReportFormat::Markdown => format_markdown(result),
ReportFormat::Csv => format_csv(&result.rows),
ReportFormat::Json => format_json(&result.rows),
}
}
fn escape_csv(field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
fn escape_markdown(field: &str) -> String {
field.replace('|', "\\|").replace('\n', " ")
}
fn format_table(result: &ReportResult) -> String {
if result.rows.is_empty() {
return "No entries found.\n".to_string();
}
let formatted: Vec<(String, String, String, String)> = result
.rows
.iter()
.map(|row| {
let earnings = match row.earnings_cents {
Some(c) => format!("${}.{:02}", c / 100, c % 100),
None => "\u{2014}".to_string(),
};
(
row.group.clone(),
format_duration_human(row.total_secs),
row.entry_count.to_string(),
earnings,
)
})
.collect();
let total_time = format_duration_human(result.unique_total_secs);
let total_entries = result.unique_entry_count.to_string();
let gw = formatted
.iter()
.map(|(g, _, _, _)| g.len())
.chain(std::iter::once("GROUP".len()))
.chain(std::iter::once("Total".len()))
.max()
.unwrap_or(5);
let tw = formatted
.iter()
.map(|(_, t, _, _)| t.len())
.chain(std::iter::once("TIME".len()))
.chain(std::iter::once(total_time.len()))
.max()
.unwrap_or(4);
let ew = formatted
.iter()
.map(|(_, _, e, _)| e.len())
.chain(std::iter::once("ENTRIES".len()))
.chain(std::iter::once(total_entries.len()))
.max()
.unwrap_or(7);
let rw = formatted
.iter()
.map(|(_, _, _, r)| r.len())
.chain(std::iter::once("EARNINGS".len()))
.max()
.unwrap_or(8);
let mut out = String::new();
out.push_str(&format!(
" {:<gw$} {:>tw$} {:>ew$} {:>rw$}\n",
"GROUP", "TIME", "ENTRIES", "EARNINGS",
));
for (group, time, entries, earnings) in &formatted {
out.push_str(&format!(
" {:<gw$} {:>tw$} {:>ew$} {:>rw$}\n",
group, time, entries, earnings,
));
}
out.push_str(&format!(
" {:<gw$} {:>tw$} {:>ew$} {:>rw$}\n",
"Total", total_time, total_entries, "",
));
out
}
fn format_markdown(result: &ReportResult) -> String {
let mut out = String::new();
out.push_str("| Group | Time | Entries | Earnings |\n");
out.push_str("|-------|------|---------|----------|\n");
for row in &result.rows {
let earnings = match row.earnings_cents {
Some(c) => format!("${}.{:02}", c / 100, c % 100),
None => "\u{2014}".to_string(),
};
out.push_str(&format!(
"| {} | {} | {} | {} |\n",
escape_markdown(&row.group),
format_duration_human(row.total_secs),
row.entry_count,
earnings,
));
}
out.push_str(&format!(
"| **Total** | **{}** | **{}** | |\n",
format_duration_human(result.unique_total_secs),
result.unique_entry_count,
));
out
}
fn format_csv(rows: &[ReportRow]) -> String {
let mut out = String::from("group,time_secs,time_human,entries,earnings_cents\n");
for row in rows {
let earnings = row
.earnings_cents
.map(|c| c.to_string())
.unwrap_or_default();
out.push_str(&format!(
"{},{},{},{},{}\n",
escape_csv(&row.group),
row.total_secs,
format_duration_human(row.total_secs),
row.entry_count,
earnings,
));
}
out
}
fn format_json(rows: &[ReportRow]) -> String {
serde_json::to_string_pretty(rows).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::entry::{EntrySource, TimeEntry};
use crate::models::project::{Project, ProjectSource, ProjectStatus};
use crate::models::types::{EntryId, ProjectId};
use std::path::PathBuf;
use time::OffsetDateTime;
fn make_entry(project: &Project, duration: i64, tags: Vec<&str>) -> (TimeEntry, Project) {
let now = OffsetDateTime::now_utc();
let entry = TimeEntry {
id: EntryId::new(),
project_id: project.id.clone(),
session_id: None,
start: now,
end: Some(now + time::Duration::seconds(duration)),
duration_secs: Some(duration),
source: EntrySource::Manual,
notes: None,
tags: tags.into_iter().map(String::from).collect(),
created_at: now,
updated_at: now,
};
(entry, project.clone())
}
fn make_project(name: &str, rate: Option<i64>) -> Project {
let now = OffsetDateTime::now_utc();
Project {
id: ProjectId::new(),
name: name.to_string(),
paths: vec![PathBuf::from(format!("/home/user/{name}"))],
tags: vec![],
hourly_rate_cents: rate,
status: ProjectStatus::Active,
source: ProjectSource::Manual,
created_at: now,
updated_at: now,
}
}
#[test]
fn group_by_project() {
let p1 = make_project("app-1", Some(15000));
let p2 = make_project("app-2", None);
let entries = vec![
make_entry(&p1, 3600, vec![]),
make_entry(&p1, 1800, vec![]),
make_entry(&p2, 7200, vec![]),
];
let result = generate_report(&entries, &GroupBy::Project);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0].group, "app-1");
assert_eq!(result.rows[0].total_secs, 5400);
assert_eq!(result.rows[0].entry_count, 2);
assert_eq!(result.rows[0].earnings_cents, Some(22500)); assert_eq!(result.rows[1].group, "app-2");
assert_eq!(result.rows[1].earnings_cents, None);
}
#[test]
fn group_by_tag() {
let p = make_project("app", None);
let entries = vec![
make_entry(&p, 3600, vec!["frontend", "client"]),
make_entry(&p, 1800, vec!["frontend"]),
make_entry(&p, 900, vec![]),
];
let result = generate_report(&entries, &GroupBy::Tag);
assert_eq!(result.rows.len(), 3);
let untagged = result
.rows
.iter()
.find(|r| r.group == "(untagged)")
.unwrap();
assert_eq!(untagged.total_secs, 900);
let frontend = result.rows.iter().find(|r| r.group == "frontend").unwrap();
assert_eq!(frontend.total_secs, 5400);
let client = result.rows.iter().find(|r| r.group == "client").unwrap();
assert_eq!(client.total_secs, 3600);
}
#[test]
fn tag_earnings_from_project_rate() {
let p = make_project("app", Some(10000)); let entries = vec![
make_entry(&p, 3600, vec!["frontend"]), make_entry(&p, 1800, vec!["frontend"]), ];
let result = generate_report(&entries, &GroupBy::Tag);
let frontend = result.rows.iter().find(|r| r.group == "frontend").unwrap();
assert_eq!(frontend.earnings_cents, Some(15000)); }
#[test]
fn deduplicated_totals_for_tags() {
let p = make_project("app", None);
let entries = vec![make_entry(&p, 3600, vec!["frontend", "client"])];
let result = generate_report(&entries, &GroupBy::Tag);
assert_eq!(result.rows.len(), 2); assert_eq!(result.unique_total_secs, 3600); assert_eq!(result.unique_entry_count, 1); }
#[test]
fn format_csv_output() {
let result = ReportResult {
rows: vec![ReportRow {
group: "app".to_string(),
total_secs: 5400,
entry_count: 2,
earnings_cents: Some(22500),
}],
unique_total_secs: 5400,
unique_entry_count: 2,
};
let csv = format_report(&result, &ReportFormat::Csv);
assert!(csv.contains("group,time_secs,time_human,entries,earnings_cents"));
assert!(csv.contains("app,5400,1h 30m,2,22500"));
}
#[test]
fn format_csv_escapes_commas() {
let result = ReportResult {
rows: vec![ReportRow {
group: "my,app".to_string(),
total_secs: 3600,
entry_count: 1,
earnings_cents: None,
}],
unique_total_secs: 3600,
unique_entry_count: 1,
};
let csv = format_report(&result, &ReportFormat::Csv);
assert!(csv.contains("\"my,app\""));
}
#[test]
fn format_json_output() {
let result = ReportResult {
rows: vec![ReportRow {
group: "app".to_string(),
total_secs: 3600,
entry_count: 1,
earnings_cents: None,
}],
unique_total_secs: 3600,
unique_entry_count: 1,
};
let json = format_report(&result, &ReportFormat::Json);
assert!(json.contains("\"group\": \"app\""));
assert!(json.contains("\"total_secs\": 3600"));
assert!(json.contains("\"earnings_cents\": null"));
}
#[test]
fn format_markdown_output() {
let result = ReportResult {
rows: vec![ReportRow {
group: "app".to_string(),
total_secs: 3600,
entry_count: 1,
earnings_cents: Some(15000),
}],
unique_total_secs: 3600,
unique_entry_count: 1,
};
let md = format_report(&result, &ReportFormat::Markdown);
assert!(md.contains("| app | 1h | 1 | $150.00 |"));
assert!(md.contains("| **Total**"));
}
#[test]
fn format_markdown_escapes_pipes() {
let result = ReportResult {
rows: vec![ReportRow {
group: "a|b".to_string(),
total_secs: 60,
entry_count: 1,
earnings_cents: None,
}],
unique_total_secs: 60,
unique_entry_count: 1,
};
let md = format_report(&result, &ReportFormat::Markdown);
assert!(md.contains("a\\|b"));
}
#[test]
fn format_table_output() {
let result = ReportResult {
rows: vec![ReportRow {
group: "app".to_string(),
total_secs: 3600,
entry_count: 1,
earnings_cents: Some(15000),
}],
unique_total_secs: 3600,
unique_entry_count: 1,
};
let table = format_report(&result, &ReportFormat::Table);
assert!(table.contains("GROUP"));
assert!(table.contains("app"));
assert!(table.contains("1h"));
assert!(table.contains("$150.00"));
assert!(table.contains("Total"));
}
#[test]
fn format_table_empty() {
let result = ReportResult {
rows: vec![],
unique_total_secs: 0,
unique_entry_count: 0,
};
let table = format_report(&result, &ReportFormat::Table);
assert_eq!(table, "No entries found.\n");
}
}