cctop 0.13.0

An htop-like terminal monitor for AI coding agent sessions on Linux (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi, Windsurf)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
//! Shared analysis behind `cctop optimize` and `cctop compare`.
//!
//! Both commands ask questions the table cannot: not "what is this session
//! costing" but "what kind of work was it, and was any of it wasted". That
//! needs the individual tool calls with their arguments, and those are
//! [deliberately never cached](crate::session::Metrics::tool_details) — at
//! ~31 KB a session they were 83% of a cache that had to be read in full
//! before the first frame.
//!
//! So these commands re-parse every transcript they look at, in parallel, and
//! are slow in a way the table is not. That is the right trade: the table runs
//! many times a minute and these run when somebody asks a question.
//!
//! Everything here is derived from what the transcript already recorded. There
//! are no model calls, no heuristic that needs the network, and nothing that
//! writes: both commands read and print.

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};

/// What a session was mostly doing.
///
/// Deterministic, from tool composition — no model call, and no cost beyond the
/// parse that already happened. The categories exist because every metric below
/// is meaningless in aggregate: a 30% one-shot rate is alarming for editing and
/// unremarkable for debugging, and "half your spend went to conversation" is
/// only sayable if conversation is a category.
///
/// ponytail: one category per session, not per turn. A session is really a
/// sequence — explore, then code, then test — and the honest unit is the turn.
/// The turn is not reachable here: `tool_details` is grouped by tool name and
/// carries a timestamp but not a turn boundary, so a per-turn split would be
/// invented rather than read. A distribution over turns is the better shape if
/// the transcript ever offers one.
#[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,
    ];
}

/// Directory fragments whose contents an agent almost never needs to read.
///
/// Matched against the path as the transcript spelled it, so a relative
/// `node_modules/x` and an absolute one both hit. Kept deliberately short:
/// every entry here is a directory that is generated, vendored or versioned
/// elsewhere, and a false positive tells someone their real source file is
/// junk.
const JUNK: [&str; 9] = [
    "node_modules/",
    "/.git/",
    "/target/debug/",
    "/target/release/",
    "/dist/",
    "/build/",
    "/vendor/",
    "/.venv/",
    "__pycache__/",
];

fn is_junk(path: &str) -> bool {
    // Both separators, because a Windows transcript spells them the other way
    // and the same directory is no less generated for it.
    let normalised = path.replace('\\', "/");
    let padded = format!("/{normalised}");
    JUNK.iter().any(|j| padded.contains(j))
}

/// Tools that write to a file, across the harnesses that name their tools
/// differently. `apply_patch` is Codex's, `str_replace_editor` an older Claude
/// spelling that still appears in transcripts kept from the time.
const EDIT_TOOLS: [&str; 6] = [
    "Edit",
    "Write",
    "MultiEdit",
    "NotebookEdit",
    "apply_patch",
    "str_replace_editor",
];

const READ_TOOLS: [&str; 4] = ["Read", "NotebookRead", "read_file", "View"];

/// Whether a shell command writes to a file.
///
/// Without this an entire way of working is invisible. A session driven in
/// "do it through Bash" mode edits with `sed -i`, a heredoc and a redirect, and
/// never touches the Edit tool at all — 194 Bash calls, no edits, and cctop
/// filed it as Testing and then reported it as a session that "spent over $0.50
/// and edited no file". On this machine that mistake covered 19 sessions and
/// $216 of ordinary work.
///
/// ponytail: this reads the command, not the filesystem, so it is a heuristic
/// in both directions. A `python3 - <<PY` whose script calls `open(p, "w")`
/// writes a file and is not detectable here — the write is inside the program,
/// and the command line says only that python ran. Detail strings are also
/// capped, so a redirect past the cap is not seen. Both make this an
/// undercount, which is the safe direction: a missed write leaves a session
/// looking quieter than it was, where a false one would accuse somebody of
/// editing something they did not.
fn bash_writes(command: &str) -> bool {
    let cmd = command.to_ascii_lowercase();

    // In-place editors and copiers, each anchored so `grep sed` in a filename
    // cannot trigger one.
    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;
    }

    // A redirect into something that looks like a path. `2>&1`, `>&2` and
    // `/dev/null` are the three that appear constantly and write nothing worth
    // counting.
    let bytes = cmd.as_bytes();
    for (i, _) in cmd.match_indices('>') {
        // Skip the `>` of `2>&1` and `->`, and the second `>` of `>>`.
        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;
        }
        // A target that names a file rather than a descriptor.
        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))
}

