Skip to main content

claude_session_parser/
usage.rs

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