Skip to main content

claude_session_parser/
usage.rs

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