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 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_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 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 truncated: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Edited {
pub first: String,
pub last: String,
pub after_failure: 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
}
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 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,
),
}
}
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,
active_ms: active_ms(&timeline),
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,
};
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,
});
span.last.clone_from(&detail.ts);
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
}
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;
pub fn mark_rework(analyses: &mut [Analysis]) {
use crate::util::parse_ts;
let mut writers: HashMap<&str, Vec<(usize, i64)>> = 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, t.timestamp()));
}
}
}
let mut charged: HashSet<(usize, &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, _)) = blamed {
charged.insert((i, path.as_str()));
}
}
}
let mut counts = vec![0u64; analyses.len()];
for (i, _) in charged {
counts[i] += 1;
}
for (a, n) in analyses.iter_mut().zip(counts) {
a.files_reworked = n;
}
}
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> {
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] [--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: claude, codex, cursor, gemini, opencode,
pi, windsurf.
--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 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 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;
}
},
"--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 = only(&analyses, provider);
match (which, json) {
("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 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))
}
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,
},
);
}
a
}
#[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"));
}
}