Skip to main content

claude_session_parser/
usage.rs

1use chrono::DateTime;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use super::activity::{classify, normalize_user_text, Activity};
10
11/// Pricing per 1M tokens (USD) by model family.
12///
13/// Anthropic bills three prompt-cache tiers off the base input rate:
14/// 5-minute cache write = 1.25× input, 1-hour cache write = 2× input,
15/// cache read = 0.1× input. `cache_write` is the 5-minute rate (Claude Code's
16/// historical default); `cache_write_1h` is the 1-hour rate. Claude Code now
17/// emits `usage.cache_creation.ephemeral_1h_input_tokens` for the 1-hour
18/// portion, so the two must be priced separately or 1h writes undercount ~38%.
19pub struct ModelPricing {
20    pub input: f64,
21    pub output: f64,
22    pub cache_read: f64,
23    /// 5-minute cache write (1.25× input).
24    pub cache_write: f64,
25    /// 1-hour cache write (2× input).
26    pub cache_write_1h: f64,
27}
28
29/// `(major, minor)` of the version that follows `family` in a
30/// `claude-<family>-<major>[-<minor>][-<yyyymmdd>]` id, or `None` when no
31/// parseable version follows it (aliases like `"fable"`, legacy
32/// `claude-3-5-sonnet-…` forms, `<synthetic>`).
33///
34/// An 8-digit segment is a date snapshot, not a minor version, so
35/// `claude-sonnet-5-20260701` is `(5, 0)` while `claude-haiku-4-5-20251001`
36/// is `(4, 5)`. Callers compare with `>=` so an unrecognised future version
37/// prices as the newest tier we know about rather than the oldest.
38///
39/// A trailing context-window tag (`claude-opus-5-5[1m]`) is dropped first:
40/// left in, it glues onto the last version segment and reads as `(5, 0)` —
41/// or fails the parse outright for `claude-sonnet-5[1m]`.
42fn family_version(model: &str, family: &str) -> Option<(u32, u32)> {
43    fn is_date(p: &str) -> bool {
44        p.len() == 8 && p.bytes().all(|b| b.is_ascii_digit())
45    }
46    let model = model.split('[').next().unwrap_or(model);
47    let rest = model.split_once(family)?.1.strip_prefix('-')?;
48    let mut parts = rest.split('-');
49    let head = parts.next()?;
50    // Legacy ids put the version *before* the family (`claude-3-5-sonnet-…`),
51    // so what follows is a date snapshot, not a version. Reject it rather than
52    // reading 20241022 as major version 20241022.
53    if is_date(head) {
54        return None;
55    }
56    let major: u32 = head.parse().ok()?;
57    let minor = match parts.next() {
58        Some(p) if is_date(p) => 0,
59        Some(p) => p.parse().unwrap_or(0),
60        None => 0,
61    };
62    Some((major, minor))
63}
64
65/// Haiku 5.5 bills by prompt size: prompts over this many tokens pay the
66/// long-context tier. The docs don't say which token classes count; we use
67/// input + cache reads + cache writes (see `pricing_for_entry`).
68const HAIKU_LONG_PROMPT_TOKENS: u64 = 100_000;
69
70fn is_haiku_5_5(model: &str) -> bool {
71    matches!(family_version(model, "haiku"), Some(v) if v >= (5, 5))
72}
73
74fn haiku_5_5_pricing(long_prompt: bool) -> ModelPricing {
75    if long_prompt {
76        ModelPricing {
77            input: 0.5,
78            output: 2.5,
79            cache_read: 0.05,
80            cache_write: 0.625,
81            cache_write_1h: 1.0,
82        }
83    } else {
84        ModelPricing {
85            input: 0.1,
86            output: 0.5,
87            cache_read: 0.01,
88            cache_write: 0.125,
89            cache_write_1h: 0.2,
90        }
91    }
92}
93
94/// Hardcoded as of 2026-10. Requires a code change + release when Anthropic updates pricing.
95///
96/// Four families price by version, so the id must carry one to get the newer
97/// rate — an alias (`"fable"`, `"opus"`, `"sonnet"`) prices as the older tier:
98/// - **Fable 5.1+** reads cache at 0.025× input ($0.25/MTok) instead of the
99///   standard 0.1× ($1/MTok) that Fable 5 pays. Everything else is identical.
100/// - **Opus 5.5+** is $4/$20 and reads cache at 0.05× input ($0.20/MTok);
101///   Opus 5 and earlier stay at $5/$25 with the standard 0.1× read.
102/// - **Sonnet 5+** is $2/$10; Sonnet 4.6 and earlier stay at $3/$15. The $2/$10
103///   launch price became Sonnet 5's standard price on 2026-09-01 — the
104///   scheduled increase to $3/$15 was cancelled.
105/// - **Haiku 5.5+** is $0.10/$0.50 for prompts up to 100k tokens and 5× that
106///   above it; this returns the up-to-100k tier, and `pricing_for_entry`
107///   applies the long-prompt tier. Haiku 4.5 and the `"haiku"` alias stay at $1/$5.
108pub fn pricing_for_model(model: &str) -> ModelPricing {
109    if model.contains("fable") {
110        // Fable 5 / 5.1: $10/$50 per MTok. Writes derive from input at the
111        // standard multipliers (1.25× 5m, 2× 1h); only the read rate moved.
112        let cache_read = match family_version(model, "fable") {
113            Some(v) if v >= (5, 1) => 0.25,
114            _ => 1.0,
115        };
116        ModelPricing {
117            input: 10.0,
118            output: 50.0,
119            cache_read,
120            cache_write: 12.5,
121            cache_write_1h: 20.0,
122        }
123    } else if matches!(family_version(model, "opus"), Some(v) if v >= (5, 5)) {
124        // Opus 5.5: $4/$20, writes at the standard multipliers, reads at
125        // 0.05× input rather than 0.1×.
126        ModelPricing {
127            input: 4.0,
128            output: 20.0,
129            cache_read: 0.2,
130            cache_write: 5.0,
131            cache_write_1h: 8.0,
132        }
133    } else if model.contains("opus") {
134        ModelPricing {
135            input: 5.0,
136            output: 25.0,
137            cache_read: 0.5,
138            cache_write: 6.25,
139            cache_write_1h: 10.0,
140        }
141    } else if is_haiku_5_5(model) {
142        haiku_5_5_pricing(false)
143    } else if model.contains("haiku") {
144        ModelPricing {
145            input: 1.0,
146            output: 5.0,
147            cache_read: 0.1,
148            cache_write: 1.25,
149            cache_write_1h: 2.0,
150        }
151    } else if matches!(family_version(model, "sonnet"), Some(v) if v >= (5, 0)) {
152        ModelPricing {
153            input: 2.0,
154            output: 10.0,
155            cache_read: 0.2,
156            cache_write: 2.5,
157            cache_write_1h: 4.0,
158        }
159    } else {
160        // Sonnet 4.6 and earlier, or unknown
161        ModelPricing {
162            input: 3.0,
163            output: 15.0,
164            cache_read: 0.3,
165            cache_write: 3.75,
166            cache_write_1h: 6.0,
167        }
168    }
169}
170
171/// Per-turn rates: `pricing_for_model`, except Haiku 5.5+ turns whose prompt
172/// (input + cache reads + cache writes) exceeds 100k tokens, which bill the
173/// long-prompt tier. Unverified: Anthropic's pricing page doesn't say which
174/// token classes count toward the threshold.
175pub fn pricing_for_entry(entry: &UsageEntry) -> ModelPricing {
176    let prompt = entry.input_tokens + entry.cache_read_tokens + entry.cache_write_tokens;
177    if is_haiku_5_5(&entry.model) && prompt > HAIKU_LONG_PROMPT_TOKENS {
178        haiku_5_5_pricing(true)
179    } else {
180        pricing_for_model(&entry.model)
181    }
182}
183
184/// Flat fee per Anthropic web-search server-tool request ($10 per 1,000 = $0.01).
185pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
186
187/// Fast-mode (research preview) cost multiplier, applied to the entire token
188/// cost line when `usage.speed == "fast"`. Fast mode is Opus-only and its
189/// premium varies by version (confirmed Anthropic pricing, 2026-09):
190/// - Opus 4.6 / 4.7 fast: 6× ($30/$150 vs $5/$25)
191/// - Opus 4.8 / 5 fast:   2× ($10/$50 vs $5/$25)
192/// - Opus 5.5 fast:       2× ($8/$40 vs $4/$20)
193///
194/// Caching multipliers stack on top of fast pricing, i.e. fast scales the whole
195/// per-token line (input, output, and the cache rates that derive from input).
196/// Unknown / unsupported models return 1.0 (no premium) so we never overcount
197/// on a guess — newer Opus releases default here until their multiplier is added.
198/// Fable has no fast-mode variant (fast is Opus-only) → 1.0.
199pub fn fast_multiplier_for_model(model: &str) -> f64 {
200    if model.contains("opus-4-8") || model.contains("opus-5") {
201        2.0
202    } else if model.contains("opus-4-7") || model.contains("opus-4-6") {
203        6.0
204    } else {
205        1.0
206    }
207}
208
209/// Single source of truth for per-turn cost (USD), used by both the live status
210/// bar (`reduce_session`) and the Usage page (`commands::usage`).
211///
212/// Splits cache-write tokens into 5-minute and 1-hour tiers, applies the
213/// fast-mode multiplier to the token line when the turn ran in fast mode, and
214/// adds the flat web-search fee (which is not a token cost and so is unaffected
215/// by the fast multiplier).
216pub fn cost_for_entry(entry: &UsageEntry) -> f64 {
217    let p = pricing_for_entry(entry);
218    let cw_1h = entry.cache_write_1h_tokens.min(entry.cache_write_tokens);
219    let cw_5m = entry.cache_write_tokens - cw_1h;
220    let token_cost = entry.input_tokens as f64 * p.input
221        + entry.output_tokens as f64 * p.output
222        + entry.cache_read_tokens as f64 * p.cache_read
223        + cw_5m as f64 * p.cache_write
224        + cw_1h as f64 * p.cache_write_1h;
225    let fast = if entry.speed.as_deref() == Some("fast") {
226        fast_multiplier_for_model(&entry.model)
227    } else {
228        1.0
229    };
230    (token_cost * fast) / 1_000_000.0
231        + entry.web_search_requests as f64 * WEB_SEARCH_COST_PER_REQUEST
232}
233
234/// Fable, Haiku 5.5+, Opus 5+ and Sonnet 5+ run a 1M window natively: JSONL
235/// strips the `[1m]` tag, yet 74 bare `claude-opus-5*`/`claude-sonnet-5` sessions
236/// were observed sending requests over 200K. Opus/Sonnet 4.x stay 200K unless
237/// tagged `[1m]`.
238pub fn context_window_for_model(model: &str) -> u64 {
239    let native_1m = model.contains("fable")
240        || is_haiku_5_5(model)
241        || matches!(family_version(model, "opus"), Some(v) if v >= (5, 0))
242        || matches!(family_version(model, "sonnet"), Some(v) if v >= (5, 0));
243    if model.contains("[1m]") || native_1m {
244        1_000_000
245    } else {
246        200_000
247    }
248}
249
250/// Flat row representing a single completed assistant turn.
251///
252/// One entry per unique `message.id`. Claude Code writes multiple JSONL
253/// entries per assistant message as the stream progresses (typically 2-3),
254/// all sharing the same `id` and final usage numbers — dedup keeps only the
255/// first occurrence.
256///
257/// Token field semantics (inherited from Anthropic's API):
258/// - `input_tokens`: *fresh* tokens in this turn's prompt that were NOT served
259///   from cache. Small when conversation is reused, large on cache misses.
260/// - `cache_read_tokens`: tokens served from the prompt cache (cheap).
261/// - `cache_write_tokens`: tokens written into the prompt cache (~25% premium).
262/// - `output_tokens`: tokens generated by the model.
263///
264/// "Total context sent" for a turn = input + cache_read + cache_write.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct UsageEntry {
268    pub session_id: String,
269    /// Unix epoch ms from the JSONL `timestamp` field. 0 if unparseable.
270    pub timestamp_ms: u64,
271    pub model: String,
272    pub input_tokens: u64,
273    pub output_tokens: u64,
274    pub cache_read_tokens: u64,
275    /// Total cache-write (creation) tokens for this turn: `max(legacy flat
276    /// `cache_creation_input_tokens`, ephemeral_5m + ephemeral_1h)`. The 1-hour
277    /// subset is broken out separately in `cache_write_1h_tokens` for pricing;
278    /// this total still drives cache-hit % and token displays.
279    pub cache_write_tokens: u64,
280    /// The 1-hour-TTL portion of `cache_write_tokens` (from
281    /// `usage.cache_creation.ephemeral_1h_input_tokens`). Billed at 2× input
282    /// vs the 5-minute tier's 1.25×. The 5-minute portion is the remainder.
283    #[serde(default)]
284    pub cache_write_1h_tokens: u64,
285    /// Anthropic web-search server-tool requests this turn
286    /// (`usage.server_tool_use.web_search_requests`). Billed flat at $0.01 each.
287    #[serde(default)]
288    pub web_search_requests: u64,
289    /// `usage.speed`: `"standard"` or `"fast"`. `Some("fast")` applies the
290    /// fast-mode cost multiplier (see `fast_multiplier_for_model`). `None` on
291    /// rows that predate the field.
292    #[serde(default)]
293    pub speed: Option<String>,
294    /// Names of tools this turn invoked (from `tool_use` content blocks).
295    /// Empty for pure-text turns. Duplicates preserved when the model called
296    /// the same tool multiple times in one turn.
297    pub tool_uses: Vec<String>,
298    /// Sum of `text` content block character counts for this turn. Zero means
299    /// a silent tool-only turn; non-zero means the model said something.
300    pub text_chars: u32,
301    /// Classified activity category (Coding, Debugging, Exploration, …).
302    /// See the [`crate::activity`] module for the decision tree.
303    pub activity: Activity,
304    /// JSONL `entrypoint` field captured from the first row of each message id.
305    /// `"cli"` = interactive Claude Code session. `"sdk-cli"` = headless
306    /// `claude -p` spawn (any Agent SDK consumer lands here). `None` for legacy
307    /// rows that predate the field. Useful for distinguishing interactive vs.
308    /// headless usage; also load-bearing once Anthropic splits Agent SDK
309    /// billing from interactive plans (announced June 2026).
310    pub entrypoint: Option<String>,
311}
312
313/// A single hook execution event from a `type: "attachment"` JSONL record
314/// where `attachment.type == "hook_success"`.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(rename_all = "camelCase")]
317pub struct HookExecution {
318    pub session_id: String,
319    pub timestamp_ms: u64,
320    /// Lifecycle phase: `PreToolUse`, `PostToolUse`, `SessionStart`, `Stop`, etc.
321    pub hook_event: String,
322    /// Full hook label including matcher: `PreToolUse:Bash`, `Stop`, etc.
323    pub hook_name: String,
324    /// The shell command that was executed.
325    pub command: String,
326    pub exit_code: i32,
327    pub duration_ms: u64,
328    /// The tool-use ID that triggered this hook (groups multiple hooks per tool call).
329    pub tool_use_id: Option<String>,
330}
331
332/// Combined result from parsing a single JSONL file.
333#[derive(Default)]
334pub struct ParsedJsonl {
335    pub entries: Vec<UsageEntry>,
336    pub hooks: Vec<HookExecution>,
337}
338
339/// Usage summary for a single session, returned to the frontend status bar.
340#[derive(Debug, Clone, Serialize, Default)]
341#[serde(rename_all = "camelCase")]
342pub struct SessionUsage {
343    pub model: String,
344    pub input_tokens: u64,
345    pub output_tokens: u64,
346    pub cache_read_tokens: u64,
347    pub cache_write_tokens: u64,
348    pub total_tokens: u64,
349    pub estimated_cost_usd: f64,
350    pub context_window_size: u64,
351    pub context_window_used_pct: f64,
352    /// Last message's total input context (current context window usage).
353    pub current_context_tokens: u64,
354    /// Cumulative context sent (input + cache_read + cache_write).
355    pub total_context_sent: u64,
356    /// cache_read / (input + cache_read), in percent. 0 when no cache activity.
357    pub cache_hit_pct: f64,
358    pub message_count: u64,
359    pub session_id: String,
360}
361
362/// Minimal JSONL entry structure.
363#[derive(Deserialize)]
364#[serde(rename_all = "camelCase")]
365struct JsonlRaw {
366    #[serde(rename = "type")]
367    entry_type: Option<String>,
368    timestamp: Option<String>,
369    message: Option<MessageData>,
370    attachment: Option<AttachmentData>,
371    // Fields for `type: "system"` hook summary records (v2.1.119+).
372    subtype: Option<String>,
373    #[allow(dead_code)]
374    hook_count: Option<usize>,
375    hook_infos: Option<Vec<HookInfoEntry>>,
376    hook_errors: Option<Vec<serde_json::Value>>,
377    prevented_continuation: Option<bool>,
378    #[serde(rename = "toolUseID")]
379    tool_use_id: Option<String>,
380    /// Origin of the session: `"cli"` (interactive Claude Code) or `"sdk-cli"`
381    /// (headless `claude -p` / Agent SDK). Present on every assistant + user row.
382    entrypoint: Option<String>,
383}
384
385#[derive(Deserialize)]
386struct MessageData {
387    id: Option<String>,
388    model: Option<String>,
389    usage: Option<UsageData>,
390    /// Raw content — assistant messages use `Vec<ContentBlock>`, user messages
391    /// use either a string or an array of blocks (including tool_results).
392    /// Kept as a raw Value so we can handle both shapes without maintaining
393    /// two parallel structs.
394    #[serde(default)]
395    content: Option<serde_json::Value>,
396}
397
398#[derive(Deserialize, Default)]
399struct UsageData {
400    input_tokens: Option<u64>,
401    output_tokens: Option<u64>,
402    cache_read_input_tokens: Option<u64>,
403    /// Legacy flat total of cache-creation tokens. Still emitted alongside the
404    /// nested `cache_creation` split; we take the max of the two for safety.
405    cache_creation_input_tokens: Option<u64>,
406    /// 5-minute / 1-hour cache-write split (Claude Code ≥ the 1h-cache rollout).
407    cache_creation: Option<CacheCreation>,
408    /// Server-tool request counts (web search / web fetch).
409    server_tool_use: Option<ServerToolUse>,
410    /// `"standard"` | `"fast"`. Drives the fast-mode cost multiplier.
411    speed: Option<String>,
412}
413
414/// Nested `usage.cache_creation` object splitting cache writes by TTL tier.
415#[derive(Deserialize, Default)]
416struct CacheCreation {
417    ephemeral_5m_input_tokens: Option<u64>,
418    ephemeral_1h_input_tokens: Option<u64>,
419}
420
421/// Nested `usage.server_tool_use` object. Only `web_search_requests` is billable
422/// ($0.01/req); `web_fetch_requests` is free and intentionally not priced.
423#[derive(Deserialize, Default)]
424struct ServerToolUse {
425    web_search_requests: Option<u64>,
426}
427
428/// Single hook execution in a `hookInfos` array (v2.1.119+ system records).
429#[derive(Deserialize)]
430#[serde(rename_all = "camelCase")]
431struct HookInfoEntry {
432    command: Option<String>,
433    duration_ms: Option<u64>,
434}
435
436/// Minimal deserialization of the `attachment` field on `type: "attachment"` rows.
437#[derive(Deserialize)]
438#[serde(rename_all = "camelCase")]
439struct AttachmentData {
440    #[serde(rename = "type")]
441    attachment_type: Option<String>,
442    hook_name: Option<String>,
443    hook_event: Option<String>,
444    command: Option<String>,
445    exit_code: Option<i32>,
446    duration_ms: Option<u64>,
447    #[serde(rename = "toolUseID")]
448    tool_use_id: Option<String>,
449}
450
451/// Claude Code active session file in `{config_dir}/sessions/{pid}.json`.
452#[derive(Deserialize)]
453#[serde(rename_all = "camelCase")]
454struct ActiveSession {
455    session_id: String,
456    cwd: String,
457    started_at: u64,
458}
459
460/// Hard cap to avoid pathological parsing of a corrupted file.
461///
462/// Parsing is streaming (`BufReader::lines()`) so memory is O(1) per line —
463/// the cap only exists to keep a runaway file from blocking the UI. Real
464/// agent sessions (Task-tool sub-agents) frequently exceed 30MB, so the cap
465/// needs generous headroom.
466use super::MAX_JSONL_BYTES;
467
468/// Encode a project path the same way Claude Code does.
469///
470/// Windows: `S:\Loom` -> `S--Loom`, `C:\Users\Seven\foo` -> `C--Users-Seven-foo`
471/// macOS/Linux: `/Users/seven/Loom` -> `-Users-seven-Loom`, `/home/alice/proj` -> `-home-alice-proj`
472///
473/// The transform collapses any leading path separator(s) into a single leading
474/// dash, which is how Claude Code writes `~/.claude/projects/` directory names
475/// on Unix. Verified against ccusage's decoder (see GitHub issue
476/// anthropics/claude-code#19972 for the path-encoding discussion).
477pub fn encode_project_path(project_path: &str) -> String {
478    let normalized = project_path.replace('/', "\\");
479    let mut result = String::new();
480    let mut chars = normalized.chars().peekable();
481
482    while let Some(ch) = chars.next() {
483        if ch == ':' {
484            if chars.peek() == Some(&'\\') {
485                chars.next();
486                result.push_str("--");
487            } else {
488                result.push('-');
489            }
490        } else if ch == '\\' {
491            result.push('-');
492        } else {
493            result.push(ch);
494        }
495    }
496
497    while result.ends_with('-') {
498        result.pop();
499    }
500
501    result
502}
503
504/// Extracted signal from an assistant message's `content` array.
505struct AssistantContent {
506    tool_uses: Vec<String>,
507    bash_cmds: Vec<String>,
508    text_chars: u32,
509}
510
511/// Walk an assistant message's content blocks to pull out tool names, any
512/// bash-style commands (used by the classifier for Testing/GitOps/Build
513/// detection), and the total character count of `text` blocks.
514fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
515    let mut out = AssistantContent {
516        tool_uses: Vec::new(),
517        bash_cmds: Vec::new(),
518        text_chars: 0,
519    };
520    let arr = match content.and_then(|v| v.as_array()) {
521        Some(a) => a,
522        None => return out,
523    };
524
525    for block in arr {
526        let block_type = match block.get("type").and_then(|v| v.as_str()) {
527            Some(t) => t,
528            None => continue,
529        };
530        match block_type {
531            "tool_use" => {
532                if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
533                    // If this is a Bash-family tool, grab the command string so
534                    // the classifier can distinguish Testing / GitOps / Build.
535                    if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
536                        if let Some(cmd) = block
537                            .get("input")
538                            .and_then(|i| i.get("command"))
539                            .and_then(|c| c.as_str())
540                        {
541                            out.bash_cmds.push(cmd.to_string());
542                        }
543                    }
544                    out.tool_uses.push(name.to_string());
545                }
546            }
547            "text" => {
548                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
549                    // Saturating because a single turn's text can exceed u32
550                    // only in pathological cases (would be >4 GB of text).
551                    out.text_chars = out
552                        .text_chars
553                        .saturating_add(text.chars().count() as u32);
554                }
555            }
556            _ => {}
557        }
558    }
559
560    out
561}
562
563/// Extract the human-typed portion of a user message's content, if any.
564///
565/// User rows in Claude Code JSONL carry two distinct shapes:
566/// - A plain string (`"content": "hello"`) — always a real user turn.
567/// - An array of content blocks, which may be:
568///   - `[{"type":"text","text":"..."}]` → real user turn
569///   - `[{"type":"tool_result",...}]` → injected tool output, NOT a user turn
570///   - Mixed → treated as a real user turn
571///
572/// Sanitize the per-hook `command` for use inside a synthesized `hook_name`
573/// bucket key. Takes the first line, collapses internal whitespace, and caps
574/// at 64 chars (with `…` suffix). The full command is preserved separately on
575/// the `HookExecution.command` field — this only affects the display label /
576/// aggregation key used by the Usage page and Refinery hook stats.
577///
578/// Without this, `/goal`-style hooks whose "command" is a multi-KB goal
579/// statement explode the hook-table layout AND defeat aggregation (one bucket
580/// per invocation).
581fn sanitize_hook_command_label(cmd: &str) -> String {
582    const MAX_LEN: usize = 64;
583    let first_line = cmd.lines().next().unwrap_or("").trim();
584    let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
585    if collapsed.chars().count() <= MAX_LEN {
586        return collapsed;
587    }
588    let truncated: String = collapsed.chars().take(MAX_LEN).collect();
589    format!("{truncated}…")
590}
591
592/// Convert `snake_case` to `PascalCase`: `"stop"` → `"Stop"`, `"pre_tool_use"` → `"PreToolUse"`.
593fn snake_to_pascal(s: &str) -> String {
594    s.split('_')
595        .filter(|w| !w.is_empty())
596        .map(|w| {
597            let mut c = w.chars();
598            match c.next() {
599                Some(first) => {
600                    let mut out = first.to_uppercase().to_string();
601                    out.extend(c);
602                    out
603                }
604                None => String::new(),
605            }
606        })
607        .collect()
608}
609
610/// Returns `None` for tool-result-only rows so the rolling `last_user_text`
611/// stays pinned to the most recent actual user message.
612fn extract_user_text(content: &serde_json::Value) -> Option<String> {
613    match content {
614        serde_json::Value::String(s) => {
615            if s.is_empty() {
616                None
617            } else {
618                Some(s.clone())
619            }
620        }
621        serde_json::Value::Array(arr) => {
622            let mut parts: Vec<&str> = Vec::new();
623            for item in arr {
624                let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
625                if t == "text" {
626                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
627                        if !text.is_empty() {
628                            parts.push(text);
629                        }
630                    }
631                }
632            }
633            if parts.is_empty() {
634                None
635            } else {
636                Some(parts.join(" "))
637            }
638        }
639        _ => None,
640    }
641}
642
643/// Per-message-id accumulator for content blocks that arrive split across
644/// multiple JSONL rows. See `parse_jsonl_entries` for the why.
645struct EntryAccumulator {
646    out_idx: usize,
647    bash_cmds: Vec<String>,
648    user_text_at_first: Option<String>,
649}
650
651/// Parse a single JSONL file into one `UsageEntry` per unique `message.id`.
652///
653/// Pure function: depends only on the file contents. Non-assistant entries,
654/// malformed lines, and missing IDs are skipped silently.
655///
656/// **Dedup is split by field semantics**, because Claude Code emits one JSONL
657/// row per assistant content block (thinking / text / each tool_use) all
658/// sharing the same `message.id`:
659///
660/// - Billing fields (token counts, model, timestamp, session_id) come from the
661///   **first** row seen for each id — they repeat identically across rows.
662/// - Content fields (`tool_uses`, `text_chars`, and the bash commands fed to
663///   the activity classifier) are **unioned across every row** with that id,
664///   then `activity` is classified once at the end using the merged content.
665///
666/// Folding only by `message.id` (as this parser did before 2026-05-13) drops
667/// every tool_use after the first row, which made the Toolkit and Usage tool
668/// counters undercount by an order of magnitude. The chat parser already
669/// uses `(message_id + content)` for the same reason — see CLAUDE.md.
670///
671/// Runs a rolling `last_user_text` cursor during the stream: each genuine
672/// `type: "user"` row updates it; the snapshot taken at the **first** row of
673/// each assistant message is used for activity classification.
674pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
675    let meta = fs::metadata(path)?;
676    if meta.len() > MAX_JSONL_BYTES {
677        tracing::warn!(
678            path = %path.display(),
679            size = meta.len(),
680            "skipping JSONL larger than {}MB",
681            MAX_JSONL_BYTES / 1024 / 1024
682        );
683        return Ok(ParsedJsonl::default());
684    }
685
686    let file = fs::File::open(path)?;
687    let reader = BufReader::new(file);
688
689    let session_id = path
690        .file_stem()
691        .and_then(|s| s.to_str())
692        .unwrap_or("")
693        .to_string();
694
695    let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
696    let mut out: Vec<UsageEntry> = Vec::new();
697    let mut hooks: Vec<HookExecution> = Vec::new();
698    let mut last_user_text: Option<String> = None;
699
700    for line in reader.lines() {
701        let line = match line {
702            Ok(l) => l,
703            Err(_) => continue,
704        };
705        let line = line.trim();
706        if line.is_empty() {
707            continue;
708        }
709
710        let raw: JsonlRaw = match serde_json::from_str(line) {
711            Ok(r) => r,
712            Err(_) => continue,
713        };
714
715        let entry_type = raw.entry_type.as_deref();
716
717        // Update the rolling user-message cursor on genuine user turns.
718        // Tool-result-only rows (also type="user") leave it untouched.
719        if entry_type == Some("user") {
720            if let Some(msg) = raw.message.as_ref() {
721                if let Some(content) = msg.content.as_ref() {
722                    if let Some(text) = extract_user_text(content) {
723                        last_user_text = Some(normalize_user_text(&text));
724                    }
725                }
726            }
727            continue;
728        }
729
730        // Extract hook execution events from attachment records (v2.1.101 format).
731        if entry_type == Some("attachment") {
732            if let Some(att) = raw.attachment {
733                if att.attachment_type.as_deref() == Some("hook_success") {
734                    let timestamp_ms = raw
735                        .timestamp
736                        .as_deref()
737                        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
738                        .map(|dt| dt.timestamp_millis().max(0) as u64)
739                        .unwrap_or(0);
740                    hooks.push(HookExecution {
741                        session_id: session_id.clone(),
742                        timestamp_ms,
743                        hook_event: att.hook_event.unwrap_or_default(),
744                        hook_name: att.hook_name.unwrap_or_default(),
745                        command: att.command.unwrap_or_default(),
746                        exit_code: att.exit_code.unwrap_or(0),
747                        duration_ms: att.duration_ms.unwrap_or(0),
748                        tool_use_id: att.tool_use_id,
749                    });
750                }
751            }
752            continue;
753        }
754
755        // Extract hook summaries from system records (v2.1.119+ format).
756        // Subtype pattern: "<event>_hook_summary" → hook_event = "<Event>".
757        if entry_type == Some("system") {
758            if let Some(subtype) = raw.subtype.as_deref() {
759                if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
760                    let hook_event = snake_to_pascal(event_snake);
761                    let timestamp_ms = raw
762                        .timestamp
763                        .as_deref()
764                        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
765                        .map(|dt| dt.timestamp_millis().max(0) as u64)
766                        .unwrap_or(0);
767                    let had_errors = raw
768                        .hook_errors
769                        .as_ref()
770                        .is_some_and(|e| !e.is_empty());
771                    let prevented = raw.prevented_continuation.unwrap_or(false);
772                    if let Some(infos) = raw.hook_infos {
773                        for info in infos {
774                            let cmd = info.command.unwrap_or_default();
775                            let hook_name =
776                                format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
777                            hooks.push(HookExecution {
778                                session_id: session_id.clone(),
779                                timestamp_ms,
780                                hook_event: hook_event.clone(),
781                                hook_name,
782                                command: cmd,
783                                exit_code: if had_errors || prevented { 1 } else { 0 },
784                                duration_ms: info.duration_ms.unwrap_or(0),
785                                tool_use_id: raw.tool_use_id.clone(),
786                            });
787                        }
788                    }
789                }
790            }
791            continue;
792        }
793
794        if entry_type != Some("assistant") {
795            continue;
796        }
797
798        let msg = match raw.message {
799            Some(m) => m,
800            None => continue,
801        };
802        let mid = match msg.id {
803            Some(id) => id,
804            None => continue,
805        };
806
807        let mut content_parsed = extract_assistant_content(msg.content.as_ref());
808
809        // Subsequent row for an already-seen message: union its content into
810        // the existing entry. Billing/model/timestamp on the entry stay as-is.
811        if let Some(acc) = accums.get_mut(&mid) {
812            let entry = &mut out[acc.out_idx];
813            entry
814                .tool_uses
815                .append(&mut content_parsed.tool_uses);
816            entry.text_chars = entry
817                .text_chars
818                .saturating_add(content_parsed.text_chars);
819            acc.bash_cmds.append(&mut content_parsed.bash_cmds);
820            continue;
821        }
822
823        // Skip synthetic entries. Claude Code / the Agent SDK writes
824        // `model: "<synthetic>"` rows for internal routing (skill listings,
825        // queue ops, tool dispatch, etc.) — these never hit the LLM and have
826        // all-zero token counts. Counting them would inflate turn counts
827        // without representing real model activity.
828        let model = msg.model.unwrap_or_default();
829        if model == "<synthetic>" {
830            continue;
831        }
832
833        let usage = msg.usage.unwrap_or_default();
834        let timestamp_ms = raw
835            .timestamp
836            .as_deref()
837            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
838            .map(|dt| dt.timestamp_millis().max(0) as u64)
839            .unwrap_or(0);
840
841        // Cache-write tokens: prefer the larger of the legacy flat total and the
842        // 5m+1h split (guards partial/missing fields across Claude Code
843        // versions). Break out the 1-hour subset for tiered pricing.
844        let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
845        let (eph_5m, eph_1h) = usage
846            .cache_creation
847            .as_ref()
848            .map(|c| {
849                (
850                    c.ephemeral_5m_input_tokens.unwrap_or(0),
851                    c.ephemeral_1h_input_tokens.unwrap_or(0),
852                )
853            })
854            .unwrap_or((0, 0));
855        let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
856        let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
857        let web_search_requests = usage
858            .server_tool_use
859            .as_ref()
860            .map(|s| s.web_search_requests.unwrap_or(0))
861            .unwrap_or(0);
862        let speed = usage.speed.clone();
863
864        // First row for this message: push placeholder entry with the content
865        // we've seen so far. Activity is classified at the end of the file
866        // against the merged content from all rows sharing this `message.id`.
867        accums.insert(
868            mid.clone(),
869            EntryAccumulator {
870                out_idx: out.len(),
871                bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
872                user_text_at_first: last_user_text.clone(),
873            },
874        );
875        out.push(UsageEntry {
876            session_id: session_id.clone(),
877            timestamp_ms,
878            model,
879            input_tokens: usage.input_tokens.unwrap_or(0),
880            output_tokens: usage.output_tokens.unwrap_or(0),
881            cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
882            cache_write_tokens,
883            cache_write_1h_tokens,
884            web_search_requests,
885            speed,
886            tool_uses: content_parsed.tool_uses,
887            text_chars: content_parsed.text_chars,
888            activity: Activity::General,
889            entrypoint: raw.entrypoint.clone(),
890        });
891    }
892
893    // Classify each accumulated entry using the merged content from all rows
894    // that shared its `message.id`. Order doesn't matter — we mutate in place.
895    for acc in accums.values() {
896        let entry = &mut out[acc.out_idx];
897        let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
898        let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
899        entry.activity = classify(
900            &tool_refs,
901            &bash_refs,
902            acc.user_text_at_first.as_deref(),
903            entry.text_chars,
904        );
905    }
906
907    Ok(ParsedJsonl {
908        entries: out,
909        hooks,
910    })
911}
912
913/// Find the JSONL file for the current session by looking up Claude Code's
914/// active sessions directory, matching on project path.
915/// Falls back to finding the newest JSONL created after `spawned_at`.
916pub fn find_current_session_jsonl(
917    config_dir: &str,
918    project_path: &str,
919    spawned_at: SystemTime,
920) -> Option<PathBuf> {
921    let config = Path::new(config_dir);
922    let encoded = encode_project_path(project_path);
923    let projects_dir = config.join("projects").join(&encoded);
924
925    if !projects_dir.exists() {
926        return None;
927    }
928
929    // Strategy 1: Look up Claude Code's sessions/ directory for an active
930    // session matching our project path. This gives us the exact session ID.
931    let sessions_dir = config.join("sessions");
932    if sessions_dir.exists() {
933        if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
934            let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
935            if jsonl_path.exists() {
936                return Some(jsonl_path);
937            }
938        }
939    }
940
941    // Strategy 2: Fall back to finding the earliest-created JSONL after spawn
942    // time. "Earliest" is critical: with two concurrent sessions for the same
943    // project, the older session must claim the first JSONL created after its
944    // spawn, not the latest one (which belongs to the newer session).
945    let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
946
947    if let Ok(entries) = fs::read_dir(&projects_dir) {
948        for entry in entries.flatten() {
949            let path = entry.path();
950            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
951                continue;
952            }
953            if let Ok(meta) = path.metadata() {
954                let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
955                if created >= spawned_at {
956                    candidates.push((path, created));
957                }
958            }
959        }
960    }
961
962    // Pick the earliest-created file after our spawn time
963    candidates.sort_by_key(|a| a.1);
964    candidates.into_iter().next().map(|(p, _)| p)
965}
966
967/// Search Claude Code's sessions/ directory for an active session matching
968/// the project path and started after our spawn time.
969fn find_active_session_id(
970    sessions_dir: &Path,
971    project_path: &str,
972    spawned_at: SystemTime,
973) -> Option<String> {
974    let spawned_ms = spawned_at
975        .duration_since(SystemTime::UNIX_EPOCH)
976        .ok()?
977        .as_millis() as u64;
978
979    // Allow 10s grace period (Claude Code may start slightly after PTY spawn)
980    let threshold_ms = spawned_ms.saturating_sub(10_000);
981
982    let normalized_project = normalize_path(project_path);
983
984    let mut best: Option<(String, u64)> = None;
985
986    if let Ok(entries) = fs::read_dir(sessions_dir) {
987        for entry in entries.flatten() {
988            let path = entry.path();
989            if path.extension().and_then(|e| e.to_str()) != Some("json") {
990                continue;
991            }
992            if let Ok(content) = fs::read_to_string(&path) {
993                if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
994                    let session_cwd = normalize_path(&session.cwd);
995                    if session_cwd == normalized_project && session.started_at >= threshold_ms {
996                        // Pick the earliest session started after our spawn
997                        // threshold. With multiple concurrent sessions for the
998                        // same project, each has a different spawned_at: the
999                        // older session's threshold is lower, so it must claim
1000                        // the first matching Claude session, not the latest one.
1001                        if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
1002                            best = Some((session.session_id, session.started_at));
1003                        }
1004                    }
1005                }
1006            }
1007        }
1008    }
1009
1010    best.map(|(id, _)| id)
1011}
1012
1013/// Normalize a path for comparison: lowercase, forward slashes, no trailing slash.
1014fn normalize_path(p: &str) -> String {
1015    p.replace('\\', "/")
1016        .trim_end_matches('/')
1017        .to_lowercase()
1018}
1019
1020/// Reduce a list of `UsageEntry` for a single session into a `SessionUsage`.
1021///
1022/// Preserves existing semantics:
1023/// - Primary model = the model used by the most messages (mode).
1024/// - Context window % uses the LAST entry's total input (API resends the full
1025///   conversation each turn; the last turn's input is the current usage).
1026/// - `launched_model` (from launch options) is used for context window sizing
1027///   because JSONL strips the `[1m]` suffix; fall back to mode when empty. The
1028///   window is never smaller than the session's peak single-entry context.
1029pub fn reduce_session(
1030    entries: &[UsageEntry],
1031    launched_model: &str,
1032    session_id_hint: &str,
1033) -> Option<SessionUsage> {
1034    if entries.is_empty() {
1035        return None;
1036    }
1037
1038    let mut input_tokens = 0u64;
1039    let mut output_tokens = 0u64;
1040    let mut cache_read = 0u64;
1041    let mut cache_write = 0u64;
1042    let mut model_counts: HashMap<&str, u64> = HashMap::new();
1043
1044    for e in entries {
1045        input_tokens += e.input_tokens;
1046        output_tokens += e.output_tokens;
1047        cache_read += e.cache_read_tokens;
1048        cache_write += e.cache_write_tokens;
1049        if !e.model.is_empty() {
1050            *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
1051        }
1052    }
1053
1054    let primary_model = model_counts
1055        .into_iter()
1056        .max_by_key(|(_, c)| *c)
1057        .map(|(m, _)| m.to_string())
1058        .unwrap_or_else(|| {
1059            entries
1060                .last()
1061                .map(|e| e.model.clone())
1062                .unwrap_or_default()
1063        });
1064
1065    // Sum per-entry cost so mixed-model sessions, the 1-hour cache premium, the
1066    // fast-mode multiplier, and web-search fees are all priced correctly — the
1067    // single source of truth is `cost_for_entry`.
1068    let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
1069
1070    let context_model = if launched_model.is_empty() {
1071        &primary_model
1072    } else {
1073        launched_model
1074    };
1075    // A session cannot exceed its window, so a peak above the model's window
1076    // means the window is larger than the model string implies (e.g. a launch
1077    // whose `[1m]` tag was stripped). Widen to the smallest standard tier.
1078    let peak_context = entries
1079        .iter()
1080        .map(|e| e.input_tokens + e.cache_read_tokens + e.cache_write_tokens)
1081        .max()
1082        .unwrap_or(0);
1083    let mut context_window = context_window_for_model(context_model);
1084    if peak_context > context_window {
1085        context_window = [200_000, 1_000_000]
1086            .into_iter()
1087            .find(|&w| peak_context <= w)
1088            .unwrap_or(peak_context);
1089    }
1090
1091    let last = entries.last().unwrap();
1092    let last_context_used =
1093        last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
1094    let context_pct = if context_window > 0 {
1095        (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
1096    } else {
1097        0.0
1098    };
1099
1100    let total = input_tokens + output_tokens + cache_read + cache_write;
1101    let total_context_sent = input_tokens + cache_read + cache_write;
1102
1103    // Cache hit % reflects "what fraction of billable input-side tokens came
1104    // from cache". Denominator includes cache_write because writes are fresh
1105    // input being laid down for later reuse (billed at ~125% of input rate) —
1106    // they are NOT a hit. Only cache_read counts as reusing prior work.
1107    let cache_denom = input_tokens + cache_read + cache_write;
1108    let cache_hit_pct = if cache_denom > 0 {
1109        (cache_read as f64 / cache_denom as f64) * 100.0
1110    } else {
1111        0.0
1112    };
1113
1114    let session_id = entries
1115        .first()
1116        .map(|e| e.session_id.clone())
1117        .filter(|s| !s.is_empty())
1118        .unwrap_or_else(|| session_id_hint.to_string());
1119
1120    Some(SessionUsage {
1121        model: primary_model,
1122        input_tokens,
1123        output_tokens,
1124        cache_read_tokens: cache_read,
1125        cache_write_tokens: cache_write,
1126        total_tokens: total,
1127        estimated_cost_usd: estimated_cost,
1128        context_window_size: context_window,
1129        context_window_used_pct: context_pct,
1130        current_context_tokens: last_context_used,
1131        total_context_sent,
1132        cache_hit_pct,
1133        message_count: entries.len() as u64,
1134        session_id,
1135    })
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use std::io::Write;
1142    use tempfile::TempDir;
1143
1144    #[test]
1145    fn test_encode_project_path_drive_root() {
1146        assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1147    }
1148
1149    #[test]
1150    fn test_encode_project_path_deep() {
1151        assert_eq!(
1152            encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1153            "C--Users-Seven-Desktop-SF-project"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_encode_project_path_forward_slashes() {
1159        assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1160    }
1161
1162    #[test]
1163    fn test_encode_project_path_trailing_slash() {
1164        assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1165    }
1166
1167    #[test]
1168    fn test_encode_project_path_macos() {
1169        assert_eq!(
1170            encode_project_path("/Users/seven/Loom"),
1171            "-Users-seven-Loom"
1172        );
1173    }
1174
1175    #[test]
1176    fn test_encode_project_path_linux() {
1177        assert_eq!(
1178            encode_project_path("/home/alice/projects/foo"),
1179            "-home-alice-projects-foo"
1180        );
1181    }
1182
1183    #[test]
1184    fn test_encode_project_path_unix_trailing_slash() {
1185        assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1186    }
1187
1188    #[test]
1189    fn test_normalize_path() {
1190        assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1191        assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1192        assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1193    }
1194
1195    fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1196        let tmp = TempDir::new().unwrap();
1197        let path = tmp.path().join("abc123.jsonl");
1198        let mut f = fs::File::create(&path).unwrap();
1199        for l in lines {
1200            writeln!(f, "{}", l).unwrap();
1201        }
1202        (tmp, path)
1203    }
1204
1205    fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1206        format!(
1207            r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"{model}","usage":{{"input_tokens":{inp},"output_tokens":{out},"cache_read_input_tokens":{cr},"cache_creation_input_tokens":{cw}}}}}}}"#
1208        )
1209    }
1210
1211    #[test]
1212    fn parse_deduplicates_repeated_message_ids() {
1213        // Mirrors real Claude Code JSONL: each assistant message is written 2-3
1214        // times with identical usage as the stream progresses.
1215        let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1216        let a1_dup = a1.clone();
1217        let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1218        let a2_dup = a2.clone();
1219        let a2_dup2 = a2.clone();
1220        let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1221
1222        let entries = parse_jsonl_entries(&path).unwrap().entries;
1223        assert_eq!(entries.len(), 2, "should dedup by message.id");
1224        assert_eq!(entries[0].input_tokens, 15);
1225        assert_eq!(entries[0].output_tokens, 1463);
1226        assert_eq!(entries[1].input_tokens, 10);
1227    }
1228
1229    #[test]
1230    fn parse_captures_entrypoint_from_assistant_row() {
1231        // Headless `claude -p` writes JSONL with entrypoint="sdk-cli" on every
1232        // assistant row. Interactive Claude Code writes "cli". A row missing
1233        // the field (legacy/malformed) yields None.
1234        let headless = r#"{"type":"assistant","timestamp":"2026-05-13T07:19:12.573Z","entrypoint":"sdk-cli","message":{"id":"msg_h1","model":"claude-opus-4-7","usage":{"input_tokens":5,"output_tokens":628,"cache_read_input_tokens":0,"cache_creation_input_tokens":14266}}}"#;
1235        let interactive = r#"{"type":"assistant","timestamp":"2026-05-13T08:00:00.000Z","entrypoint":"cli","message":{"id":"msg_i1","model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1236        let legacy = r#"{"type":"assistant","timestamp":"2026-05-13T09:00:00.000Z","message":{"id":"msg_l1","model":"claude-sonnet-4-6","usage":{"input_tokens":3,"output_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1237        let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1238
1239        let entries = parse_jsonl_entries(&path).unwrap().entries;
1240        assert_eq!(entries.len(), 3);
1241        assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1242        assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1243        assert_eq!(entries[2].entrypoint, None);
1244    }
1245
1246    #[test]
1247    fn parse_skips_non_assistant_entries() {
1248        let lines = [
1249            r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1250            r#"{"type":"permission-mode"}"#.to_string(),
1251            r#"{"type":"file-history-snapshot"}"#.to_string(),
1252            r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1253            assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1254        ];
1255        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1256        let (_tmp, path) = write_jsonl(&line_refs);
1257
1258        let entries = parse_jsonl_entries(&path).unwrap().entries;
1259        assert_eq!(entries.len(), 1);
1260        assert_eq!(entries[0].model, "claude-sonnet-4-6");
1261    }
1262
1263    #[test]
1264    fn parse_tolerates_malformed_lines_and_empty_lines() {
1265        let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1266        let lines = vec![
1267            "",
1268            "not json at all",
1269            r#"{"type":"assistant","message":null}"#,
1270            r#"{"type":"assistant","message":{"id":null}}"#,
1271            &good,
1272            "   ",
1273            r#"{"broken":"trailing"#,
1274        ];
1275        let (_tmp, path) = write_jsonl(&lines);
1276
1277        let entries = parse_jsonl_entries(&path).unwrap().entries;
1278        assert_eq!(entries.len(), 1);
1279    }
1280
1281    #[test]
1282    fn parse_extracts_session_id_from_file_stem() {
1283        let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1284        let (_tmp, path) = write_jsonl(&[&line]);
1285        let entries = parse_jsonl_entries(&path).unwrap().entries;
1286        assert_eq!(entries[0].session_id, "abc123");
1287    }
1288
1289    #[test]
1290    fn parse_parses_iso_timestamps() {
1291        let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1292        let (_tmp, path) = write_jsonl(&[&line]);
1293        let entries = parse_jsonl_entries(&path).unwrap().entries;
1294        // 2026-04-14T08:27:38.895Z → 1776155258895 ms
1295        assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1296    }
1297
1298    #[test]
1299    fn parse_skips_synthetic_model_rows() {
1300        // Claude Code / Agent SDK writes `model: "<synthetic>"` for internal
1301        // routing (skill listings, queue ops). These must not count as turns.
1302        let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1303        let synthetic = r#"{"type":"assistant","timestamp":"2026-04-11T00:27:39.000Z","message":{"id":"msg_synth","model":"<synthetic>","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1304        let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1305        let entries = parse_jsonl_entries(&path).unwrap().entries;
1306        assert_eq!(entries.len(), 1);
1307        assert_eq!(entries[0].model, "claude-opus-4-6");
1308    }
1309
1310    #[test]
1311    fn parse_handles_missing_usage_fields() {
1312        let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1313        let (_tmp, path) = write_jsonl(&[line]);
1314        let entries = parse_jsonl_entries(&path).unwrap().entries;
1315        assert_eq!(entries.len(), 1);
1316        assert_eq!(entries[0].input_tokens, 0);
1317        assert_eq!(entries[0].cache_read_tokens, 0);
1318    }
1319
1320    #[test]
1321    fn reduce_computes_primary_model_by_mode() {
1322        let e = |m: &str| UsageEntry {
1323            session_id: "s".into(),
1324            timestamp_ms: 0,
1325            model: m.into(),
1326            input_tokens: 1,
1327            output_tokens: 1,
1328            cache_read_tokens: 0,
1329            cache_write_tokens: 0,
1330            cache_write_1h_tokens: 0,
1331            web_search_requests: 0,
1332            speed: None,
1333            tool_uses: Vec::new(),
1334            text_chars: 0,
1335            activity: Activity::General,
1336            entrypoint: None,
1337        };
1338        let entries = vec![
1339            e("claude-opus-4-6"),
1340            e("claude-opus-4-6"),
1341            e("claude-opus-4-6"),
1342            e("claude-sonnet-4-6"),
1343        ];
1344        let u = reduce_session(&entries, "", "sid").unwrap();
1345        assert_eq!(u.model, "claude-opus-4-6");
1346        assert_eq!(u.message_count, 4);
1347    }
1348
1349    #[test]
1350    fn reduce_uses_launched_model_for_context_window() {
1351        let entries = vec![UsageEntry {
1352            session_id: "s".into(),
1353            timestamp_ms: 0,
1354            // JSONL strips the [1m] suffix
1355            model: "claude-opus-4-6".into(),
1356            input_tokens: 1000,
1357            output_tokens: 0,
1358            cache_read_tokens: 0,
1359            cache_write_tokens: 0,
1360            cache_write_1h_tokens: 0,
1361            web_search_requests: 0,
1362            speed: None,
1363            tool_uses: Vec::new(),
1364            text_chars: 0,
1365            activity: Activity::General,
1366            entrypoint: None,
1367        }];
1368        let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1369        let without = reduce_session(&entries, "", "sid").unwrap();
1370        assert_eq!(with_1m.context_window_size, 1_000_000);
1371        assert_eq!(without.context_window_size, 200_000);
1372    }
1373
1374    #[test]
1375    fn bare_opus_and_sonnet_5_plus_are_natively_1m() {
1376        for m in [
1377            "claude-opus-5-5",
1378            "claude-opus-5",
1379            "claude-sonnet-5",
1380            "claude-sonnet-5-5",
1381            "claude-opus-5-5[1m]",
1382        ] {
1383            assert_eq!(context_window_for_model(m), 1_000_000, "{m}");
1384        }
1385        for m in ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"] {
1386            assert_eq!(context_window_for_model(m), 200_000, "{m}");
1387        }
1388    }
1389
1390    #[test]
1391    fn reduce_widens_window_to_hold_peak_context() {
1392        let mk = |input: u64, cr: u64| UsageEntry {
1393            session_id: "s".into(),
1394            timestamp_ms: 0,
1395            model: "claude-opus-4-8".into(),
1396            input_tokens: input,
1397            output_tokens: 0,
1398            cache_read_tokens: cr,
1399            cache_write_tokens: 0,
1400            cache_write_1h_tokens: 0,
1401            web_search_requests: 0,
1402            speed: None,
1403            tool_uses: Vec::new(),
1404            text_chars: 0,
1405            activity: Activity::General,
1406            entrypoint: None,
1407        };
1408        // Peak (250K) is mid-session; the last entry (100K) drives the percentage.
1409        let entries = vec![mk(10, 250_000), mk(10, 99_990)];
1410        let u = reduce_session(&entries, "claude-opus-4-8", "sid").unwrap();
1411        assert_eq!(u.context_window_size, 1_000_000);
1412        assert_eq!(u.current_context_tokens, 100_000);
1413        assert!((u.context_window_used_pct - 10.0).abs() < 0.01);
1414    }
1415
1416    #[test]
1417    fn reduce_context_pct_uses_last_entry_only() {
1418        // Claude's context window reflects the LAST turn's input, not the sum.
1419        let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1420            session_id: "s".into(),
1421            timestamp_ms: 0,
1422            model: "claude-opus-4-6".into(),
1423            input_tokens: input,
1424            output_tokens: 0,
1425            cache_read_tokens: cr,
1426            cache_write_tokens: cw,
1427            cache_write_1h_tokens: 0,
1428            web_search_requests: 0,
1429            speed: None,
1430            tool_uses: Vec::new(),
1431            text_chars: 0,
1432            activity: Activity::General,
1433            entrypoint: None,
1434        };
1435        let entries = vec![
1436            mk(1000, 0, 0),
1437            mk(500, 0, 0),
1438            mk(5, 160_000, 0), // last: 160_005 / 200_000 = 80.0025%
1439        ];
1440        let u = reduce_session(&entries, "", "sid").unwrap();
1441        assert_eq!(u.current_context_tokens, 160_005);
1442        assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1443    }
1444
1445    #[test]
1446    fn reduce_cache_hit_pct() {
1447        let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1448            session_id: "s".into(),
1449            timestamp_ms: 0,
1450            model: "claude-opus-4-6".into(),
1451            input_tokens: input,
1452            output_tokens: 1,
1453            cache_read_tokens: cr,
1454            cache_write_tokens: cw,
1455            cache_write_1h_tokens: 0,
1456            web_search_requests: 0,
1457            speed: None,
1458            tool_uses: Vec::new(),
1459            text_chars: 0,
1460            activity: Activity::General,
1461            entrypoint: None,
1462        };
1463        // 3 fresh + 97 cache_read + 0 cache_write → 97 / 100 = 97%
1464        let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1465        assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1466
1467        // Cache writes count as NOT a hit: 10 fresh + 80 read + 10 write → 80%
1468        let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1469        assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1470
1471        // zero activity → 0 (not NaN)
1472        let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1473        assert_eq!(u.cache_hit_pct, 0.0);
1474    }
1475
1476    #[test]
1477    fn reduce_total_context_sent_is_cumulative() {
1478        let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1479            session_id: "s".into(),
1480            timestamp_ms: 0,
1481            model: "claude-opus-4-6".into(),
1482            input_tokens: input,
1483            output_tokens: 0,
1484            cache_read_tokens: cr,
1485            cache_write_tokens: cw,
1486            cache_write_1h_tokens: 0,
1487            web_search_requests: 0,
1488            speed: None,
1489            tool_uses: Vec::new(),
1490            text_chars: 0,
1491            activity: Activity::General,
1492            entrypoint: None,
1493        };
1494        let u = reduce_session(
1495            &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1496            "",
1497            "sid",
1498        )
1499        .unwrap();
1500        assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1501    }
1502
1503    #[test]
1504    fn reduce_cost_matches_opus_pricing() {
1505        // Opus: $5/$25 input/output, $0.50 read, $6.25 write per 1M tokens
1506        let entries = vec![UsageEntry {
1507            session_id: "s".into(),
1508            timestamp_ms: 0,
1509            model: "claude-opus-4-6".into(),
1510            input_tokens: 1_000_000,   // $5
1511            output_tokens: 1_000_000,  // $25
1512            cache_read_tokens: 1_000_000, // $0.50
1513            cache_write_tokens: 1_000_000, // $6.25
1514            cache_write_1h_tokens: 0,
1515            web_search_requests: 0,
1516            speed: None,
1517            tool_uses: Vec::new(),
1518            text_chars: 0,
1519            activity: Activity::General,
1520            entrypoint: None,
1521        }];
1522        let u = reduce_session(&entries, "", "sid").unwrap();
1523        assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1524    }
1525
1526    #[test]
1527    fn reduce_empty_returns_none() {
1528        assert!(reduce_session(&[], "", "sid").is_none());
1529    }
1530
1531    // ---------- content extraction ----------
1532
1533    fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1534        format!(
1535            r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-6","usage":{{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"content":{content_json}}}}}"#
1536        )
1537    }
1538
1539    #[test]
1540    fn parse_extracts_tool_use_names() {
1541        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"a.rs"}},{"type":"tool_use","id":"tu_2","name":"Read","input":{"file_path":"b.rs"}}]"#;
1542        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1543        let (_tmp, path) = write_jsonl(&[&line]);
1544        let entries = parse_jsonl_entries(&path).unwrap().entries;
1545        assert_eq!(entries.len(), 1);
1546        assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1547    }
1548
1549    #[test]
1550    fn parse_captures_text_char_count() {
1551        let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1552        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1553        let (_tmp, path) = write_jsonl(&[&line]);
1554        let entries = parse_jsonl_entries(&path).unwrap().entries;
1555        assert_eq!(entries[0].text_chars, 12); // "Hello world" + "!"
1556    }
1557
1558    #[test]
1559    fn parse_handles_missing_content_gracefully() {
1560        // Most existing JSONL rows have no `content` field at all (older
1561        // Claude Code versions, or when we parse a row before content arrives).
1562        let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1563        let (_tmp, path) = write_jsonl(&[&line]);
1564        let entries = parse_jsonl_entries(&path).unwrap().entries;
1565        assert_eq!(entries.len(), 1);
1566        assert!(entries[0].tool_uses.is_empty());
1567        assert_eq!(entries[0].text_chars, 0);
1568        assert_eq!(entries[0].activity, Activity::General);
1569    }
1570
1571    #[test]
1572    fn parse_classifies_edit_plus_debug_as_debugging() {
1573        // Real-world flow: user asks to fix a bug, assistant runs Edit.
1574        let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"Please fix the bug in login.rs"}}"#.to_string();
1575        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1576        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1577        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1578        let entries = parse_jsonl_entries(&path).unwrap().entries;
1579        assert_eq!(entries.len(), 1);
1580        assert_eq!(entries[0].activity, Activity::Debugging);
1581    }
1582
1583    #[test]
1584    fn parse_classifies_bash_pytest_as_testing() {
1585        let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1586        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1587        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1588        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1589        let entries = parse_jsonl_entries(&path).unwrap().entries;
1590        assert_eq!(entries[0].activity, Activity::Testing);
1591    }
1592
1593    #[test]
1594    fn parse_classifies_bash_git_as_gitops() {
1595        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1596        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1597        let (_tmp, path) = write_jsonl(&[&line]);
1598        let entries = parse_jsonl_entries(&path).unwrap().entries;
1599        assert_eq!(entries[0].activity, Activity::GitOps);
1600    }
1601
1602    #[test]
1603    fn parse_tool_result_row_does_not_overwrite_user_text() {
1604        // Sequence: user says "refactor the store", assistant runs a tool, the
1605        // injected tool_result row is also type="user", then another assistant
1606        // follow-up. The follow-up should still attribute to the original
1607        // "refactor" intent — tool_result rows must NOT overwrite last_user_text.
1608        let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1609        let asst1 = assistant_with_content(
1610            "msg_1",
1611            "2026-04-14T08:27:35.000Z",
1612            r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1613        );
1614        let tool_result = r#"{"type":"user","timestamp":"2026-04-14T08:27:36.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"file contents..."}]}}"#.to_string();
1615        let asst2 = assistant_with_content(
1616            "msg_2",
1617            "2026-04-14T08:27:40.000Z",
1618            r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1619        );
1620        let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1621        let entries = parse_jsonl_entries(&path).unwrap().entries;
1622        assert_eq!(entries.len(), 2);
1623        // First assistant: Read only → Exploration
1624        assert_eq!(entries[0].activity, Activity::Exploration);
1625        // Second assistant: Edit after "refactor" intent → Refactor (not Coding),
1626        // proving the tool_result row didn't wipe last_user_text.
1627        assert_eq!(entries[1].activity, Activity::Refactor);
1628    }
1629
1630    #[test]
1631    fn parse_classifies_agent_turn_as_delegation() {
1632        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1633        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1634        let (_tmp, path) = write_jsonl(&[&line]);
1635        let entries = parse_jsonl_entries(&path).unwrap().entries;
1636        assert_eq!(entries[0].activity, Activity::Delegation);
1637    }
1638
1639    #[test]
1640    fn parse_pure_text_turn_is_conversation() {
1641        let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1642        let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1643        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1644        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1645        let entries = parse_jsonl_entries(&path).unwrap().entries;
1646        assert_eq!(entries[0].activity, Activity::Conversation);
1647        assert!(entries[0].text_chars > 0);
1648    }
1649
1650    #[test]
1651    fn parse_user_array_content_extracts_text_blocks() {
1652        // Some Claude Code versions write user content as an array even for
1653        // plain text. Ensure we pull the text out, not skip the row.
1654        let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":[{"type":"text","text":"fix the crash"}]}}"#.to_string();
1655        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1656        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1657        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1658        let entries = parse_jsonl_entries(&path).unwrap().entries;
1659        assert_eq!(entries[0].activity, Activity::Debugging);
1660    }
1661
1662    // ---------- split-row content merge (Claude Code one-block-per-row) ----------
1663
1664    /// Helper: builds an assistant row that carries the same token usage
1665    /// numbers as the other rows for this `id` (matches what Claude Code
1666    /// actually writes — every row of a message repeats the final usage block).
1667    fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1668        format!(
1669            r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-7","usage":{{"input_tokens":6,"output_tokens":746,"cache_read_input_tokens":21859,"cache_creation_input_tokens":33027}},"content":{content_json}}}}}"#
1670        )
1671    }
1672
1673    #[test]
1674    fn parse_merges_tool_uses_across_split_content_rows() {
1675        // Claude Code 2.1.x emits one JSONL row per content block of an
1676        // assistant message: a `thinking` row, an optional `text` row, then
1677        // one row per `tool_use`. All rows share the same `message.id` and
1678        // the same `usage` block. The parser must merge tool_uses across rows
1679        // rather than dropping every row after the first.
1680        let thinking = assistant_split_row(
1681            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1682            "2026-05-13T09:09:03.500Z",
1683            r#"[{"type":"thinking","thinking":"plan"}]"#,
1684        );
1685        let text = assistant_split_row(
1686            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1687            "2026-05-13T09:09:03.550Z",
1688            r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1689        );
1690        let tu1 = assistant_split_row(
1691            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1692            "2026-05-13T09:09:03.600Z",
1693            r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1694        );
1695        let tu2 = assistant_split_row(
1696            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1697            "2026-05-13T09:09:03.700Z",
1698            r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1699        );
1700        let tu3 = assistant_split_row(
1701            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1702            "2026-05-13T09:09:03.800Z",
1703            r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1704        );
1705        let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1706
1707        let entries = parse_jsonl_entries(&path).unwrap().entries;
1708        assert_eq!(entries.len(), 1, "still one entry per message.id");
1709
1710        let e = &entries[0];
1711        assert_eq!(
1712            e.tool_uses,
1713            vec![
1714                "mcp__example__list_configs".to_string(),
1715                "mcp__example__get_report".to_string(),
1716                "Edit".to_string(),
1717            ],
1718            "all tool_use names merged in row order"
1719        );
1720        assert!(e.text_chars > 0, "text from the text row is preserved");
1721
1722        // Billing fields come from the first row (they repeat identically, so
1723        // first == last in practice). Importantly, the merged content must
1724        // NOT cause tokens to multiply.
1725        assert_eq!(e.input_tokens, 6);
1726        assert_eq!(e.output_tokens, 746);
1727        assert_eq!(e.cache_read_tokens, 21859);
1728        assert_eq!(e.cache_write_tokens, 33027);
1729    }
1730
1731    #[test]
1732    fn parse_split_row_classifies_against_merged_content() {
1733        // Bug pre-fix: first row is `thinking`, so `tool_uses` was empty and
1734        // activity classified as Conversation/General. After the fix the merged
1735        // content includes Edit, and a "fix the bug" user intent must route to
1736        // Debugging.
1737        let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"please fix the bug in auth.rs"}}"#.to_string();
1738        let thinking = assistant_split_row(
1739            "msg_split",
1740            "2026-05-13T09:09:01.000Z",
1741            r#"[{"type":"thinking","thinking":"investigating"}]"#,
1742        );
1743        let edit = assistant_split_row(
1744            "msg_split",
1745            "2026-05-13T09:09:02.000Z",
1746            r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1747        );
1748        let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1749
1750        let entries = parse_jsonl_entries(&path).unwrap().entries;
1751        assert_eq!(entries.len(), 1);
1752        assert_eq!(entries[0].activity, Activity::Debugging);
1753        assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1754    }
1755
1756    #[test]
1757    fn parse_split_row_bash_commands_feed_classifier() {
1758        // bash_cmds accumulate across rows too — pytest in a separate row
1759        // from the assistant's text must still classify as Testing.
1760        let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1761        let text = assistant_split_row(
1762            "msg_bash",
1763            "2026-05-13T09:09:01.000Z",
1764            r#"[{"type":"text","text":"Running the unit tests."}]"#,
1765        );
1766        let bash = assistant_split_row(
1767            "msg_bash",
1768            "2026-05-13T09:09:02.000Z",
1769            r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1770        );
1771        let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1772
1773        let entries = parse_jsonl_entries(&path).unwrap().entries;
1774        assert_eq!(entries[0].activity, Activity::Testing);
1775    }
1776
1777    // ---------- hook extraction ----------
1778
1779    #[test]
1780    fn parse_extracts_hook_success_events() {
1781        let hook_line = r#"{"type":"attachment","timestamp":"2026-04-15T18:08:50.000Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_abc","command":"verify-edit.sh","exitCode":0,"durationMs":90}}"#;
1782        let hook_fail = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:00.000Z","attachment":{"type":"hook_success","hookName":"PreToolUse:Bash","hookEvent":"PreToolUse","toolUseID":"tu_def","command":"lint-check.sh","exitCode":1,"durationMs":250}}"#;
1783        // hook_system_message should be ignored (no execution data)
1784        let hook_msg = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:01.000Z","attachment":{"type":"hook_system_message","hookName":"Stop","hookEvent":"Stop","content":"reminder text"}}"#;
1785        let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1786        let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1787
1788        let parsed = parse_jsonl_entries(&path).unwrap();
1789        assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1790        assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1791
1792        assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1793        assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1794        assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1795        assert_eq!(parsed.hooks[0].exit_code, 0);
1796        assert_eq!(parsed.hooks[0].duration_ms, 90);
1797
1798        assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1799
1800        assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1801        assert_eq!(parsed.hooks[1].exit_code, 1);
1802        assert_eq!(parsed.hooks[1].duration_ms, 250);
1803        assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1804    }
1805
1806    #[test]
1807    fn parse_ignores_attachment_without_hook_data() {
1808        // Non-hook attachments (e.g. file-history-snapshot) should be skipped.
1809        let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1810        let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1811        let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1812
1813        let parsed = parse_jsonl_entries(&path).unwrap();
1814        assert_eq!(parsed.entries.len(), 1);
1815        assert!(parsed.hooks.is_empty());
1816    }
1817
1818    #[test]
1819    fn snake_to_pascal_converts_correctly() {
1820        assert_eq!(snake_to_pascal("stop"), "Stop");
1821        assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1822        assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1823        assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1824    }
1825
1826    #[test]
1827    fn parse_extracts_system_hook_summary_v2() {
1828        // v2.1.119+ format: type=system, subtype=*_hook_summary
1829        let stop_summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"Running build checks...","durationMs":5227}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-04-25T09:06:12.854Z","uuid":"abc","toolUseID":"tu_xyz","sessionId":"sess-1","version":"2.1.119"}"#;
1830        let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1831        let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1832
1833        let parsed = parse_jsonl_entries(&path).unwrap();
1834        assert_eq!(parsed.entries.len(), 1);
1835        assert_eq!(parsed.hooks.len(), 1);
1836
1837        let h = &parsed.hooks[0];
1838        assert_eq!(h.hook_event, "Stop");
1839        assert_eq!(h.command, "Running build checks...");
1840        assert_eq!(h.duration_ms, 5227);
1841        assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1842        assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1843    }
1844
1845    #[test]
1846    fn parse_system_hook_summary_with_errors() {
1847        let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":["something broke"],"preventedContinuation":true,"timestamp":"2026-04-25T10:00:00.000Z","uuid":"def","toolUseID":"tu_err","sessionId":"sess-2","version":"2.1.119"}"#;
1848        let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1849        let (_tmp, path) = write_jsonl(&[summary, &asst]);
1850
1851        let parsed = parse_jsonl_entries(&path).unwrap();
1852        assert_eq!(parsed.hooks.len(), 1);
1853        assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1854    }
1855
1856    #[test]
1857    fn parse_system_hook_summary_multiple_hooks() {
1858        let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"tsc --noEmit","durationMs":1200},{"command":"cargo check","durationMs":3500}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T11:00:00.000Z","uuid":"ghi","toolUseID":"tu_multi","sessionId":"sess-3","version":"2.1.119"}"#;
1859        let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1860        let (_tmp, path) = write_jsonl(&[summary, &asst]);
1861
1862        let parsed = parse_jsonl_entries(&path).unwrap();
1863        assert_eq!(parsed.hooks.len(), 2);
1864        assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1865        assert_eq!(parsed.hooks[0].duration_ms, 1200);
1866        assert_eq!(parsed.hooks[1].command, "cargo check");
1867        assert_eq!(parsed.hooks[1].duration_ms, 3500);
1868        assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1869    }
1870
1871    #[test]
1872    fn hook_name_label_sanitizes_long_multiline_commands() {
1873        // /goal-style stop hook: command is a multi-line goal statement.
1874        // The synthesized hook_name must NOT include the full body — otherwise
1875        // every distinct goal creates a unique bucket and breaks the Usage page.
1876        let long_cmd = "Goal statement: Achieve >=3x better compression ratio than zstd -19 --long=27 and xz -9e on the Lichess 2024-01 corpus.\n\n3. Validation strategy:\n- (a) Fixed + held-out corpora.";
1877        let escaped = serde_json::to_string(long_cmd).unwrap();
1878        let summary = format!(
1879            r#"{{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{{"command":{escaped},"durationMs":42}}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T12:00:00.000Z","uuid":"goal","toolUseID":"tu_goal","sessionId":"sess-goal","version":"2.1.119"}}"#
1880        );
1881        let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1882        let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1883
1884        let parsed = parse_jsonl_entries(&path).unwrap();
1885        assert_eq!(parsed.hooks.len(), 1);
1886        let h = &parsed.hooks[0];
1887
1888        assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1889        assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1890        // Total label length is bounded: event prefix + ":" + max 64 chars + 1-char ellipsis.
1891        assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1892        assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1893        assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1894    }
1895
1896    #[test]
1897    fn hook_name_label_preserves_short_commands() {
1898        let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T13:00:00.000Z","uuid":"short","toolUseID":"tu_short","sessionId":"sess-short","version":"2.1.119"}"#;
1899        let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1900        let (_tmp, path) = write_jsonl(&[summary, &asst]);
1901
1902        let parsed = parse_jsonl_entries(&path).unwrap();
1903        assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1904    }
1905
1906    #[test]
1907    fn parse_handles_both_formats_in_same_file() {
1908        // Old v2.1.101 attachment format
1909        let old_hook = r#"{"type":"attachment","timestamp":"2026-04-23T14:05:05.937Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_old","command":"lint.sh","exitCode":0,"durationMs":90}}"#;
1910        // New v2.1.119 system format
1911        let new_hook = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":5000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T09:06:12.854Z","uuid":"both","toolUseID":"tu_new","sessionId":"sess-both","version":"2.1.119"}"#;
1912        let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1913        let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1914
1915        let parsed = parse_jsonl_entries(&path).unwrap();
1916        assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1917        assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1918        assert_eq!(parsed.hooks[1].hook_event, "Stop");
1919    }
1920
1921    // ---------- tiered cache / fast mode / web search pricing ----------
1922
1923    #[allow(clippy::too_many_arguments)]
1924    fn entry_for_cost(
1925        model: &str,
1926        input: u64,
1927        output: u64,
1928        cr: u64,
1929        cw_total: u64,
1930        cw_1h: u64,
1931        web: u64,
1932        speed: Option<&str>,
1933    ) -> UsageEntry {
1934        UsageEntry {
1935            session_id: "s".into(),
1936            timestamp_ms: 0,
1937            model: model.into(),
1938            input_tokens: input,
1939            output_tokens: output,
1940            cache_read_tokens: cr,
1941            cache_write_tokens: cw_total,
1942            cache_write_1h_tokens: cw_1h,
1943            web_search_requests: web,
1944            speed: speed.map(|s| s.to_string()),
1945            tool_uses: Vec::new(),
1946            text_chars: 0,
1947            activity: Activity::General,
1948            entrypoint: None,
1949        }
1950    }
1951
1952    #[test]
1953    fn fable_pricing_all_token_classes() {
1954        // Fable 5: $10 in / $50 out / $1 read / $12.50 5m-write / $20 1h-write per MTok.
1955        let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
1956        assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1957        let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
1958        assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1959        let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1960        assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
1961        let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
1962        assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1963        let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1964        assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1965    }
1966
1967    #[test]
1968    fn fable_has_no_fast_premium() {
1969        // Fast mode is Opus-only; an (unexpected) fast-tagged Fable turn must
1970        // not be multiplied — never overcount on a guess.
1971        let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1972        assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1973    }
1974
1975    #[test]
1976    fn fable_context_window_is_natively_1m() {
1977        // No `[1m]` suffix variant exists for Fable — 1M is the default.
1978        assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
1979        assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
1980        assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
1981    }
1982
1983    #[test]
1984    fn cost_splits_5m_and_1h_cache_writes() {
1985        // Opus: 5m write $6.25/M, 1h write $10/M.
1986        let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1987        assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
1988        let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
1989        assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
1990        // 400k 1h + 600k 5m = 0.4*10 + 0.6*6.25 = $7.75.
1991        let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
1992        assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
1993    }
1994
1995    #[test]
1996    fn cost_adds_flat_web_search_fee() {
1997        // 3 web searches → $0.03, independent of tokens.
1998        let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
1999        assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
2000    }
2001
2002    #[test]
2003    fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
2004        // Opus 4.8 fast = 2x: 1M input $5 → $10.
2005        let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
2006        assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
2007        let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2008        assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
2009        // Opus 4.6 fast = 6x → $30.
2010        let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2011        assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
2012        // Web fee is flat even under fast: $10 token + $0.01 search.
2013        let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
2014        assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
2015    }
2016
2017    #[test]
2018    fn fast_multiplier_only_for_known_opus() {
2019        assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
2020        assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
2021        assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
2022        assert_eq!(fast_multiplier_for_model("claude-opus-5"), 2.0);
2023        assert_eq!(fast_multiplier_for_model("claude-opus-5[1m]"), 2.0);
2024        assert_eq!(fast_multiplier_for_model("claude-opus-5-5"), 2.0);
2025        assert_eq!(fast_multiplier_for_model("claude-opus-5-5[1m]"), 2.0);
2026        assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
2027        assert_eq!(fast_multiplier_for_model("claude-fable-5-1"), 1.0);
2028        // Unknown future model → no premium (never overcount on a guess).
2029        assert_eq!(fast_multiplier_for_model("claude-opus-6"), 1.0);
2030    }
2031
2032    #[test]
2033    fn fable_5_1_reads_cache_at_a_quarter_of_fable_5() {
2034        // Fable 5.1: reads at 0.025× input ($0.25/MTok) instead of Fable 5's
2035        // 0.1× ($1/MTok). Every other rate is identical.
2036        let read51 = entry_for_cost("claude-fable-5-1", 0, 0, 1_000_000, 0, 0, 0, None);
2037        assert!((cost_for_entry(&read51) - 0.25).abs() < 1e-6);
2038        let read5 = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
2039        assert!((cost_for_entry(&read5) - 1.0).abs() < 1e-6);
2040
2041        let input = entry_for_cost("claude-fable-5-1", 1_000_000, 0, 0, 0, 0, 0, None);
2042        assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
2043        let output = entry_for_cost("claude-fable-5-1", 0, 1_000_000, 0, 0, 0, 0, None);
2044        assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
2045        let w5m = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 0, 0, None);
2046        assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
2047        let w1h = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2048        assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
2049    }
2050
2051    #[test]
2052    fn opus_5_5_is_cheaper_than_opus_5() {
2053        // Opus 5.5: $4/$20, 5m write $5, 1h write $8, cache read $0.20
2054        // (0.05× input). Opus 5 stays at $5/$25 with the 0.1× read.
2055        let in55 = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, None);
2056        assert!((cost_for_entry(&in55) - 4.0).abs() < 1e-6);
2057        let out55 = entry_for_cost("claude-opus-5-5", 0, 1_000_000, 0, 0, 0, 0, None);
2058        assert!((cost_for_entry(&out55) - 20.0).abs() < 1e-6);
2059        let read55 = entry_for_cost("claude-opus-5-5", 0, 0, 1_000_000, 0, 0, 0, None);
2060        assert!((cost_for_entry(&read55) - 0.2).abs() < 1e-6);
2061        let w5m = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 0, 0, None);
2062        assert!((cost_for_entry(&w5m) - 5.0).abs() < 1e-6);
2063        let w1h = entry_for_cost("claude-opus-5-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2064        assert!((cost_for_entry(&w1h) - 8.0).abs() < 1e-6);
2065
2066        // The 1M-context spelling and fast mode (2×) price the same model.
2067        let in55_1m = entry_for_cost("claude-opus-5-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2068        assert!((cost_for_entry(&in55_1m) - 4.0).abs() < 1e-6);
2069        let fast = entry_for_cost("claude-opus-5-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
2070        assert!((cost_for_entry(&fast) - 8.0).abs() < 1e-6);
2071
2072        // Opus 5 and the bare alias keep the older, dearer tier.
2073        let in5 = entry_for_cost("claude-opus-5", 1_000_000, 0, 0, 0, 0, 0, None);
2074        assert!((cost_for_entry(&in5) - 5.0).abs() < 1e-6);
2075        assert_eq!(pricing_for_model("opus").input, 5.0);
2076        assert_eq!(pricing_for_model("opus").cache_read, 0.5);
2077    }
2078
2079    #[test]
2080    fn sonnet_5_is_cheaper_than_sonnet_4_6() {
2081        // Sonnet 5: $2/$10 (standard price since 2026-09-01). Sonnet 4.6 and
2082        // earlier stay at $3/$15.
2083        let in5 = entry_for_cost("claude-sonnet-5", 1_000_000, 0, 0, 0, 0, 0, None);
2084        assert!((cost_for_entry(&in5) - 2.0).abs() < 1e-6);
2085        let out5 = entry_for_cost("claude-sonnet-5", 0, 1_000_000, 0, 0, 0, 0, None);
2086        assert!((cost_for_entry(&out5) - 10.0).abs() < 1e-6);
2087        let read5 = entry_for_cost("claude-sonnet-5", 0, 0, 1_000_000, 0, 0, 0, None);
2088        assert!((cost_for_entry(&read5) - 0.2).abs() < 1e-6);
2089        let w5m = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 0, 0, None);
2090        assert!((cost_for_entry(&w5m) - 2.5).abs() < 1e-6);
2091        let w1h = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2092        assert!((cost_for_entry(&w1h) - 4.0).abs() < 1e-6);
2093
2094        let in46 = entry_for_cost("claude-sonnet-4-6", 1_000_000, 0, 0, 0, 0, 0, None);
2095        assert!((cost_for_entry(&in46) - 3.0).abs() < 1e-6);
2096
2097        // The 1M-context spelling is the same model, not an unknown one.
2098        let in5_1m = entry_for_cost("claude-sonnet-5[1m]", 1_000_000, 0, 0, 0, 0, 0, None);
2099        assert!((cost_for_entry(&in5_1m) - 2.0).abs() < 1e-6);
2100    }
2101
2102    #[test]
2103    fn haiku_5_5_is_a_tenth_of_haiku_4_5() {
2104        // Up to 100k: $0.10/$0.50, 5m write $0.125, 1h write $0.20, read $0.01.
2105        for id in ["claude-haiku-5-5", "claude-haiku-5-5-20261007"] {
2106            let p = pricing_for_model(id);
2107            assert_eq!(p.input, 0.1, "{id}");
2108            assert_eq!(p.output, 0.5, "{id}");
2109            assert_eq!(p.cache_read, 0.01, "{id}");
2110            assert_eq!(p.cache_write, 0.125, "{id}");
2111            assert_eq!(p.cache_write_1h, 0.2, "{id}");
2112        }
2113        // Haiku 4.5 and the bare alias keep the older, dearer tier.
2114        for id in ["claude-haiku-4-5", "claude-haiku-4-5-20251001", "haiku"] {
2115            let p = pricing_for_model(id);
2116            assert_eq!(p.input, 1.0, "{id}");
2117            assert_eq!(p.output, 5.0, "{id}");
2118            assert_eq!(p.cache_read, 0.1, "{id}");
2119        }
2120    }
2121
2122    #[test]
2123    fn haiku_5_5_long_prompts_bill_the_higher_tier() {
2124        let model = "claude-haiku-5-5";
2125        // Prompt = input + cache read + cache write; exactly 100k stays on the base tier.
2126        let at = entry_for_cost(model, 50_000, 0, 30_000, 20_000, 0, 0, None);
2127        let want_at = (50_000.0 * 0.1 + 30_000.0 * 0.01 + 20_000.0 * 0.125) / 1e6;
2128        assert!((cost_for_entry(&at) - want_at).abs() < 1e-9);
2129        let over = entry_for_cost(model, 50_000, 0, 30_000, 20_001, 0, 0, None);
2130        let want_over = (50_000.0 * 0.5 + 30_000.0 * 0.05 + 20_001.0 * 0.625) / 1e6;
2131        assert!((cost_for_entry(&over) - want_over).abs() < 1e-9);
2132
2133        // Long tier: $2.50 output, $1 1h write.
2134        let out = entry_for_cost(model, 100_001, 1_000_000, 0, 0, 0, 0, None);
2135        assert!((cost_for_entry(&out) - (100_001.0 * 0.5 + 2_500_000.0) / 1e6).abs() < 1e-9);
2136        let w1h = entry_for_cost(model, 0, 0, 0, 200_000, 200_000, 0, None);
2137        assert!((cost_for_entry(&w1h) - 0.2).abs() < 1e-9);
2138
2139        // Other models are flat regardless of prompt size.
2140        let h45 = entry_for_cost("claude-haiku-4-5", 200_000, 0, 0, 0, 0, 0, None);
2141        assert!((cost_for_entry(&h45) - 0.2).abs() < 1e-9);
2142    }
2143
2144    #[test]
2145    fn haiku_5_5_context_window_is_natively_1m() {
2146        assert_eq!(context_window_for_model("claude-haiku-5-5"), 1_000_000);
2147        assert_eq!(context_window_for_model("claude-haiku-4-5"), 200_000);
2148        assert_eq!(context_window_for_model("claude-haiku-4-5-20251001"), 200_000);
2149    }
2150
2151    #[test]
2152    fn unknown_models_keep_the_conservative_sonnet_rate() {
2153        // An id with no parseable family version must not pick up the newer,
2154        // cheaper tier — undercounting a real spend is worse than the default.
2155        for id in ["", "<unknown>", "sonnet", "claude-3-5-sonnet-20241022"] {
2156            let p = pricing_for_model(id);
2157            assert_eq!(p.input, 3.0, "{id} should price at the Sonnet default");
2158        }
2159        // …except "fable", which is still recognised as the Fable family and
2160        // prices at the older (more expensive) cache-read rate.
2161        assert_eq!(pricing_for_model("fable").cache_read, 1.0);
2162    }
2163
2164    #[test]
2165    fn family_version_reads_minor_and_ignores_date_snapshots() {
2166        assert_eq!(family_version("claude-fable-5-1", "fable"), Some((5, 1)));
2167        assert_eq!(family_version("claude-fable-5", "fable"), Some((5, 0)));
2168        assert_eq!(family_version("claude-sonnet-5", "sonnet"), Some((5, 0)));
2169        assert_eq!(family_version("claude-sonnet-4-6", "sonnet"), Some((4, 6)));
2170        assert_eq!(family_version("claude-opus-5-5", "opus"), Some((5, 5)));
2171        assert_eq!(family_version("claude-opus-5-5[1m]", "opus"), Some((5, 5)));
2172        assert_eq!(
2173            family_version("claude-haiku-4-5-20251001", "haiku"),
2174            Some((4, 5))
2175        );
2176        // 8-digit segment is a date, not a minor version.
2177        assert_eq!(
2178            family_version("claude-sonnet-5-20260701", "sonnet"),
2179            Some((5, 0))
2180        );
2181        assert_eq!(family_version("fable", "fable"), None);
2182        assert_eq!(family_version("claude-opus-4-8", "sonnet"), None);
2183        // Legacy ids carry the version before the family name, so the segment
2184        // after it is a date — not major version 20241022.
2185        assert_eq!(family_version("claude-3-5-sonnet-20241022", "sonnet"), None);
2186    }
2187
2188    #[test]
2189    fn parse_extracts_cache_split_speed_and_web_search() {
2190        let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_1","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":673,"cache_read_input_tokens":0,"cache_creation_input_tokens":40702,"cache_creation":{"ephemeral_5m_input_tokens":700,"ephemeral_1h_input_tokens":40002},"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5},"speed":"fast"}}}"#;
2191        let (_tmp, path) = write_jsonl(&[line]);
2192        let entries = parse_jsonl_entries(&path).unwrap().entries;
2193        assert_eq!(entries.len(), 1);
2194        let e = &entries[0];
2195        assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
2196        assert_eq!(e.cache_write_1h_tokens, 40002);
2197        assert_eq!(e.web_search_requests, 2);
2198        assert_eq!(e.speed.as_deref(), Some("fast"));
2199    }
2200
2201    #[test]
2202    fn parse_cache_write_takes_max_of_flat_and_split() {
2203        // Nested split sums higher than the flat field → prefer the split.
2204        let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_2","model":"claude-opus-4-8","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":500,"ephemeral_1h_input_tokens":900}}}}"#;
2205        let (_tmp, path) = write_jsonl(&[line]);
2206        let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2207        assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
2208        assert_eq!(e.cache_write_1h_tokens, 900);
2209    }
2210
2211    #[test]
2212    fn parse_missing_nested_cache_falls_back_to_flat() {
2213        // Older rows: only the flat field → 1h = 0 (all treated as 5m), no speed/web.
2214        let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
2215        let (_tmp, path) = write_jsonl(&[&line]);
2216        let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2217        assert_eq!(e.cache_write_tokens, 30000);
2218        assert_eq!(e.cache_write_1h_tokens, 0);
2219        assert_eq!(e.web_search_requests, 0);
2220        assert_eq!(e.speed, None);
2221    }
2222
2223    #[test]
2224    fn reduce_session_prices_1h_cache_premium() {
2225        // 1M of 1h cache writes on opus = $10, not the old single-rate $6.25.
2226        let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2227        let u = reduce_session(&[e], "", "sid").unwrap();
2228        assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
2229    }
2230}