use std::collections::BTreeMap;
use crate::config::Pricing;
use crate::output::{Cell, Report, Table};
use crate::reports::Cost;
use crate::store::Event;
use crate::store::Scanner;
use super::{count, desc, scan, ReportCtx, ReportError};
pub const METHOD: &str = "even-split";
const LIMIT: usize = 25;
#[derive(Debug, Clone, Copy, Default)]
struct Attributed {
events: u64,
tool_calls: u64,
input: f64,
output: f64,
cache_read: f64,
cache_write: f64,
cost: Cost,
}
impl Attributed {
fn add(&mut self, event: &Event, share: f64, calls: u64, pricing: &Pricing) {
self.events += 1;
self.tool_calls += calls;
self.input += event.input_tok.unwrap_or(0) as f64 * share;
self.output += event.output_tok.unwrap_or(0) as f64 * share;
self.cache_read += event.cache_read_tok.unwrap_or(0) as f64 * share;
self.cache_write += event.cache_write_tok.unwrap_or(0) as f64 * share;
self.cost.add_share(event, pricing, share);
}
fn total(&self) -> f64 {
self.input + self.output + self.cache_read + self.cache_write
}
}
pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
let scanned = scan(scanner, ctx)?;
let mut by_file: BTreeMap<String, Attributed> = BTreeMap::new();
let mut attributed_events = 0u64;
let mut unattributed = super::Totals::default();
for event in &scanned.events {
let mut calls: BTreeMap<&str, u64> = BTreeMap::new();
for call in &event.tool_calls {
if let Some(path) = file_target(call.tool_target.as_deref()) {
*calls.entry(path).or_default() += 1;
}
}
if calls.is_empty() {
if event.has_usage() {
unattributed.add(event, &ctx.pricing);
}
continue;
}
attributed_events += 1;
let share = 1.0 / calls.len() as f64;
for (path, calls_here) in calls {
by_file.entry(path.to_string()).or_default().add(
event,
share,
calls_here,
&ctx.pricing,
);
}
}
let total_files = by_file.len();
let mut ranked: Vec<(String, Attributed)> = by_file.into_iter().collect();
ranked.sort_by(|a, b| desc(a.1.total(), b.1.total()).then_with(|| a.0.cmp(&b.0)));
let mut table = Table::new([
"file",
"turns",
"calls",
"attrib. in",
"attrib. out",
"attrib. cache r",
"attrib. cost",
]);
let mut rows = Vec::new();
for (path, attributed) in ranked.iter().take(LIMIT) {
table.push(vec![
Cell::text(path),
count(attributed.events),
count(attributed.tool_calls),
count(attributed.input.round() as u64),
count(attributed.output.round() as u64),
count(attributed.cache_read.round() as u64),
attributed.cost.cell(),
]);
rows.push(serde_json::json!({
"file": path,
"method": METHOD,
"attributed": true,
"turns": attributed.events,
"tool_calls": attributed.tool_calls,
"attributed_input_tok": round2(attributed.input),
"attributed_output_tok": round2(attributed.output),
"attributed_cache_read_tok": round2(attributed.cache_read),
"attributed_cache_write_tok": round2(attributed.cache_write),
"attributed_cost_est": attributed.cost.json(),
"cost_partial": attributed.cost.is_partial(),
"cost_priced_events": attributed.cost.priced,
"cost_unpriced_events": attributed.cost.unpriced,
}));
}
let mut notes = scanned.notes;
notes.push(format!(
"attributed, not measured: no log records what a file cost. Each event's tokens are split \
evenly across the distinct files its tool calls touched (method: {METHOD}); --json carries \
\"method\": \"{METHOD}\" on every row"
));
notes.push(format!(
"{attributed_events} events touched a file and were attributed; {} events carried usage \
but touched no file (a plain answer, a shell command) and are in no row here — attributed \
totals therefore sum to less than the period's",
unattributed.requests
));
if total_files > LIMIT {
notes.push(format!(
"showing the {LIMIT} heaviest of {total_files} files, in the table and in --json alike"
));
}
Ok(Report::new("files", ctx.window, table)
.with_json_rows(rows)
.with_notes(notes.finish()))
}
fn file_target(target: Option<&str>) -> Option<&str> {
let target = target?.trim();
if target.is_empty() || target.starts_with("http://") || target.starts_with("https://") {
return None;
}
Some(target)
}
fn round2(value: f64) -> f64 {
(value * 100.0).round() / 100.0
}
#[cfg(test)]
mod tests {
use super::super::testkit::*;
use super::*;
use crate::cli::TimeWindow;
use crate::output::Style;
fn report(events: &[Event]) -> Report {
let (_dir, paths) = store(events);
build(
&Scanner::new(paths),
&ReportCtx::new(TimeWindow::all(), None, true),
)
.unwrap()
}
fn row<'a>(report: &'a Report, file: &str) -> &'a serde_json::Value {
report
.json_rows
.iter()
.find(|row| row["file"] == file)
.unwrap_or_else(|| panic!("no row for {file}"))
}
#[test]
fn a_turn_splits_evenly_across_the_files_it_touched() {
let report = report(&[with_tools(
priced(used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20), 1.0),
&[("Read", "src/a.rs"), ("Edit", "src/b.rs")],
)]);
for file in ["src/a.rs", "src/b.rs"] {
let row = row(&report, file);
assert_eq!(row["attributed_input_tok"], 50.0, "{file}");
assert_eq!(row["attributed_output_tok"], 10.0, "{file}");
assert_eq!(row["attributed_cost_est"], 0.5, "{file}");
assert_eq!(row["method"], METHOD);
}
}
#[test]
fn two_calls_on_one_file_are_one_share_not_two() {
let report = report(&[with_tools(
used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
&[("Read", "src/a.rs"), ("Edit", "src/a.rs")],
)]);
let row = row(&report, "src/a.rs");
assert_eq!(row["attributed_input_tok"], 100.0, "the whole turn, once");
assert_eq!(row["tool_calls"], 2);
assert_eq!(row["turns"], 1);
assert_eq!(report.json_rows.len(), 1);
}
#[test]
fn the_method_is_labelled_in_the_table_and_the_notes() {
let report = report(&[with_tools(
used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
&[("Read", "src/a.rs")],
)]);
let rendered = report.table.render(Style::plain());
assert!(rendered.contains("ATTRIB. IN"), "{rendered}");
assert!(
report
.notes
.iter()
.any(|n| n.contains("attributed, not measured") && n.contains(METHOD)),
"{:?}",
report.notes
);
}
#[test]
fn events_that_touched_no_file_are_excluded_and_the_shortfall_is_stated() {
let report = report(&[
with_tools(
used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
&[("Read", "src/a.rs")],
),
used("b", ms(2026, 8, 4, 9), "acme", "m", 900, 20),
]);
assert_eq!(report.json_rows.len(), 1);
assert!(
report
.notes
.iter()
.any(|n| n.contains("1 events carried usage but touched no file")),
"{:?}",
report.notes
);
}
#[test]
fn urls_are_not_files() {
let report = report(&[with_tools(
used("a", ms(2026, 8, 4, 8), "acme", "m", 100, 20),
&[("WebFetch", "https://example.com/x"), ("Read", "src/a.rs")],
)]);
assert_eq!(report.json_rows.len(), 1);
assert_eq!(
row(&report, "src/a.rs")["attributed_input_tok"],
100.0,
"the fetched page takes no share"
);
}
#[test]
fn an_unpriced_file_shows_a_dash_not_zero() {
let report = report(&[with_tools(
used("a", ms(2026, 8, 4, 8), "acme", "claude-opus-5", 100, 20),
&[("Read", "src/a.rs")],
)]);
assert!(row(&report, "src/a.rs")["attributed_cost_est"].is_null());
let rendered = report.table.render(Style::plain());
assert!(rendered.contains(crate::output::UNSUPPORTED), "{rendered}");
assert!(!rendered.contains("$0.00"), "{rendered}");
}
}