pub mod compare;
pub mod optimize;
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 is_edit(tool: &str) -> bool {
EDIT_TOOLS.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 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 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 truncated: bool,
}
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
}
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 tool.eq_ignore_ascii_case("Bash") || tool.eq_ignore_ascii_case("shell") {
let cmd = detail.d.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
}
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,
),
}
}
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(),
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,
edits: 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,
};
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<&str, Vec<usize>> = HashMap::new();
for (i, (tool, detail)) in timeline.iter().enumerate() {
let path = detail.d.as_str();
if is_edit(tool) {
out.edits += 1;
edit_order.entry(path).or_default().push(i);
} 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 contiguous = positions.windows(2).all(|w| w[1] == w[0] + 1);
if contiguous {
out.files_one_shot += 1;
}
}
out
}
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> {
sessions
.par_iter()
.map(|s| {
let data = store.session_data_fresh(s);
analyse(s, &data)
})
.collect()
}
pub fn substantive(a: &Analysis) -> bool {
a.calls > 0
}
pub fn only(analyses: &[Analysis], provider: Option<Provider>) -> Vec<&Analysis> {
analyses
.iter()
.filter(|a| provider.is_none_or(|p| a.provider == p))
.collect()
}
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] [--json]
cctop compare [--provider NAME] [--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: claude, codex, cursor, gemini, opencode,
pi, windsurf.
--json Machine-readable, for scripting.
-h, --help This.
Both read and print. Neither writes anything, to your configuration or
anywhere else.
";
pub fn run(which: &str, argv: &[String]) -> i32 {
if argv.iter().any(|a| a == "-h" || a == "--help") {
print!("{HELP}");
return 0;
}
let mut provider = 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;
}
},
other => {
eprintln!("cctop {which}: unexpected argument `{other}`; see --help");
return 2;
}
}
}
let analyses = scan(Plan::Retail);
let selected = only(&analyses, provider);
match (which, json) {
("optimize", false) => print!("{}", optimize::report(&selected)),
("compare", false) => print!("{}", compare::report(&selected)),
("optimize", true) => println!("{}", optimize::as_json(&selected)),
(_, true) => println!("{}", compare::as_json(&selected)),
_ => unreachable!("only optimize and compare reach here"),
}
0
}
pub fn plural(n: usize, noun: &str) -> String {
match n {
1 => format!("1 {noun}"),
_ => format!("{n} {noun}s"),
}
}
fn provider_named(name: &str) -> Option<Provider> {
let name = name.to_ascii_lowercase();
[
Provider::Claude,
Provider::Codex,
Provider::Cursor,
Provider::Gemini,
Provider::OpenCode,
Provider::Pi,
Provider::Windsurf,
]
.into_iter()
.find(|p| p.as_str() == name)
}
#[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))
}
#[test]
fn a_retry_is_the_same_file_edited_after_looking_elsewhere() {
let retried = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
call("Bash", "cargo test", "02"),
call("Edit", "/a.rs", "03"),
],
);
assert_eq!(retried.files_edited, 1);
assert_eq!(
retried.files_one_shot, 0,
"the same file, twice, is a retry"
);
let progress = analysed(
Provider::Claude,
&[
call("Edit", "/a.rs", "01"),
call("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"
);
let burst = analysed(
Provider::Claude,
&[call("Edit", "/a.rs", "01"), call("Edit", "/a.rs", "02")],
);
assert_eq!(burst.files_one_shot, 1);
}
#[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 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"));
}
}