1use crate::output::{Cell, Report, Table};
4use crate::store::Scanner;
5
6use super::{by_weight_desc, count, rollup, scan, ReportCtx, ReportError, NO_PROJECT};
7
8pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
9 let scanned = scan(scanner, ctx)?;
10 let by_project = rollup(&scanned.events, &ctx.pricing, |event| {
11 Some(
12 event
13 .project
14 .clone()
15 .unwrap_or_else(|| NO_PROJECT.to_string()),
16 )
17 });
18
19 let mut table = Table::new(["project", "sessions", "in", "out", "cache r", "est. cost"]);
20 let mut rows = Vec::new();
21 let mut unattributed = false;
22
23 for (project, totals) in by_weight_desc(by_project) {
24 unattributed |= project == NO_PROJECT;
25 let mut row = vec![Cell::text(&project), count(totals.sessions.len() as u64)];
26 row.extend(totals.tail_cells());
27 table.push(row);
28
29 let mut json = serde_json::Map::new();
30 json.insert(
31 "project".into(),
32 if project == NO_PROJECT {
33 serde_json::Value::Null
34 } else {
35 serde_json::json!(project)
36 },
37 );
38 json.insert("sessions".into(), serde_json::json!(totals.sessions.len()));
39 totals.write_json(&mut json);
40 rows.push(serde_json::Value::Object(json));
41 }
42
43 let mut notes = scanned.notes;
44 if unattributed {
45 notes.push(format!(
46 "{NO_PROJECT}: events whose source log recorded no working directory; they are real \
47 spend and are kept rather than dropped, and their JSON `project` is null"
48 ));
49 }
50 Ok(Report::new("projects", ctx.window, table)
51 .with_json_rows(rows)
52 .with_notes(notes.finish()))
53}
54
55#[cfg(test)]
56mod tests {
57 use super::super::testkit::*;
58 use super::*;
59 use crate::cli::TimeWindow;
60 use crate::output::Style;
61
62 fn report(project_filter: Option<&str>) -> Report {
63 let mut orphan = used("d", ms(2026, 8, 4, 9), "x", "m", 7, 1);
64 orphan.project = None;
65 let (_dir, paths) = store(&[
66 priced(used("a", ms(2026, 8, 4, 8), "acme", "m", 1_000, 100), 5.0),
67 priced(used("b", ms(2026, 8, 4, 9), "acme", "m", 500, 50), 2.5),
68 priced(used("c", ms(2026, 8, 4, 9), "dotfiles", "m", 10, 1), 0.1),
69 orphan,
70 ]);
71 build(
72 &Scanner::new(paths),
73 &ReportCtx::new(TimeWindow::all(), project_filter.map(str::to_string), true),
74 )
75 .unwrap()
76 }
77
78 #[test]
79 fn heaviest_project_first_with_the_documented_columns() {
80 let rendered = report(None).table.render(Style::plain());
81 let lines: Vec<&str> = rendered.lines().collect();
82 assert_eq!(
83 lines[0], "PROJECT SESSIONS IN OUT CACHE R EST. COST",
84 "{rendered}"
85 );
86 assert!(lines[1].starts_with("acme"), "{rendered}");
87 assert!(lines[2].starts_with("dotfiles"), "{rendered}");
88 assert!(rendered.contains("~ estimated"));
89 }
90
91 #[test]
92 fn honours_the_project_filter() {
93 let report = report(Some("dotfiles"));
94 assert_eq!(report.json_rows.len(), 1);
95 assert_eq!(report.json_rows[0]["project"], "dotfiles");
96 }
97
98 #[test]
99 fn unattributed_events_are_kept_with_a_null_project_and_a_note() {
100 let report = report(None);
101 let orphan = report
102 .json_rows
103 .iter()
104 .find(|row| row["project"].is_null())
105 .expect("the projectless event still has a row");
106 assert_eq!(orphan["input_tok"], 7);
107 assert!(report.notes.iter().any(|n| n.contains("(no project)")));
108 }
109
110 #[test]
111 fn unpriced_projects_show_a_dash_rather_than_zero() {
112 let (_dir, paths) = store(&[used("a", ms(2026, 8, 4, 8), "acme", "claude-opus-5", 10, 1)]);
113 let report = build(
114 &Scanner::new(paths),
115 &ReportCtx::new(TimeWindow::all(), None, true),
116 )
117 .unwrap();
118 let rendered = report.table.render(Style::plain());
119 assert!(rendered.contains(crate::output::UNSUPPORTED), "{rendered}");
120 assert!(!rendered.contains("$0.00"), "{rendered}");
121 assert!(report.json_rows[0]["cost_est"].is_null());
122 assert_eq!(report.json_rows[0]["cost_partial"], false);
123 }
124
125 #[test]
128 fn a_partly_priced_project_is_marked_not_passed_off_as_a_total() {
129 let config: crate::config::Config = toml::from_str(
130 r#"
131[pricing.anthropic]
132"claude-sonnet-5" = { input = 3.0, output = 15.0, cache_read = 0.3 }
133"#,
134 )
135 .unwrap();
136 let (_dir, paths) = store(&[
137 used(
138 "a",
139 ms(2026, 8, 4, 8),
140 "acme",
141 "claude-sonnet-5",
142 1_000,
143 100,
144 ),
145 used("b", ms(2026, 8, 4, 9), "acme", "claude-opus-5", 1_000, 100),
146 ]);
147 let report = build(
148 &Scanner::new(paths),
149 &ReportCtx::new(TimeWindow::all(), None, true).with_pricing(config.pricing()),
150 )
151 .unwrap();
152
153 let rendered = report.table.render(Style::plain());
154 assert!(rendered.contains(" ~+"), "{rendered}");
155 assert!(rendered.contains("partial"), "the legend explains the mark");
156
157 let row = &report.json_rows[0];
158 assert_eq!(row["cost_partial"], true);
159 assert_eq!(row["cost_priced_requests"], 1);
160 assert_eq!(row["cost_unpriced_requests"], 1);
161 assert!(row["cost_est"].as_f64().unwrap() > 0.0);
162 assert!(
163 report
164 .notes
165 .iter()
166 .any(|n| n.contains("no configured price for claude-opus-5")
167 && n.contains("marked ~+")),
168 "{:?}",
169 report.notes
170 );
171 }
172}