Skip to main content

warden/reports/
files.rs

1//! `warden report files` — *attributed* tokens per file.
2//!
3//! No log records "this file consumed N tokens". This report derives it:
4//!
5//! 1. read `tool_calls` on each event for file paths,
6//! 2. assign that event's token cost across the distinct files it touched,
7//! 3. split evenly.
8//!
9//! That is a heuristic and it is labelled as one everywhere it surfaces: the
10//! columns say `attrib.`, the notes say `attributed, not measured`, and every
11//! JSON row carries `"method": "even-split"` so a consumer can tell without
12//! reading this file.
13
14use std::collections::BTreeMap;
15
16use crate::config::Pricing;
17use crate::output::{Cell, Report, Table};
18use crate::reports::Cost;
19use crate::store::Event;
20use crate::store::Scanner;
21
22use super::{count, desc, scan, ReportCtx, ReportError};
23
24/// The documented attribution rule, published in the rows and the notes.
25pub const METHOD: &str = "even-split";
26
27/// Files are long-tailed; the table shows the heaviest and says so.
28const LIMIT: usize = 25;
29
30/// A share of a turn's tokens, hence fractional.
31#[derive(Debug, Clone, Copy, Default)]
32struct Attributed {
33    events: u64,
34    tool_calls: u64,
35    input: f64,
36    output: f64,
37    cache_read: f64,
38    cache_write: f64,
39    /// The attributed share of this file's spend. Sharing `Cost` with every
40    /// other report is what keeps the honesty rules — `–` when nothing could be
41    /// priced, `~+` when only some of it could — stated in exactly one place.
42    cost: Cost,
43}
44
45impl Attributed {
46    fn add(&mut self, event: &Event, share: f64, calls: u64, pricing: &Pricing) {
47        self.events += 1;
48        self.tool_calls += calls;
49        self.input += event.input_tok.unwrap_or(0) as f64 * share;
50        self.output += event.output_tok.unwrap_or(0) as f64 * share;
51        self.cache_read += event.cache_read_tok.unwrap_or(0) as f64 * share;
52        self.cache_write += event.cache_write_tok.unwrap_or(0) as f64 * share;
53        self.cost.add_share(event, pricing, share);
54    }
55
56    fn total(&self) -> f64 {
57        self.input + self.output + self.cache_read + self.cache_write
58    }
59}
60
61pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
62    let scanned = scan(scanner, ctx)?;
63
64    let mut by_file: BTreeMap<String, Attributed> = BTreeMap::new();
65    let mut attributed_events = 0u64;
66    let mut unattributed = super::Totals::default();
67
68    for event in &scanned.events {
69        // Distinct paths: two Edits of the same file are one file, not two
70        // shares, or a turn that edits one file twice would halve its own cost.
71        let mut calls: BTreeMap<&str, u64> = BTreeMap::new();
72        for call in &event.tool_calls {
73            if let Some(path) = file_target(call.tool_target.as_deref()) {
74                *calls.entry(path).or_default() += 1;
75            }
76        }
77        if calls.is_empty() {
78            if event.has_usage() {
79                unattributed.add(event, &ctx.pricing);
80            }
81            continue;
82        }
83        attributed_events += 1;
84        let share = 1.0 / calls.len() as f64;
85        for (path, calls_here) in calls {
86            by_file.entry(path.to_string()).or_default().add(
87                event,
88                share,
89                calls_here,
90                &ctx.pricing,
91            );
92        }
93    }
94
95    let total_files = by_file.len();
96    let mut ranked: Vec<(String, Attributed)> = by_file.into_iter().collect();
97    ranked.sort_by(|a, b| desc(a.1.total(), b.1.total()).then_with(|| a.0.cmp(&b.0)));
98
99    let mut table = Table::new([
100        "file",
101        "turns",
102        "calls",
103        "attrib. in",
104        "attrib. out",
105        "attrib. cache r",
106        "attrib. cost",
107    ]);
108    let mut rows = Vec::new();
109
110    for (path, attributed) in ranked.iter().take(LIMIT) {
111        table.push(vec![
112            Cell::text(path),
113            count(attributed.events),
114            count(attributed.tool_calls),
115            count(attributed.input.round() as u64),
116            count(attributed.output.round() as u64),
117            count(attributed.cache_read.round() as u64),
118            attributed.cost.cell(),
119        ]);
120        rows.push(serde_json::json!({
121            "file": path,
122            "method": METHOD,
123            "attributed": true,
124            "turns": attributed.events,
125            "tool_calls": attributed.tool_calls,
126            "attributed_input_tok": round2(attributed.input),
127            "attributed_output_tok": round2(attributed.output),
128            "attributed_cache_read_tok": round2(attributed.cache_read),
129            "attributed_cache_write_tok": round2(attributed.cache_write),
130            "attributed_cost_est": attributed.cost.json(),
131            "cost_partial": attributed.cost.is_partial(),
132            "cost_priced_events": attributed.cost.priced,
133            "cost_unpriced_events": attributed.cost.unpriced,
134        }));
135    }
136
137    let mut notes = scanned.notes;
138    notes.push(format!(
139        "attributed, not measured: no log records what a file cost. Each event's tokens are split \
140         evenly across the distinct files its tool calls touched (method: {METHOD}); --json carries \
141         \"method\": \"{METHOD}\" on every row"
142    ));
143    notes.push(format!(
144        "{attributed_events} events touched a file and were attributed; {} events carried usage \
145         but touched no file (a plain answer, a shell command) and are in no row here — attributed \
146         totals therefore sum to less than the period's",
147        unattributed.requests
148    ));
149    if total_files > LIMIT {
150        notes.push(format!(
151            "showing the {LIMIT} heaviest of {total_files} files, in the table and in --json alike"
152        ));
153    }
154
155    Ok(Report::new("files", ctx.window, table)
156        .with_json_rows(rows)
157        .with_notes(notes.finish()))
158}
159
160/// A tool target that names a file. URLs are targets too (`WebFetch`), and a
161/// fetched page is not a file in the repo, so they are excluded.
162fn file_target(target: Option<&str>) -> Option<&str> {
163    let target = target?.trim();
164    if target.is_empty() || target.starts_with("http://") || target.starts_with("https://") {
165        return None;
166    }
167    Some(target)
168}
169
170/// Token shares are fractional by construction; published at 2dp like money.
171fn round2(value: f64) -> f64 {
172    (value * 100.0).round() / 100.0
173}
174
175#[cfg(test)]
176mod tests {
177    use super::super::testkit::*;
178    use super::*;
179    use crate::cli::TimeWindow;
180    use crate::output::Style;
181
182    fn report(events: &[Event]) -> Report {
183        let (_dir, paths) = store(events);
184        build(
185            &Scanner::new(paths),
186            &ReportCtx::new(TimeWindow::all(), None, true),
187        )
188        .unwrap()
189    }
190
191    fn row<'a>(report: &'a Report, file: &str) -> &'a serde_json::Value {
192        report
193            .json_rows
194            .iter()
195            .find(|row| row["file"] == file)
196            .unwrap_or_else(|| panic!("no row for {file}"))
197    }
198
199    #[test]
200    fn a_turn_splits_evenly_across_the_files_it_touched() {
201        let report = report(&[with_tools(
202            priced(used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20), 1.0),
203            &[("Read", "src/a.rs"), ("Edit", "src/b.rs")],
204        )]);
205        for file in ["src/a.rs", "src/b.rs"] {
206            let row = row(&report, file);
207            assert_eq!(row["attributed_input_tok"], 50.0, "{file}");
208            assert_eq!(row["attributed_output_tok"], 10.0, "{file}");
209            assert_eq!(row["attributed_cost_est"], 0.5, "{file}");
210            assert_eq!(row["method"], METHOD);
211        }
212    }
213
214    #[test]
215    fn two_calls_on_one_file_are_one_share_not_two() {
216        let report = report(&[with_tools(
217            used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
218            &[("Read", "src/a.rs"), ("Edit", "src/a.rs")],
219        )]);
220        let row = row(&report, "src/a.rs");
221        assert_eq!(row["attributed_input_tok"], 100.0, "the whole turn, once");
222        assert_eq!(row["tool_calls"], 2);
223        assert_eq!(row["turns"], 1);
224        assert_eq!(report.json_rows.len(), 1);
225    }
226
227    #[test]
228    fn the_method_is_labelled_in_the_table_and_the_notes() {
229        let report = report(&[with_tools(
230            used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
231            &[("Read", "src/a.rs")],
232        )]);
233        let rendered = report.table.render(Style::plain());
234        assert!(rendered.contains("ATTRIB. IN"), "{rendered}");
235        assert!(
236            report
237                .notes
238                .iter()
239                .any(|n| n.contains("attributed, not measured") && n.contains(METHOD)),
240            "{:?}",
241            report.notes
242        );
243    }
244
245    #[test]
246    fn events_that_touched_no_file_are_excluded_and_the_shortfall_is_stated() {
247        let report = report(&[
248            with_tools(
249                used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
250                &[("Read", "src/a.rs")],
251            ),
252            used("b", ms(2026, 8, 4, 9), "acme", "m", 900, 20),
253        ]);
254        assert_eq!(report.json_rows.len(), 1);
255        assert!(
256            report
257                .notes
258                .iter()
259                .any(|n| n.contains("1 events carried usage but touched no file")),
260            "{:?}",
261            report.notes
262        );
263    }
264
265    #[test]
266    fn urls_are_not_files() {
267        let report = report(&[with_tools(
268            used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
269            &[("WebFetch", "https://example.com/x"), ("Read", "src/a.rs")],
270        )]);
271        assert_eq!(report.json_rows.len(), 1);
272        assert_eq!(
273            row(&report, "src/a.rs")["attributed_input_tok"],
274            100.0,
275            "the fetched page takes no share"
276        );
277    }
278
279    #[test]
280    fn an_unpriced_file_shows_a_dash_not_zero() {
281        let report = report(&[with_tools(
282            used("a", ms(2026, 8, 4, 8), "acme", "claude-opus-5", 100, 20),
283            &[("Read", "src/a.rs")],
284        )]);
285        assert!(row(&report, "src/a.rs")["attributed_cost_est"].is_null());
286        let rendered = report.table.render(Style::plain());
287        assert!(rendered.contains(crate::output::UNSUPPORTED), "{rendered}");
288        assert!(!rendered.contains("$0.00"), "{rendered}");
289    }
290}