/// The tool each harness runs shell commands through.
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))
}

/// One session, reduced to the things both commands ask about.
///
/// No `Default`: there is no default harness, and inventing one would put a
/// real provider's name on a row that came from nowhere.
#[derive(Debug, Clone)]
pub struct Analysis {
    pub provider: Provider,
    pub label: String,
    pub model: String,
    pub cost: f64,
    /// False where the provider records no usage, so a zero cost means "not
    /// said" rather than "free" and must not be averaged into anything.
    pub cost_available: bool,
    pub task: Task,
    pub calls: u64,
    pub errors: u64,
    /// Whether `errors` is a measurement or a provider's silence.
    pub records_outcomes: bool,
    /// Calls to a tool whose job is to write a file.
    pub edits: u64,
    /// Shell calls that wrote a file — see [`bash_writes`]. Kept apart from
    /// `edits` because only one of the two comes with a path, and the one-shot
    /// rate needs the path.
    pub bash_writes: u64,
    pub reads: u64,
    /// Distinct files edited, and how many of them took one contiguous attempt.
    pub files_edited: u64,
    pub files_one_shot: u64,
    /// Distinct paths read, for the cross-session duplicate detector.
    pub read_paths: HashSet<String>,
    /// Reads into generated or vendored directories, with the window growth
    /// they cost where the transcript recorded it.
    pub junk_reads: u64,
    pub junk_tokens: u64,
    /// Window growth spent re-reading a path this session had already read.
    pub reread_tokens: u64,
    pub rereads: u64,
    pub cache_read: u64,
    pub input_total: u64,
    /// The per-tool history hit its cap, so every count here is a floor.
    pub truncated: bool,
}

/// Every tool call in one session, oldest first.
///
/// `tool_details` is grouped by tool name; almost everything below needs the
/// order calls actually happened in, so it is flattened and sorted once here.
/// Timestamps are ISO-8601 and sort lexically, which is why this can be a
/// string comparison rather than a parse per call.
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
}

/// Which model to credit a session to: the one that cost the most.
///
/// A session that switched models mid-way is credited entirely to its dominant
/// one, because the tool calls cannot be attributed per model — the transcript
/// records which model billed a request, not which model asked for a given
/// call. `compare` says so on its output rather than pretending otherwise.
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())
}

/// Classify by what the session did, falling back to what it said it was for.
///
/// Precedence is deliberate and tool-first. Keyword rules are English-shaped
/// and would misfile a French or Chinese prompt; tool composition is the same
/// in every language, so the keywords only ever break a tie between kinds of
/// editing — never decide whether editing happened.
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);
            // A shell that wrote a file is editing, whatever else it did.
            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 {
        // A session that edited *and* ran tests is test-driven coding, not
        // testing. Ranking by which happened more often looked reasonable and
        // filed 22 of 57 Claude sessions as Testing on this very repository —
        // a category that swallows the work it was meant to distinguish is
        // worse than no category. Testing means running tests and changing
        // nothing, which is the only case the two are actually distinct.
        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 {
    /// Every write this session made, however it made it.
    ///
    /// The thing to ask before saying a session changed nothing. `edits` alone
    /// answers that question wrongly for anyone working through the shell.
    pub fn wrote(&self) -> u64 {
        self.edits + self.bash_writes
    }
}

/// Cached input and total input, which are spelled differently per harness.
///
/// Adding every field together looks harmless and is not: Codex reports
/// `input_total` as the whole prompt with `cached_input` already inside it,
/// while Claude reports `input` as the *uncached* remainder alongside
/// `cache_read`. Summing both shapes counted Codex's cached tokens twice and
/// put every Codex model's cache hit rate over 50% before it had read anything.
fn input_split(provider: Provider, data: &SessionData) -> (u64, u64) {
    let t = &data.tokens;
    match provider {
        // Codex says so directly.
        Provider::Codex if t.input_total > 0 => (t.cached_input, t.input_total),
        // Everything else: fresh input plus what came from the cache, plus what
        // was paid to put it there — a cache write is billed input too.
        _ => (
            t.cache_read,
            t.input + t.cache_read + t.cache_write_5m + t.cache_write_1h,
        ),
    }
}

/// Reduce one session's freshly-parsed data to an [`Analysis`].
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,
        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,
    };

    // A tool whose history filled its cap has older calls dropped, so every
    // count derived from it is a floor rather than a total. Said once here so
    // both commands can label the row rather than quietly under-report it.
    out.truncated = data
        .metrics
        .tool_details
        .values()
        .any(|l| l.len() >= crate::config::MAX_TOOL_DETAILS);

    let mut seen_reads: HashSet<&str> = HashSet::new();
    // Edits per file in call order, so a file's attempts can be found later.
    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_shell(tool) {
            // The full text when there is one: the short form is capped, and a
            // redirect past the cap would go unseen.
            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;
            }
            // Kept whole, not just counted: the cross-session detector needs to
            // know *which* file, because a path read once in each of six
            // sessions is a missing note in CLAUDE.md, and the same count spread
            // over six different files is nothing at all.
            out.read_paths.insert(path.to_string());
        }
    }

    // A file is one-shot when its edits form a single run with no other tool
    // call in between. An intervening call is what makes a second edit a
    // *retry* — the agent looked at something and came back — where two edits
    // in a row are one turn writing twice.
    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
}

