1use 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
24pub const METHOD: &str = "even-split";
26
27const LIMIT: usize = 25;
29
30#[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 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 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
160fn 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
170fn 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}