Skip to main content

stint_core/
report.rs

1//! Report generation and formatting.
2
3use 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/// How to group report entries.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum GroupBy {
14    /// Group by project name.
15    Project,
16    /// Group by tag (entries with multiple tags appear in multiple groups).
17    Tag,
18}
19
20impl GroupBy {
21    /// Parses a group-by string.
22    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/// Output format for reports.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ReportFormat {
34    /// Plain-text aligned table for terminal display.
35    Table,
36    /// Markdown table.
37    Markdown,
38    /// Comma-separated values.
39    Csv,
40    /// JSON array.
41    Json,
42}
43
44impl ReportFormat {
45    /// Parses a format string.
46    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/// A single row in a report.
60#[derive(Debug, Clone, Serialize)]
61pub struct ReportRow {
62    /// Group label (project name or tag).
63    pub group: String,
64    /// Total duration in seconds.
65    pub total_secs: i64,
66    /// Number of entries in this group.
67    pub entry_count: usize,
68    /// Earnings in cents (if hourly rate is set).
69    pub earnings_cents: Option<i64>,
70}
71
72/// Accumulator for a report group.
73struct GroupAccum {
74    total_secs: i64,
75    entry_count: usize,
76    /// Accumulated earnings in cents (None if any entry in the group lacks a rate).
77    earnings_cents: Option<i64>,
78}
79
80impl GroupAccum {
81    /// Creates a new accumulator.
82    fn new() -> Self {
83        Self {
84            total_secs: 0,
85            entry_count: 0,
86            earnings_cents: Some(0),
87        }
88    }
89
90    /// Adds an entry's contribution to this group.
91    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                // If any entry lacks a rate, earnings become indeterminate
100                self.earnings_cents = None;
101            }
102        }
103    }
104
105    /// Converts to a report row.
106    fn into_row(self, group: String) -> ReportRow {
107        // If no entries had rates, show None rather than Some(0)
108        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
121/// Result of report generation, including deduplicated totals.
122pub struct ReportResult {
123    /// Grouped rows.
124    pub rows: Vec<ReportRow>,
125    /// Total seconds across unique entries (not double-counted).
126    pub unique_total_secs: i64,
127    /// Total unique entries (not double-counted).
128    pub unique_entry_count: usize,
129}
130
131/// Generates grouped report rows from entries, with deduplicated totals.
132pub 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    // Track unique entries for accurate totals
137    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        // Count each entry only once for totals
144        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
186/// Formats report rows into the specified output format.
187pub 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
196/// Escapes a string for use in a CSV field.
197fn 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
205/// Escapes a string for use in a Markdown table cell.
206fn escape_markdown(field: &str) -> String {
207    field.replace('|', "\\|").replace('\n', " ")
208}
209
210/// Renders rows as a plain-text aligned table for terminal display.
211fn format_table(result: &ReportResult) -> String {
212    if result.rows.is_empty() {
213        return "No entries found.\n".to_string();
214    }
215
216    // Pre-format all values so we can measure widths
217    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    // Compute dynamic column widths from headers, rows, and footer
238    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    // Header
269    out.push_str(&format!(
270        "  {:<gw$}  {:>tw$}  {:>ew$}  {:>rw$}\n",
271        "GROUP", "TIME", "ENTRIES", "EARNINGS",
272    ));
273
274    // Rows
275    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    // Footer
283    out.push_str(&format!(
284        "  {:<gw$}  {:>tw$}  {:>ew$}  {:>rw$}\n",
285        "Total", total_time, total_entries, "",
286    ));
287
288    out
289}
290
291/// Renders rows as a Markdown table with deduplicated totals.
292fn 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
320/// Renders rows as CSV with proper field escaping.
321fn 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
340/// Renders rows as JSON.
341fn 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)); // 1.5h * $150
404        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); // (untagged), client, frontend
419
420        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); // 3600 + 1800
429
430        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)); // $100/hr
437        let entries = vec![
438            make_entry(&p, 3600, vec!["frontend"]), // 1h = $100
439            make_entry(&p, 1800, vec!["frontend"]), // 30m = $50
440        ];
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)); // $150
445    }
446
447    #[test]
448    fn deduplicated_totals_for_tags() {
449        let p = make_project("app", None);
450        // One entry with two tags — should only count once in totals
451        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); // two tag groups
455        assert_eq!(result.unique_total_secs, 3600); // not 7200
456        assert_eq!(result.unique_entry_count, 1); // not 2
457    }
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}