/// Freshly parse and analyse every session, in parallel.
///
/// `Store::session_data_fresh` is the only path that returns the tool history —
/// a cached copy always has it stripped — so this cannot be served from the
/// cache however warm it is.
pub fn scan(plan: Plan) -> Vec<Analysis> {
    let mut loader = crate::loader::Loader::new();
    let walked = loader.load(plan);
    from_store(&walked, loader.store())
}

/// Analyse an already-walked set against an existing store.
///
/// Split from [`scan`] so the UI worker can use the loader it already has. That
/// loader's walk is warm, so opening this from the table costs the fresh
/// re-parse and nothing else.
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()
}

/// Sessions worth reasoning about.
///
/// A session with no tool calls at all is either pure conversation or a
/// transcript cctop cannot read the calls out of, and the two are not
/// distinguishable here. Both would drag every average toward zero, so they are
/// counted separately rather than mixed in.
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.
";

/// `cctop optimize` and `cctop compare`.
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
}

/// `1 session` / `2 sessions`, because a report that says "1 sessions" reads as
/// one nobody proof-read.
pub fn plural(n: usize, noun: &str) -> String {
    match n {
        1 => format!("1 {noun}"),
        _ => format!("{n} {noun}s"),
    }
}

/// A harness name as somebody would type it at `--provider`.
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))
    }

    /// The distinction the whole one-shot rate rests on. Editing a file, going
    /// away to run something, and editing it again is a retry; editing two
    /// different files in a row is progress and must not be counted as one.
    #[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"
        );

        // Two edits in a row are one turn writing twice, not a second attempt:
        // nothing was learned in between.
        let burst = analysed(
            Provider::Claude,
            &[call("Edit", "/a.rs", "01"), call("Edit", "/a.rs", "02")],
        );
        assert_eq!(burst.files_one_shot, 1);
    }

    /// Codex reports the whole prompt with the cached part already inside it,
    /// where Claude reports the uncached remainder alongside it. Adding every
    /// field together counted Codex's cached tokens twice and put its cache hit
    /// rate over 50% before it had read anything.
    #[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));
    }

    /// Ranking testing above coding by call volume filed 22 of 57 sessions on
    /// this repository as Testing. A category that swallows the work it was
    /// meant to distinguish is worse than not having it.
    #[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);

        // Running tests and changing nothing is the only case where the two are
        // actually distinct.
        let only_running = vec![call("Bash", "cargo test", "01")];
        let data = data_of(&only_running);
        assert_eq!(classify(&data, &timeline(&data)), Task::Testing);
    }

    /// The bug this closes. A session driven entirely through the shell edits
    /// with `sed -i`, a heredoc and a redirect and never touches the Edit tool.
    /// cctop counted no edits, filed 194 Bash calls as Testing, and then
    /// reported the session as having "spent over $0.50 and edited no file" —
    /// 19 sessions and $216 of ordinary work on this machine.
    #[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}");
        }

        // The commands a session runs constantly and that write nothing worth
        // counting. A false positive here accuses somebody of editing a file
        // they only looked at.
        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}"
            );
        }
    }

    /// A session that only ever wrote through the shell still edited, and the
    /// two are counted in one place so nothing has to remember both.
    #[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"
        );

        // The path-based figures stay honest: a shell write carries no file
        // name, so it must not inflate the one-shot rate.
        assert_eq!(shell_only.files_edited, 0);
    }

    /// Either separator: a path in a transcript is spelled however the harness
    /// that wrote it spelled it.
    #[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"));
    }
}