Skip to main content

warden/reports/
projects.rs

1//! `warden report projects` — tokens and est. cost per project.
2
3use 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    /// A project mixing priced and unpriced models: the figure is real but
126    /// incomplete, so it is marked rather than printed as a bare total.
127    #[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}