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