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