pub mod compare;
pub mod inventory;
pub mod optimize;
pub mod ship;
mod unused;
use crate::pricing::{Plan, Provider};
use crate::session::{Session, SessionData, ToolDetail};
use rayon::prelude::*;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Task {
Coding,
Debugging,
Testing,
Exploration,
Planning,
Delegation,
Git,
Build,
Conversation,
#[default]
General,
}
impl Task {
pub fn as_str(&self) -> &'static str {
match self {
Task::Coding => "coding",
Task::Debugging => "debugging",
Task::Testing => "testing",
Task::Exploration => "exploration",
Task::Planning => "planning",
Task::Delegation => "delegation",
Task::Git => "git",
Task::Build => "build",
Task::Conversation => "conversation",
Task::General => "general",
}
}
pub const ALL: [Task; 10] = [
Task::Coding,
Task::Debugging,
Task::Testing,
Task::Exploration,
Task::Planning,
Task::Delegation,
Task::Git,
Task::Build,
Task::Conversation,
Task::General,
];
}
const JUNK: [&str; 9] = [
"node_modules/",
"/.git/",
"/target/debug/",
"/target/release/",
"/dist/",
"/build/",
"/vendor/",
"/.venv/",
"__pycache__/",
];
fn is_junk(path: &str) -> bool {
let normalised = path.replace('\\', "/");
let padded = format!("/{normalised}");
JUNK.iter().any(|j| padded.contains(j))
}
const EDIT_TOOLS: [&str; 6] = [
"Edit",
"Write",
"MultiEdit",
"NotebookEdit",
"apply_patch",
"str_replace_editor",
];
const READ_TOOLS: [&str; 4] = ["Read", "NotebookRead", "read_file", "View"];
fn bash_writes(command: &str) -> bool {
let cmd = command.to_ascii_lowercase();
const WRITERS: [&str; 8] = [
"sed -i",
"sed --in-place",
"tee ",
"patch -p",
"install -m",
"mv ",
"cp ",
"rsync ",
];
if WRITERS.iter().any(|w| {
cmd.split([';', '|', '&'])
.any(|part| part.trim_start().starts_with(w) || part.contains(&format!(" {w}")))
}) {
return true;
}
let bytes = cmd.as_bytes();
for (i, _) in cmd.match_indices('>') {
if i > 0 && matches!(bytes[i - 1], b'>' | b'-' | b'=') {
continue;
}
let after = cmd[i..].trim_start_matches('>').trim_start();
if after.starts_with('&') || after.starts_with("/dev/null") || after.is_empty() {
continue;
}
let target = after.split_whitespace().next().unwrap_or("");
if target.contains('/') || target.contains('.') {
return true;
}
}
false
}
fn is_check(command: &str) -> bool {
const CHECKS: [&str; 20] = [
"cargo test",
"cargo nextest",
"cargo build",
"cargo check",
"cargo clippy",
"pytest",
"vitest",
"jest",
"go test",
"go build",
"go vet",
"npm test",
"npm run test",
"npm run build",
"pnpm test",
"yarn test",
"tsc",
"mypy",
"phpunit",
"make",
];
let cmd = command.to_ascii_lowercase();
cmd.split(['&', ';', '|']).any(|part| {
let mut rest = part.trim_start();
loop {
let word = rest.split_whitespace().next().unwrap_or("");
let wrapper = word.contains('=')
|| matches!(word, "env" | "nice" | "time" | "timeout" | "command")
|| word.starts_with(|c: char| c.is_ascii_digit());
if !wrapper {
break;
}
rest = rest[word.len()..].trim_start();
}
CHECKS.iter().any(|c| {
rest.strip_prefix(c)
.is_some_and(|after| after.is_empty() || after.starts_with(' '))
})
})
}
fn is_edit(tool: &str) -> bool {
EDIT_TOOLS.iter().any(|t| t.eq_ignore_ascii_case(tool))
}
fn is_shell(tool: &str) -> bool {
["Bash", "shell", "run_terminal_cmd", "execute_command"]
.iter()
.any(|t| t.eq_ignore_ascii_case(tool))
}
fn is_read(tool: &str) -> bool {
READ_TOOLS.iter().any(|t| t.eq_ignore_ascii_case(tool))
}
#[derive(Debug, Clone)]
pub struct Analysis {
pub provider: Provider,
pub label: String,
pub last_active: String,
pub model: String,
pub cost: f64,
pub cost_available: bool,
pub task: Task,
pub calls: u64,
pub errors: u64,
pub records_outcomes: bool,
pub edits: u64,
pub bash_writes: u64,
pub reads: u64,
pub files_edited: u64,
pub files_one_shot: u64,
pub read_paths: HashSet<String>,
pub junk_reads: u64,
pub junk_tokens: u64,
pub reread_tokens: u64,
pub rereads: u64,
pub cache_read: u64,
pub input_total: u64,
pub edited: HashMap<String, Edited>,
pub files_reworked: u64,
pub active_ms: u64,
pub slices: Vec<Slice>,
pub truncated: bool,
pub cwd: String,
pub loadout: crate::session::Loadout,
pub used_mcp: HashSet<String>,
pub used_skills: HashSet<String>,
pub used_agents: HashSet<String>,
pub cached_rate: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub struct Slice {
pub origin: Option<String>,
pub model: String,
pub cost: f64,
pub calls: u64,
pub edits: u64,
pub files_edited: u64,
pub files_one_shot: u64,
pub files_reworked: u64,
pub active_ms: u64,
pub ended_red: bool,
pub delegated: u64,
}
#[derive(Debug, Clone, Default)]
pub struct Edited {
pub first: String,
pub last: String,
pub after_failure: bool,
pub by: Option<String>,
}
fn timeline(data: &SessionData) -> Vec<(&str, &ToolDetail)> {
let mut all: Vec<(&str, &ToolDetail)> = data
.metrics
.tool_details
.iter()
.flat_map(|(name, list)| list.iter().map(move |d| (name.as_str(), d)))
.collect();
all.sort_by(|a, b| a.1.ts.cmp(&b.1.ts));
all
}
const IDLE_MS: i64 = 5 * 60 * 1000;
fn active_ms(timeline: &[(&str, &ToolDetail)]) -> u64 {
let stamps: Vec<(i64, i64)> = timeline
.iter()
.filter_map(|(_, d)| {
crate::util::parse_ts(&d.ts)
.map(|t| (t.timestamp_millis(), d.dur_ms.unwrap_or(0).max(0)))
})
.collect();
let mut total: i64 = stamps.last().map_or(0, |&(_, dur)| dur.min(IDLE_MS));
for w in stamps.windows(2) {
let ((at, dur), (next, _)) = (w[0], w[1]);
let gap = (next - at).max(0);
total += if gap <= IDLE_MS { gap } else { dur.min(gap) };
}
total.max(0) as u64
}
fn dominant_model(data: &SessionData) -> String {
data.model_breakdown
.iter()
.max_by(|a, b| {
a.total
.partial_cmp(&b.total)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|m| m.model.clone())
.filter(|m| !m.is_empty())
.unwrap_or_else(|| data.last_model.clone())
}
fn classify(data: &SessionData, timeline: &[(&str, &ToolDetail)]) -> Task {
if timeline.is_empty() && data.metrics.tool_count == 0 {
return Task::Conversation;
}
if !data.subagents.is_empty() {
return Task::Delegation;
}
let mut edits = 0u64;
let mut reads = 0u64;
let mut test_cmds = 0u64;
let mut git_cmds = 0u64;
let mut build_cmds = 0u64;
let mut plan_calls = 0u64;
for (tool, detail) in timeline {
if is_edit(tool) {
edits += 1;
} else if is_read(tool) || tool.eq_ignore_ascii_case("Grep") {
reads += 1;
} else if tool.to_ascii_lowercase().contains("plan") {
plan_calls += 1;
} else if is_shell(tool) {
let command = detail.full.as_deref().unwrap_or(&detail.d);
if bash_writes(command) {
edits += 1;
}
let cmd = command.to_ascii_lowercase();
if [
"pytest",
"vitest",
"jest",
"cargo test",
"go test",
"npm test",
"phpunit",
]
.iter()
.any(|t| cmd.contains(t))
{
test_cmds += 1;
} else if cmd.starts_with("git ") || cmd.contains("&& git ") {
git_cmds += 1;
} else if ["docker", "npm run build", "cargo build", "make ", "pm2 "]
.iter()
.any(|t| cmd.contains(t))
{
build_cmds += 1;
}
}
}
if edits > 0 {
let title = data.title.as_deref().unwrap_or("").to_ascii_lowercase();
if ["fix", "bug", "error", "broken", "fails", "debug"]
.iter()
.any(|k| title.contains(k))
{
return Task::Debugging;
}
return Task::Coding;
}
if test_cmds > 0 {
return Task::Testing;
}
if plan_calls > 0 {
return Task::Planning;
}
if git_cmds > 0 && git_cmds >= build_cmds {
return Task::Git;
}
if build_cmds > 0 {
return Task::Build;
}
if reads > 0 {
return Task::Exploration;
}
Task::General
}
impl Analysis {
pub fn agent_slices(&self) -> Vec<Slice> {
if !self.slices.is_empty() {
return self.slices.clone();
}
vec![Slice {
origin: None,
model: self.model.clone(),
cost: self.cost,
calls: self.calls,
edits: self.edits,
files_edited: self.files_edited,
files_one_shot: self.files_one_shot,
files_reworked: self.files_reworked,
active_ms: self.active_ms,
ended_red: false,
delegated: 0,
}]
}
pub fn wrote(&self) -> u64 {
self.edits + self.bash_writes
}
}
fn input_split(provider: Provider, data: &SessionData) -> (u64, u64) {
let t = &data.tokens;
match provider {
Provider::Codex if t.input_total > 0 => (t.cached_input, t.input_total),
_ => (
t.cache_read,
t.input + t.cache_read + t.cache_write_5m + t.cache_write_1h,
),
}
}
fn used_mcp(data: &SessionData) -> HashSet<String> {
data.metrics
.mcp_tools
.iter()
.filter_map(|t| crate::session::mcp_server_of(t))
.map(str::to_string)
.chain(data.loadout.mcp_resources.iter().cloned())
.collect()
}
fn used_skills(data: &SessionData) -> HashSet<String> {
data.metrics
.skills
.keys()
.chain(&data.loadout.skills_invoked)
.map(|s| s.trim_start_matches('/').to_string())
.collect()
}
fn cached_rate(session: &Session, data: &SessionData) -> Option<f64> {
let (t, c) = (&data.tokens, &data.costs);
(session.cost_available && t.cache_read > 0 && c.cache_read > 0.0)
.then(|| c.cache_read / t.cache_read as f64)
}
pub fn analyse(session: &Session, data: &SessionData) -> Analysis {
let timeline = timeline(data);
let task = classify(data, &timeline);
let (cached, billed_in) = input_split(session.provider, data);
let mut out = Analysis {
provider: session.provider,
label: session.abbrev_label.clone(),
last_active: session.last_active.clone(),
model: dominant_model(data),
cost: data.costs.total,
cost_available: session.cost_available,
task,
calls: data.metrics.tool_count,
errors: data.metrics.tool_errors,
records_outcomes: session.provider.records_tool_outcomes(),
cache_read: cached,
input_total: billed_in,
active_ms: active_ms(&timeline),
slices: Vec::new(),
edited: HashMap::new(),
files_reworked: 0,
edits: 0,
bash_writes: 0,
reads: 0,
files_edited: 0,
files_one_shot: 0,
read_paths: HashSet::new(),
junk_reads: 0,
junk_tokens: 0,
reread_tokens: 0,
rereads: 0,
truncated: false,
cwd: session.label_source.clone(),
loadout: data.loadout.clone(),
used_mcp: used_mcp(data),
used_skills: used_skills(data),
used_agents: data
.subagents
.iter()
.map(|s| s.agent_type.clone())
.filter(|t| !t.is_empty())
.collect(),
cached_rate: cached_rate(session, data),
};
out.truncated = data
.metrics
.tool_details
.values()
.any(|l| l.len() >= crate::config::MAX_TOOL_DETAILS);
let mut seen_reads: HashSet<&str> = HashSet::new();
let mut edit_order: HashMap<String, Vec<usize>> = HashMap::new();
let mut shell_failed: HashMap<&Option<String>, bool> = HashMap::new();
for (i, (tool, detail)) in timeline.iter().enumerate() {
let path = detail.d.as_str();
if is_edit(tool) {
out.edits += 1;
for file in edited_files(detail) {
let file = crate::collide::normalise(file, &session.label_source);
let after_failure = shell_failed.get(&detail.origin) == Some(&true);
let span = out
.edited
.entry(checkout_path(&file))
.or_insert_with(|| Edited {
first: detail.ts.clone(),
last: String::new(),
after_failure,
by: None,
});
span.last.clone_from(&detail.ts);
span.by.clone_from(&detail.origin);
edit_order.entry(file).or_default().push(i);
}
} else if is_shell(tool) {
shell_failed.insert(&detail.origin, detail.failed);
let command = detail.full.as_deref().unwrap_or(path);
if bash_writes(command) {
out.bash_writes += 1;
}
} else if is_read(tool) {
out.reads += 1;
let growth = detail.window_growth.unwrap_or(0);
if is_junk(path) {
out.junk_reads += 1;
out.junk_tokens += growth;
}
if !seen_reads.insert(path) {
out.rereads += 1;
out.reread_tokens += growth;
}
out.read_paths.insert(path.to_string());
}
}
for positions in edit_order.values() {
out.files_edited += 1;
let retried = positions
.windows(2)
.any(|w| is_retry(&timeline, w[0], w[1], out.records_outcomes));
if !retried {
out.files_one_shot += 1;
}
}
out.slices = slices(session, data, &timeline, out.records_outcomes);
out
}
fn slices(
session: &Session,
data: &SessionData,
timeline: &[(&str, &ToolDetail)],
outcomes: bool,
) -> Vec<Slice> {
let sub_cost: f64 = data.subagents.iter().map(|s| s.cost).sum();
let mut out = vec![Slice {
origin: None,
model: main_model(data),
cost: (data.costs.total - sub_cost).max(0.0),
..Default::default()
}];
for sub in &data.subagents {
let model = match sub.model.as_str() {
"" | "?" => out[0].model.clone(),
m => m.to_string(),
};
out.push(Slice {
origin: Some(sub.agent_id.clone()),
model,
cost: sub.cost,
..Default::default()
});
}
let mut files: Vec<HashSet<String>> = vec![HashSet::new(); out.len()];
for (k, slice) in out.iter_mut().enumerate() {
let own: Vec<(&str, &ToolDetail)> = timeline
.iter()
.copied()
.filter(|(_, d)| d.origin == slice.origin)
.collect();
slice.calls = own.len() as u64;
slice.active_ms = match slice.origin {
None => active_ms(timeline),
Some(_) => active_ms(&own),
};
let mut order: HashMap<String, Vec<usize>> = HashMap::new();
for (i, (tool, detail)) in own.iter().enumerate() {
if is_edit(tool) {
slice.edits += 1;
for file in edited_files(detail) {
let file =
checkout_path(&crate::collide::normalise(file, &session.label_source));
order.entry(file).or_default().push(i);
}
}
}
if outcomes && let Some(last_edit) = own.iter().rposition(|(t, _)| is_edit(t)) {
slice.ended_red = own[last_edit + 1..]
.iter()
.rfind(|(tool, d)| is_shell(tool) && is_check(d.full.as_deref().unwrap_or(&d.d)))
.is_some_and(|(_, d)| d.failed);
}
for (file, positions) in order {
slice.files_edited += 1;
if !positions
.windows(2)
.any(|w| is_retry(&own, w[0], w[1], outcomes))
{
slice.files_one_shot += 1;
}
files[k].insert(file);
}
}
let main_model = out[0].model.clone();
let delegated: HashSet<&String> = out
.iter()
.zip(&files)
.skip(1)
.filter(|(slice, _)| slice.model != main_model)
.flat_map(|(_, f)| f)
.filter(|f| !files[0].contains(*f))
.collect();
out[0].delegated = delegated.len() as u64;
let mut k = 0;
out.retain(|s| {
k += 1;
k == 1 || s.calls > 0 || s.cost > 0.0
});
out
}
fn main_model(data: &SessionData) -> String {
let mut by_model: HashMap<&str, f64> = data
.model_breakdown
.iter()
.map(|m| (m.model.as_str(), m.total))
.collect();
for sub in &data.subagents {
if let Some(c) = by_model.get_mut(sub.model.as_str()) {
*c -= sub.cost;
}
}
by_model
.into_iter()
.filter(|(m, c)| !m.is_empty() && *c > 1e-9)
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|(m, _)| m.to_string())
.unwrap_or_else(|| dominant_model(data))
}
fn edited_files(detail: &ToolDetail) -> Vec<&str> {
let files: Vec<&str> = if detail.paths.is_empty() {
vec![detail.d.trim()]
} else {
detail.paths.iter().map(|p| p.trim()).collect()
};
let mut out: Vec<&str> = Vec::with_capacity(files.len());
for f in files {
if !f.is_empty() && !out.contains(&f) {
out.push(f);
}
}
out
}
fn is_retry(timeline: &[(&str, &ToolDetail)], prev: usize, next: usize, outcomes: bool) -> bool {
let (_, first) = timeline[prev];
let origin = &timeline[next].1.origin;
let between = timeline[(prev + 1).min(next)..next]
.iter()
.filter(|(_, d)| &d.origin == origin);
if !outcomes {
return between.count() > 0;
}
first.failed
|| between
.filter(|(_, d)| d.failed)
.any(|(tool, _)| is_shell(tool) || is_edit(tool))
}
fn checkout_path(path: &str) -> String {
const MARK: &str = "/.claude/worktrees/";
match path.find(MARK) {
Some(at) => {
let rest = &path[at + MARK.len()..];
match rest.find('/') {
Some(slash) => format!("{}{}", &path[..at], &rest[slash..]),
None => path.to_string(),
}
}
None => path.to_string(),
}
}
const REWORK_WINDOW_SECS: i64 = 24 * 60 * 60;
type Writer<'a> = (usize, Option<&'a str>, i64);
pub fn mark_rework(analyses: &mut [Analysis]) {
use crate::util::parse_ts;
let mut writers: HashMap<&str, Vec<Writer>> = HashMap::new();
for (i, a) in analyses.iter().enumerate() {
for (path, e) in &a.edited {
if let Some(t) = parse_ts(&e.last) {
writers
.entry(path)
.or_default()
.push((i, e.by.as_deref(), t.timestamp()));
}
}
}
let mut charged: HashSet<(usize, &str, Option<&str>)> = HashSet::new();
for (j, fix) in analyses.iter().enumerate() {
let debugging = fix.task == Task::Debugging;
for (path, e) in &fix.edited {
if !debugging && !e.after_failure {
continue;
}
let Some(at) = parse_ts(&e.first).map(|t| t.timestamp()) else {
continue;
};
let blamed = writers.get(path.as_str()).and_then(|ws| {
ws.iter()
.filter(|&&(i, _, t)| i != j && t <= at && at - t <= REWORK_WINDOW_SECS)
.max_by_key(|&&(_, _, t)| t)
});
if let Some(&(i, by, _)) = blamed {
charged.insert((i, path.as_str(), by));
}
}
}
let charges: Vec<(usize, Option<String>)> = charged
.into_iter()
.map(|(i, _, by)| (i, by.map(str::to_string)))
.collect();
for a in analyses.iter_mut() {
a.files_reworked = 0;
for s in &mut a.slices {
s.files_reworked = 0;
}
}
for (i, by) in charges {
let a = &mut analyses[i];
a.files_reworked += 1;
let at = a.slices.iter().position(|s| s.origin == by).unwrap_or(0);
if let Some(s) = a.slices.get_mut(at) {
s.files_reworked += 1;
}
}
}
pub fn scan(plan: Plan) -> Vec<Analysis> {
let mut loader = crate::loader::Loader::new();
let walked = loader.load(plan);
from_store(&walked, loader.store())
}
pub fn from_store(sessions: &[Session], store: &crate::cache::Store) -> Vec<Analysis> {
let mut analyses: Vec<Analysis> = sessions
.par_iter()
.map(|s| {
let data = store.session_data_fresh(s);
analyse(s, &data)
})
.collect();
mark_rework(&mut analyses);
analyses
}
pub fn substantive(a: &Analysis) -> bool {
a.calls > 0
}
pub fn only(analyses: &[Analysis], provider: Option<Provider>) -> Vec<&Analysis> {
since(analyses, provider, None)
}
pub fn since(
analyses: &[Analysis],
provider: Option<Provider>,
cutoff: Option<chrono::DateTime<chrono::Utc>>,
) -> Vec<&Analysis> {
analyses
.iter()
.filter(|a| provider.is_none_or(|p| a.provider == p))
.filter(|a| {
cutoff.is_none_or(|c| crate::util::parse_ts(&a.last_active).is_none_or(|t| t >= c))
})
.collect()
}
fn parse_since(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let now = chrono::Utc::now();
if let Ok(day) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") {
return day.and_hms_opt(0, 0, 0).map(|t| t.and_utc());
}
let unit = value.chars().last()?;
let n: i64 = value[..value.len() - unit.len_utf8()].parse().ok()?;
let span = match unit {
'm' => chrono::Duration::minutes(n),
'h' => chrono::Duration::hours(n),
'd' => chrono::Duration::days(n),
'w' => chrono::Duration::weeks(n),
_ => return None,
};
(n > 0).then(|| now - span)
}
pub const HELP: &str = "\
cctop optimize — what your sessions spent and did not get back
cctop compare — how each model behaved on the work you gave it
USAGE:
cctop optimize [--provider NAME] [--since SPAN] [--json]
cctop compare [--provider NAME] [--since SPAN] [--rate USD_PER_HOUR] [--json]
Both re-read every transcript rather than using the session cache, because the
individual tool calls are the thing they reason about and those are never
cached. Expect them to take a few seconds on a large machine.
OPTIONS:
--provider NAME Only this harness: {PROVIDERS}
--since SPAN Only sessions active in the last SPAN — 24h, 7d, 2w — or
since a date, 2026-09-01. Models change, and so does what
you give them; last month's sessions blur this week's.
--rate USD compare only: what an hour of agent time is worth to you.
Adds a column of dollars plus time per file, and ranks by
it — a free model that takes a day can lose to a $10 one
that takes five minutes.
--json Machine-readable, for scripting.
-h, --help This.
Both read and print. Neither writes anything, to your configuration or
anywhere else.
";
pub fn help() -> String {
HELP.replace("{PROVIDERS}", &provider_names())
}
fn provider_names() -> String {
const INDENT: &str = " ";
const WIDTH: usize = 76 - INDENT.len();
let mut out = String::new();
let mut line = String::new();
for p in Provider::ALL {
if !line.is_empty() && line.len() + 2 + p.as_str().len() > WIDTH {
out.push_str(&line);
out.push_str(",\n");
out.push_str(INDENT);
line.clear();
}
if !line.is_empty() {
line.push_str(", ");
}
line.push_str(p.as_str());
}
out.push_str(&line);
out.push('.');
out
}
pub fn run(which: &str, argv: &[String]) -> i32 {
if argv.iter().any(|a| a == "-h" || a == "--help") {
match which {
"yield" => print!("{}", ship::HELP),
_ => print!("{}", help()),
}
return 0;
}
let mut provider = None;
let mut cutoff = None;
let mut rate = None;
let mut json = false;
let mut args = argv.iter();
while let Some(a) = args.next() {
match a.as_str() {
"--json" => json = true,
"--provider" => match args.next().and_then(|p| provider_named(p)) {
Some(p) => provider = Some(p),
None => {
eprintln!("cctop {which}: --provider needs a harness name; see --help");
return 2;
}
},
"--since" => match args.next().and_then(|v| parse_since(v)) {
Some(c) => cutoff = Some(c),
None => {
eprintln!(
"cctop {which}: --since needs a span or a date, e.g. 7d or 2026-09-01"
);
return 2;
}
},
"--rate" if which == "compare" => {
match args
.next()
.and_then(|r| r.trim_start_matches('$').parse::<f64>().ok())
{
Some(r) if r.is_finite() && r >= 0.0 => rate = Some(r),
_ => {
eprintln!("cctop compare: --rate needs dollars per hour, e.g. --rate 60");
return 2;
}
}
}
other => {
eprintln!("cctop {which}: unexpected argument `{other}`; see --help");
return 2;
}
}
}
crate::pricing::refresh_pricing_blocking();
let analyses = scan(Plan::Retail);
let selected = since(&analyses, provider, cutoff);
match (which, json) {
("yield", _) => {
let report = ship::build(&analyses, &selected, chrono::Utc::now().timestamp());
match json {
true => println!("{}", ship::as_json(&report)),
false => print!("{}", ship::report(&report)),
}
}
("optimize", false) => print!("{}", optimize::report(&selected)),
("compare", false) => print!("{}", compare::report_at(&selected, rate)),
("optimize", true) => println!("{}", optimize::as_json(&selected)),
(_, true) => println!("{}", compare::as_json(&selected, rate)),
_ => unreachable!("only optimize, compare and yield reach here"),
}
0
}
pub fn plural(n: usize, noun: &str) -> String {
match n {
1 => format!("1 {noun}"),
_ => format!("{n} {noun}s"),
}
}
pub(crate) fn provider_named(name: &str) -> Option<Provider> {
Provider::parse(&name.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{Metrics, ToolDetail};
fn call(name: &str, arg: &str, ts: &str) -> (String, ToolDetail) {
(
name.to_string(),
ToolDetail {
d: arg.to_string(),
ts: ts.to_string(),
..Default::default()
},
)
}
fn data_of(calls: &[(String, ToolDetail)]) -> SessionData {
let mut details: HashMap<String, Vec<ToolDetail>> = HashMap::new();
for (name, d) in calls {
details.entry(name.clone()).or_default().push(d.clone());
}
SessionData {
metrics: Metrics {
tool_count: calls.len() as u64,
tool_details: details,
..Default::default()
},
..Default::default()
}
}
fn analysed(provider: Provider, calls: &[(String, ToolDetail)]) -> Analysis {
let mut s = Session::new(provider, "sid".into());
s.cost_available = true;
analyse(&s, &data_of(calls))
}
fn failing(name: &str, arg: &str, ts: &str) -> (String, ToolDetail) {
let (n, mut d) = call(name, arg, ts);
d.failed = true;
(n, d)
}
#[test]
fn a_retry_is_the_same_file_edited_after_something_failed() {
let retried = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
failing("Bash", "cargo test", "02"),
call("Edit", "/a.rs", "03"),
],
);
assert_eq!(retried.files_edited, 1);
assert_eq!(retried.files_one_shot, 0, "a failure, then the same file");
let next_step = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
call("Bash", "cargo test", "02"),
call("Edit", "/a.rs", "03"),
],
);
assert_eq!(next_step.files_one_shot, 1, "the tests passed in between");
let failed_edit = analysed(
Provider::Claude,
&[failing("Edit", "/a.rs", "01"), call("Edit", "/a.rs", "02")],
);
assert_eq!(
failed_edit.files_one_shot, 0,
"the first edit itself failed"
);
let progress = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
failing("Bash", "cargo test", "02"),
call("Edit", "/b.rs", "03"),
],
);
assert_eq!(progress.files_edited, 2);
assert_eq!(
progress.files_one_shot, 2,
"two different files is two first attempts, not a retry"
);
}
#[test]
fn without_outcomes_any_call_between_is_a_retry() {
let calls = [
call("Edit", "/a.rs", "01"),
call("Bash", "cargo test", "02"),
call("Edit", "/a.rs", "03"),
];
assert_eq!(analysed(Provider::Cursor, &calls).files_one_shot, 0);
let burst = [call("Edit", "/a.rs", "01"), call("Edit", "/a.rs", "02")];
assert_eq!(analysed(Provider::Cursor, &burst).files_one_shot, 1);
}
#[test]
fn another_agents_failure_is_not_a_retry() {
let mut theirs = failing("Bash", "cargo test", "02");
theirs.1.origin = Some("sub".into());
let a = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
theirs,
call("Edit", "/a.rs", "03"),
],
);
assert_eq!(a.files_one_shot, 1);
}
#[test]
fn a_patch_counts_every_file_it_touched() {
let (n, mut d) = call("apply_patch", "a.rs (+2 more)", "01");
d.paths = vec!["a.rs".into(), "b.rs".into(), "a.rs".into(), "c.rs".into()];
let a = analysed(Provider::Codex, &[(n, d)]);
assert_eq!(a.files_edited, 3);
assert_eq!(a.files_one_shot, 3);
}
fn edited_at(task: Task, files: &[(&str, &str)]) -> Analysis {
let mut a = analysed(Provider::Claude, &[]);
a.task = task;
for (path, ts) in files {
a.edited.insert(
path.to_string(),
Edited {
first: ts.to_string(),
last: ts.to_string(),
after_failure: false,
by: None,
},
);
}
a
}
fn delegating_session() -> Analysis {
let sub_call = |name: &str, arg: &str, ts: &str, failed: bool| {
let (n, mut d) = call(name, arg, ts);
d.origin = Some("agent-1".into());
d.failed = failed;
(n, d)
};
let calls = vec![
call("Edit", "/p.rs", "2026-01-01T10:00:00Z"),
call("Task", "go", "2026-01-01T10:01:00Z"),
sub_call("Edit", "/a.rs", "2026-01-01T10:02:00Z", false),
sub_call("Bash", "cargo test", "2026-01-01T10:03:00Z", true),
sub_call("Edit", "/a.rs", "2026-01-01T10:04:00Z", false),
sub_call("Edit", "/b.rs", "2026-01-01T10:05:00Z", false),
call("Edit", "/b.rs", "2026-01-01T10:06:00Z"),
];
let mut data = data_of(&calls);
let breakdown = |model: &str, total: f64| crate::session::ModelBreakdown {
model: model.into(),
tokens: Default::default(),
costs: Default::default(),
total,
};
data.model_breakdown = vec![breakdown("big", 8.0), breakdown("small", 3.0)];
data.costs.total = 11.0;
data.subagents = vec![crate::session::Subagent {
agent_id: "agent-1".into(),
agent_type: "general-purpose".into(),
description: String::new(),
model: "small".into(),
started_at: None,
last_active: None,
duration_ms: 0,
status: crate::session::SubagentStatus::Done,
cost: 3.0,
tool_count: 4,
tool_use_id: None,
context: None,
ghost: false,
}];
let mut s = Session::new(Provider::Claude, "sid".into());
s.cost_available = true;
analyse(&s, &data)
}
#[test]
fn a_subagent_is_credited_to_its_own_model() {
let a = delegating_session();
assert_eq!(a.slices.len(), 2);
let (main, sub) = (&a.slices[0], &a.slices[1]);
assert_eq!(main.model, "big");
assert_eq!(main.cost, 8.0, "the session's cost less the subagent's");
assert_eq!((main.files_edited, main.files_one_shot), (2, 2));
assert_eq!(main.delegated, 1, "a.rs; b.rs the parent wrote itself");
assert_eq!(main.calls, 3);
assert_eq!(sub.model, "small");
assert_eq!(sub.cost, 3.0);
assert_eq!(sub.files_edited, 2);
assert_eq!(
sub.files_one_shot, 1,
"a.rs was retried after its test failed"
);
assert_eq!(sub.active_ms, 3 * 60_000);
}
#[test]
fn a_subagent_on_the_parents_model_is_not_delegation() {
let mut a = delegating_session();
for s in &mut a.slices {
s.model = "big".into();
}
a.slices[0].delegated = 0;
let table = compare::rows(&[&a], None);
assert_eq!(table.len(), 1);
assert_eq!(table[0].files_delegated, 0);
assert_eq!(
table[0].files_edited, 4,
"p, b by the parent; a, b by the subagent"
);
assert_eq!(table[0].sessions, 1, "one session, however many agents");
assert_eq!(
table[0].active_ms, a.slices[0].active_ms,
"the parent's time already covers its subagent's"
);
}
#[test]
fn the_parent_spreads_its_cost_over_what_it_delegated() {
let a = delegating_session();
let table = compare::rows(&[&a], None);
let big = table.iter().find(|r| r.model == "big").unwrap();
let small = table.iter().find(|r| r.model == "small").unwrap();
assert_eq!(big.files_produced(), 3);
assert_eq!(big.per_edit(), Some(8.0 / 3.0));
assert_eq!(small.per_edit(), Some(3.0 / 2.0));
assert_eq!(big.one_shot(), None, "two files of its own is not a rate");
}
#[test]
fn an_agent_that_stopped_on_a_failing_check_ended_red() {
let red = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
call("Bash", "cargo test", "02"),
call("Edit", "/a.rs", "03"),
failing("Bash", "cargo test 2>&1 | tail -5", "04"),
failing("Bash", "grep -n nothing src/a.rs", "05"),
],
);
assert!(red.slices[0].ended_red);
let fixed = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
failing("Bash", "cargo test", "02"),
call("Bash", "cargo test", "03"),
],
);
assert!(!fixed.slices[0].ended_red, "the last check passed");
let unchecked = analysed(
Provider::Claude,
&[
failing("Bash", "cargo test", "01"),
call("Edit", "/a.rs", "02"),
],
);
assert!(!unchecked.slices[0].ended_red, "nothing ran after the edit");
assert!(is_check("cd x && cargo clippy --all-targets"));
assert!(is_check("make"));
assert!(is_check("RUST_LOG=debug timeout 60 cargo test -q"));
assert!(!is_check("makefile-lint"), "a check is a whole word");
assert!(!is_check("grep -n cargo test src/a.rs | head"));
assert!(!is_check("git diff --quiet"));
}
#[test]
fn since_takes_a_span_or_a_date() {
let now = chrono::Utc::now();
let week = parse_since("7d").unwrap();
assert!((now - week - chrono::Duration::days(7)).num_seconds().abs() < 5);
assert_eq!(
parse_since("2026-09-01").unwrap().to_rfc3339(),
"2026-09-01T00:00:00+00:00"
);
for bad in ["", "d", "7", "7y", "-3d", "0d", "yesterday"] {
assert!(parse_since(bad).is_none(), "{bad}");
}
let mut old = analysed(Provider::Claude, &[]);
old.last_active = "2020-01-01T00:00:00Z".into();
let mut undated = analysed(Provider::Claude, &[]);
undated.last_active = String::new();
let set = [old, undated];
let kept = since(&set, None, Some(week));
assert_eq!(kept.len(), 1, "the old one goes, the undated one stays");
assert!(kept[0].last_active.is_empty());
}
#[test]
fn a_worktree_path_folds_into_its_checkout() {
assert_eq!(
checkout_path("/r/cctop/.claude/worktrees/agent-9/src/ui/mod.rs"),
"/r/cctop/src/ui/mod.rs"
);
assert_eq!(
checkout_path("/r/cctop/src/ui/mod.rs"),
"/r/cctop/src/ui/mod.rs"
);
assert_eq!(
checkout_path("/r/cctop/.claude/worktrees/agent-9"),
"/r/cctop/.claude/worktrees/agent-9"
);
}
#[test]
fn an_edit_after_a_failure_is_a_fix_without_a_title() {
let fix = analysed(
Provider::Claude,
&[
call("Bash", "cargo test", "2026-01-01T12:00:00Z"),
failing("Bash", "cargo test", "2026-01-01T12:01:00Z"),
call("Edit", "/a.rs", "2026-01-01T12:02:00Z"),
call("Bash", "cargo test", "2026-01-01T12:03:00Z"),
call("Edit", "/b.rs", "2026-01-01T12:04:00Z"),
],
);
assert_eq!(fix.task, Task::Coding, "no title says it was a fix");
assert!(fix.edited["/a.rs"].after_failure);
assert!(!fix.edited["/b.rs"].after_failure, "the tests passed first");
let mut set = vec![
edited_at(
Task::Coding,
&[
("/a.rs", "2026-01-01T09:00:00Z"),
("/b.rs", "2026-01-01T09:00:00Z"),
],
),
fix,
];
mark_rework(&mut set);
assert_eq!(
set[0].files_reworked, 1,
"a.rs was fixed, b.rs was just changed"
);
}
#[test]
fn rework_is_charged_to_the_last_writer_before_a_fix() {
let mut set = vec![
edited_at(Task::Coding, &[("/a.rs", "2026-01-01T09:00:00Z")]),
edited_at(Task::Coding, &[("/a.rs", "2026-01-01T10:00:00Z")]),
edited_at(Task::Coding, &[("/b.rs", "2026-01-01T10:00:00Z")]),
edited_at(
Task::Debugging,
&[
("/a.rs", "2026-01-01T12:00:00Z"),
("/c.rs", "2026-01-01T12:00:00Z"),
],
),
edited_at(Task::Debugging, &[("/a.rs", "2026-01-01T13:00:00Z")]),
edited_at(Task::Debugging, &[("/b.rs", "2026-01-03T12:00:00Z")]),
];
mark_rework(&mut set);
let charged: Vec<u64> = set.iter().map(|a| a.files_reworked).collect();
assert_eq!(charged, [0, 1, 0, 1, 0, 0]);
}
#[test]
fn cached_input_is_not_counted_twice_for_codex() {
let codex = SessionData {
tokens: crate::session::Tokens {
input_total: 1000,
cached_input: 900,
..Default::default()
},
..Default::default()
};
assert_eq!(input_split(Provider::Codex, &codex), (900, 1000));
let claude = SessionData {
tokens: crate::session::Tokens {
input: 100,
cache_read: 900,
..Default::default()
},
..Default::default()
};
assert_eq!(input_split(Provider::Claude, &claude), (900, 1000));
}
#[test]
fn a_session_that_edits_and_tests_is_coding() {
let calls = vec![
call("Edit", "/a.rs", "01"),
call("Bash", "cargo test", "02"),
call("Bash", "cargo test", "03"),
call("Bash", "cargo test", "04"),
];
let data = data_of(&calls);
assert_eq!(classify(&data, &timeline(&data)), Task::Coding);
let only_running = vec![call("Bash", "cargo test", "01")];
let data = data_of(&only_running);
assert_eq!(classify(&data, &timeline(&data)), Task::Testing);
}
#[test]
fn a_shell_that_writes_a_file_is_editing() {
for writing in [
"sed -i 's/a/b/' src/main.rs",
"cat > /tmp/x.py <<'EOF'",
"cargo build 2>&1 > build.log",
"echo hi >> notes.md",
"grep -r foo . | tee results.txt",
"mv old.rs new.rs",
"cp a.toml b.toml",
] {
assert!(bash_writes(writing), "should count as a write: {writing}");
}
for reading in [
"cargo test 2>&1 | tail -3",
"grep -n foo src/main.rs | head -20",
"ls -la >/dev/null 2>&1",
"awk '{print $1}' file | sort | uniq -c",
"git status --short",
"if [ $a > $b ]; then echo yes; fi",
] {
assert!(
!bash_writes(reading),
"should not count as a write: {reading}"
);
}
}
#[test]
fn shell_writes_count_as_having_changed_something() {
let shell_only = analysed(
Provider::Claude,
&[
call("Bash", "sed -i 's/a/b/' src/main.rs", "01"),
call("Bash", "cargo test", "02"),
],
);
assert_eq!(shell_only.edits, 0, "no edit tool was used");
assert_eq!(shell_only.bash_writes, 1);
assert_eq!(shell_only.wrote(), 1, "but something was written");
assert_eq!(
shell_only.task,
Task::Coding,
"and that makes it coding, not testing"
);
assert_eq!(shell_only.files_edited, 0);
}
#[test]
fn idle_time_is_not_agent_time() {
let at = |name: &str, ts: &str, dur: Option<i64>| {
let (n, mut d) = call(name, "x", ts);
d.dur_ms = dur;
(n, d)
};
let calls = [
at("Read", "2026-01-01T10:00:00Z", None),
at("Edit", "2026-01-01T10:00:30Z", None),
at("Bash", "2026-01-01T22:00:00Z", Some(600_000)),
at("Edit", "2026-01-01T22:10:00Z", None),
];
let data = data_of(&calls);
assert_eq!(active_ms(&timeline(&data)), 30_000 + 600_000);
}
#[test]
fn junk_is_recognised_with_either_separator() {
assert!(is_junk("node_modules/react/index.js"));
assert!(is_junk(r"C:\repo\node_modules\react\index.js"));
assert!(is_junk("/home/x/repo/.git/config"));
assert!(!is_junk("/home/x/repo/src/target_picker.rs"));
assert!(!is_junk("/home/x/dist_report.md"));
}
}