Skip to main content

lean_ctx/core/stats/
mod.rs

1mod format;
2mod io;
3mod model;
4
5pub use format::*;
6pub use model::*;
7
8use std::collections::HashMap;
9use std::sync::Mutex;
10use std::time::Instant;
11
12/// (current_state, baseline_from_disk, last_flush_time)
13static STATS_BUFFER: Mutex<Option<(StatsStore, StatsStore, Instant)>> = Mutex::new(None);
14
15const FLUSH_INTERVAL_SECS: u64 = 2;
16
17/// Daily savings history retained on disk (~10 years of active days). This is a
18/// storage/sync safety bound, NOT a display limit: all-time token totals are
19/// unbounded, and the cumulative-savings chart baselines any pre-window savings
20/// so it always reaches the true all-time total regardless of this window.
21pub(super) const MAX_DAILY_HISTORY_DAYS: usize = 3650;
22
23pub fn load() -> StatsStore {
24    let guard = STATS_BUFFER
25        .lock()
26        .unwrap_or_else(std::sync::PoisonError::into_inner);
27    if let Some((ref current, ref baseline, _)) = *guard {
28        let disk = io::load_from_disk();
29        return io::apply_deltas(&disk, current, baseline);
30    }
31    drop(guard);
32    io::load_from_disk()
33}
34
35/// Loads stats for **display**, summing across every auto-resolved data dir that
36/// holds a `stats.json` (#500). When the MCP server process and the CLI resolve
37/// different XDG dirs — the documented #408/#414 split, common when an agent host
38/// (e.g. a containerised Hermes) launches the MCP server with a different `HOME`
39/// or `XDG_*` than the user's shell — the bulk of the savings can land in a
40/// sibling tree. Reading only the primary dir then makes `gain` report `0` while
41/// the real data sits one directory over. Folding the siblings in keeps the
42/// headline honest regardless of which process wrote where.
43///
44/// Safe by construction:
45/// - **No-op without a split** — when only the primary dir has stats (the
46///   overwhelmingly common case) the result equals [`load`].
47/// - **Respects an explicit pin** — when `LEAN_CTX_DATA_DIR` is set the user has
48///   chosen exactly one dir, so nothing is auto-merged.
49/// - **Read-only** — never writes back; recording still targets the primary dir.
50pub fn load_for_display() -> StatsStore {
51    let primary = load();
52    // An explicit override means "use exactly this dir" — never auto-merge.
53    if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
54        return primary;
55    }
56    let primary_dir = crate::core::data_dir::lean_ctx_data_dir()
57        .ok()
58        .and_then(|p| std::fs::canonicalize(&p).ok());
59    let siblings: Vec<StatsStore> = crate::core::data_dir::all_data_dirs_with_stats()
60        .into_iter()
61        .filter(|d| std::fs::canonicalize(d).ok() != primary_dir)
62        .map(|d| io::load_from_dir(&d))
63        .collect();
64    aggregate_for_display(primary, &siblings)
65}
66
67/// Folds sibling-dir stores into the primary for display. Pure (no I/O, no
68/// globals) so the cross-dir summation is unit-testable. Reuses
69/// [`io::apply_deltas`] with a zero baseline so each sibling store is added in
70/// full (delta-from-empty == the whole store).
71fn aggregate_for_display(primary: StatsStore, siblings: &[StatsStore]) -> StatsStore {
72    let zero = StatsStore::default();
73    siblings
74        .iter()
75        .fold(primary, |acc, other| io::apply_deltas(&acc, other, &zero))
76}
77
78pub fn save(store: &StatsStore) {
79    io::locked_write(store);
80}
81
82/// Additively merge two stores (used by data consolidation to combine
83/// split stats.json files without losing data from either side).
84pub fn merge_additive(base: &StatsStore, other: &StatsStore) -> StatsStore {
85    let zero = StatsStore::default();
86    io::apply_deltas(base, other, &zero)
87}
88
89fn maybe_flush(store: &mut StatsStore, baseline: &mut StatsStore, last_flush: &mut Instant) {
90    if last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS
91        && let Some(merged) = io::merge_and_save(store, baseline)
92    {
93        *store = merged.clone();
94        *baseline = merged;
95        *last_flush = Instant::now();
96    }
97}
98
99pub fn flush() {
100    let mut guard = STATS_BUFFER
101        .lock()
102        .unwrap_or_else(std::sync::PoisonError::into_inner);
103    if let Some((ref mut store, ref mut baseline, ref mut last_flush)) = *guard
104        && let Some(merged) = io::merge_and_save(store, baseline)
105    {
106        *store = merged.clone();
107        *baseline = merged;
108        *last_flush = Instant::now();
109    }
110}
111
112/// Debounced flush: persists at most once per FLUSH_INTERVAL_SECS.
113/// Call from post_dispatch on every tool call to bound data loss
114/// on abrupt MCP termination to at most 2 seconds of events.
115pub fn flush_if_due() {
116    let due = {
117        let guard = STATS_BUFFER
118            .lock()
119            .unwrap_or_else(std::sync::PoisonError::into_inner);
120        match guard.as_ref() {
121            Some((_, _, last_flush)) => last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS,
122            None => false,
123        }
124    };
125    if due {
126        flush();
127    }
128}
129
130/// Adjust saved tokens after post-processing (terse, hints) changed the output size.
131/// Positive delta = savings were over-reported, negative = under-reported.
132///
133/// Persist immediately: this adjustment follows a durable `record()` and must not
134/// be lost when a short-lived MCP process exits before the periodic CEP flush.
135pub fn adjust_savings(command: &str, over_report_delta: i64) {
136    let mut guard = STATS_BUFFER
137        .lock()
138        .unwrap_or_else(std::sync::PoisonError::into_inner);
139    let Some((store, baseline, last_flush)) = guard.as_mut() else {
140        return;
141    };
142    apply_adjust(store, command, over_report_delta);
143    if let Some(merged) = io::merge_and_save(store, baseline) {
144        *store = merged.clone();
145        *baseline = merged;
146        *last_flush = Instant::now();
147    }
148    drop(guard);
149    record_for_project(|project| apply_adjust(project, command, over_report_delta));
150}
151
152fn apply_adjust(store: &mut StatsStore, command: &str, over_report_delta: i64) {
153    let cmd_key = format::normalize_command(command);
154    let stream_tracked = classify_command(&cmd_key) == TrafficClass::Compressible;
155    if over_report_delta > 0 {
156        let adj = over_report_delta as u64;
157        store.total_output_tokens = store.total_output_tokens.saturating_add(adj);
158        if let Some(cmd) = store.commands.get_mut(&cmd_key) {
159            cmd.output_tokens = cmd.output_tokens.saturating_add(adj);
160        }
161        if stream_tracked {
162            store.first_inject_tokens_saved = store.first_inject_tokens_saved.saturating_sub(adj);
163            store.active_tool_result_tokens_saved =
164                store.active_tool_result_tokens_saved.saturating_sub(adj);
165        }
166    } else {
167        let adj = over_report_delta.unsigned_abs();
168        store.total_output_tokens = store.total_output_tokens.saturating_sub(adj);
169        if let Some(cmd) = store.commands.get_mut(&cmd_key) {
170            cmd.output_tokens = cmd.output_tokens.saturating_sub(adj);
171        }
172        if stream_tracked {
173            store.first_inject_tokens_saved = store.first_inject_tokens_saved.saturating_add(adj);
174            if store.last_tool_result_turn > 0 {
175                store.active_tool_result_tokens_saved =
176                    store.active_tool_result_tokens_saved.saturating_add(adj);
177            }
178        }
179    }
180}
181
182pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
183    record_at_turn(command, input_tokens, output_tokens, 0);
184}
185
186pub fn record_reread(tokens_saved: usize) {
187    let mut guard = STATS_BUFFER
188        .lock()
189        .unwrap_or_else(std::sync::PoisonError::into_inner);
190    if guard.is_none() {
191        let disk = io::load_from_disk();
192        *guard = Some((disk.clone(), disk, Instant::now()));
193    }
194    let Some((store, baseline, last_flush)) = guard.as_mut() else {
195        return;
196    };
197    store.reread_tokens_saved = store
198        .reread_tokens_saved
199        .saturating_add(tokens_saved as u64);
200    if let Some(merged) = io::merge_and_save(store, baseline) {
201        *store = merged.clone();
202        *baseline = merged;
203        *last_flush = Instant::now();
204    }
205    drop(guard);
206    record_for_project(|project| {
207        project.reread_tokens_saved = project
208            .reread_tokens_saved
209            .saturating_add(tokens_saved as u64);
210    });
211}
212
213/// Records a tool result against an observed provider turn. `turn == 0` keeps
214/// daemon-free callers honest: first injection is known, re-read count is not.
215pub fn record_at_turn(command: &str, input_tokens: usize, output_tokens: usize, turn: u64) {
216    let mut guard = STATS_BUFFER
217        .lock()
218        .unwrap_or_else(std::sync::PoisonError::into_inner);
219    if guard.is_none() {
220        let disk = io::load_from_disk();
221        *guard = Some((disk.clone(), disk, Instant::now()));
222    }
223    let Some((store, baseline, last_flush)) = guard.as_mut() else {
224        return;
225    };
226
227    apply_record(store, command, input_tokens, output_tokens, turn);
228
229    // MCP stdio servers are routinely terminated without a graceful shutdown.
230    // Delaying this write made the append-only ledger survive while aggregate
231    // stats disappeared, so persist every completed accounting event.
232    if let Some(merged) = io::merge_and_save(store, baseline) {
233        *store = merged.clone();
234        *baseline = merged;
235        *last_flush = Instant::now();
236    }
237    drop(guard);
238    record_for_project(|project| {
239        apply_record(project, command, input_tokens, output_tokens, turn);
240    });
241}
242
243/// Books one event into the store of the project being worked on as well;
244/// `stats.json` stays the device total.
245fn record_for_project(apply: impl FnOnce(&mut StatsStore)) {
246    if let Some(key) = crate::core::savings_ledger::project_scope::current_project_key() {
247        io::update_project(&key, apply);
248    }
249}
250
251/// The usage counters of one project: the stores of its project keys, summed.
252pub fn load_for_project(scope: &crate::core::savings_ledger::ProjectScope) -> StatsStore {
253    let zero = StatsStore::default();
254    let hashes = scope.repo_hashes();
255    io::load_projects(hashes.iter().map(String::as_str))
256        .iter()
257        .fold(StatsStore::default(), |acc, other| {
258            io::apply_deltas(&acc, other, &zero)
259        })
260}
261
262/// Every project store by its project key (for the device view's split).
263pub fn load_all_projects() -> Vec<(String, StatsStore)> {
264    io::project_keys()
265        .into_iter()
266        .filter_map(|key| {
267            let store = io::load_projects(std::iter::once(key.as_str())).pop()?;
268            Some((key, store))
269        })
270        .collect()
271}
272
273/// Counts one tool result into `store` (totals, per command, per day).
274fn apply_record(
275    store: &mut StatsStore,
276    command: &str,
277    input_tokens: usize,
278    output_tokens: usize,
279    turn: u64,
280) {
281    // Tool-call accounting is durable per event, matching the savings ledger.
282    let now = chrono::Local::now();
283    let today = now.format("%Y-%m-%d").to_string();
284    let timestamp = now.to_rfc3339();
285
286    store.total_commands = store.total_commands.saturating_add(1);
287    store.total_input_tokens = store.total_input_tokens.saturating_add(input_tokens as u64);
288    store.total_output_tokens = store
289        .total_output_tokens
290        .saturating_add(output_tokens as u64);
291
292    if store.first_use.is_none() {
293        store.first_use = Some(timestamp.clone());
294    }
295    store.last_use = Some(timestamp);
296
297    let cmd_key = format::normalize_command(command);
298    if classify_command(&cmd_key) == TrafficClass::Compressible {
299        let saved = input_tokens.saturating_sub(output_tokens) as u64;
300        store.record_tool_result_savings(saved, turn);
301    }
302    store
303        .command_classes
304        .insert(cmd_key.clone(), classify_command(&cmd_key));
305    let is_compressible = classify_command(&cmd_key) == TrafficClass::Compressible;
306    let entry = store.commands.entry(cmd_key).or_default();
307    entry.count = entry.count.saturating_add(1);
308    entry.input_tokens = entry.input_tokens.saturating_add(input_tokens as u64);
309    entry.output_tokens = entry.output_tokens.saturating_add(output_tokens as u64);
310
311    let current_version = env!("CARGO_PKG_VERSION").to_string();
312    let ci = if is_compressible {
313        input_tokens as u64
314    } else {
315        0
316    };
317    let co = if is_compressible {
318        output_tokens as u64
319    } else {
320        0
321    };
322    if let Some(day) = store.daily.last_mut() {
323        if day.date == today {
324            day.commands = day.commands.saturating_add(1);
325            day.input_tokens = day.input_tokens.saturating_add(input_tokens as u64);
326            day.output_tokens = day.output_tokens.saturating_add(output_tokens as u64);
327            day.compressible_input_tokens = day.compressible_input_tokens.saturating_add(ci);
328            day.compressible_output_tokens = day.compressible_output_tokens.saturating_add(co);
329            day.version = current_version;
330        } else {
331            store.daily.push(DayStats {
332                date: today,
333                commands: 1,
334                input_tokens: input_tokens as u64,
335                output_tokens: output_tokens as u64,
336                compressible_input_tokens: ci,
337                compressible_output_tokens: co,
338                version: current_version,
339            });
340        }
341    } else {
342        store.daily.push(DayStats {
343            date: today,
344            commands: 1,
345            input_tokens: input_tokens as u64,
346            output_tokens: output_tokens as u64,
347            compressible_input_tokens: ci,
348            compressible_output_tokens: co,
349            version: current_version,
350        });
351    }
352
353    if store.daily.len() > MAX_DAILY_HISTORY_DAYS {
354        store
355            .daily
356            .drain(..store.daily.len() - MAX_DAILY_HISTORY_DAYS);
357    }
358}
359
360pub fn reset_cep() {
361    let mut guard = STATS_BUFFER
362        .lock()
363        .unwrap_or_else(std::sync::PoisonError::into_inner);
364    let mut store = io::load_from_disk();
365    store.cep = CepStats::default();
366    io::locked_write(&store);
367    *guard = Some((store.clone(), store, Instant::now()));
368}
369
370pub fn reset_all() {
371    let mut guard = STATS_BUFFER
372        .lock()
373        .unwrap_or_else(std::sync::PoisonError::into_inner);
374    let store = StatsStore::default();
375    io::locked_write(&store);
376    *guard = Some((store.clone(), store, Instant::now()));
377    crate::core::heatmap::reset();
378}
379
380pub fn load_stats() -> GainSummary {
381    let store = load();
382    let input_saved = store
383        .total_input_tokens
384        .saturating_sub(store.total_output_tokens);
385    GainSummary {
386        total_saved: input_saved,
387        total_calls: store.total_commands,
388    }
389}
390
391#[allow(clippy::too_many_arguments)]
392pub fn record_cep_session(
393    score: u32,
394    cache_hits: u64,
395    cache_reads: u64,
396    tokens_original: u64,
397    tokens_compressed: u64,
398    modes: &HashMap<String, u64>,
399    tool_calls: u64,
400    complexity: &str,
401) {
402    let mut guard = STATS_BUFFER
403        .lock()
404        .unwrap_or_else(std::sync::PoisonError::into_inner);
405    if guard.is_none() {
406        let disk = io::load_from_disk();
407        *guard = Some((disk.clone(), disk, Instant::now()));
408    }
409    let Some((store, baseline, last_flush)) = guard.as_mut() else {
410        return;
411    };
412
413    apply_cep_snapshot(
414        &mut store.cep,
415        std::process::id(),
416        score,
417        cache_hits,
418        cache_reads,
419        tokens_original,
420        tokens_compressed,
421        modes,
422        tool_calls,
423        complexity,
424    );
425
426    maybe_flush(store, baseline, last_flush);
427    drop(guard);
428    record_for_project(|project| {
429        apply_cep_snapshot(
430            &mut project.cep,
431            std::process::id(),
432            score,
433            cache_hits,
434            cache_reads,
435            tokens_original,
436            tokens_compressed,
437            modes,
438            tool_calls,
439            complexity,
440        );
441    });
442}
443
444/// Fold one CEP snapshot into `cep`. Pure (no globals, no I/O) so the
445/// delta/aggregation rules are unit-testable in isolation.
446///
447/// `cache_hits`, `cache_reads`, `tokens_original` and `tokens_compressed` arrive
448/// as **cumulative per-process** counters. For repeated snapshots within the same
449/// PID only the delta since the previous snapshot is added, so the lifetime
450/// totals keep tracking cache activity instead of freezing at the first
451/// checkpoint's value (#361). A new PID starts a fresh session and seeds the
452/// cumulative baselines.
453#[allow(clippy::too_many_arguments)]
454fn apply_cep_snapshot(
455    cep: &mut CepStats,
456    pid: u32,
457    score: u32,
458    cache_hits: u64,
459    cache_reads: u64,
460    tokens_original: u64,
461    tokens_compressed: u64,
462    modes: &HashMap<String, u64>,
463    tool_calls: u64,
464    complexity: &str,
465) {
466    let prev_original = cep.last_session_original.unwrap_or(0);
467    let prev_compressed = cep.last_session_compressed.unwrap_or(0);
468    let prev_cache_hits = cep.last_session_cache_hits.unwrap_or(0);
469    let prev_cache_reads = cep.last_session_cache_reads.unwrap_or(0);
470    let is_same_session = cep.last_session_pid == Some(pid);
471
472    if is_same_session {
473        cep.total_tokens_original += tokens_original.saturating_sub(prev_original);
474        cep.total_tokens_compressed += tokens_compressed.saturating_sub(prev_compressed);
475        cep.total_cache_hits += cache_hits.saturating_sub(prev_cache_hits);
476        cep.total_cache_reads += cache_reads.saturating_sub(prev_cache_reads);
477    } else {
478        cep.sessions += 1;
479        cep.total_cache_hits += cache_hits;
480        cep.total_cache_reads += cache_reads;
481        cep.total_tokens_original += tokens_original;
482        cep.total_tokens_compressed += tokens_compressed;
483
484        for (mode, count) in modes {
485            *cep.modes.entry(mode.clone()).or_insert(0) += count;
486        }
487    }
488
489    cep.last_session_pid = Some(pid);
490    cep.last_session_original = Some(tokens_original);
491    cep.last_session_compressed = Some(tokens_compressed);
492    cep.last_session_cache_hits = Some(cache_hits);
493    cep.last_session_cache_reads = Some(cache_reads);
494
495    let cache_hit_rate = if cache_reads > 0 {
496        (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
497    } else {
498        0
499    };
500
501    let compression_rate = if tokens_original > 0 {
502        ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
503            as u32
504    } else {
505        0
506    };
507
508    let total_modes = 6u32;
509    let mode_diversity =
510        ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
511
512    let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
513
514    cep.scores.push(CepSessionSnapshot {
515        timestamp: chrono::Local::now().to_rfc3339(),
516        score,
517        cache_hit_rate,
518        mode_diversity,
519        compression_rate,
520        tool_calls,
521        tokens_saved,
522        complexity: complexity.to_string(),
523    });
524
525    if cep.scores.len() > 100 {
526        cep.scores.drain(..cep.scores.len() - 100);
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn make_store(commands: u64, input: u64, output: u64) -> StatsStore {
535        StatsStore {
536            total_commands: commands,
537            total_input_tokens: input,
538            total_output_tokens: output,
539            ..Default::default()
540        }
541    }
542
543    /// #706: a corrupt `stats.json` must never be silently replaced by an
544    /// empty store. The loader quarantines the bytes to `stats.json.corrupt`
545    /// (recoverable), and an existing quarantine is never overwritten — the
546    /// older copy is the one closest to the lost history.
547    #[test]
548    fn corrupt_stats_file_is_quarantined_not_silently_reset() {
549        let dir = crate::core::data_dir::isolated_data_dir();
550        let stats_path = dir.path().join("stats.json");
551        let quarantine = dir.path().join("stats.json.corrupt");
552        let truncated = r#"{"total_commands": 15677, "total_input_tok"#;
553        std::fs::write(&stats_path, truncated).unwrap();
554
555        let loaded = io::load_from_disk();
556        assert_eq!(loaded.total_commands, 0, "fresh store after corruption");
557        assert!(
558            !stats_path.exists(),
559            "corrupt file must be moved aside, not left to be overwritten"
560        );
561        assert_eq!(
562            std::fs::read_to_string(&quarantine).unwrap(),
563            truncated,
564            "quarantine preserves the corrupt bytes verbatim for recovery"
565        );
566
567        // A second corruption must NOT clobber the first quarantine.
568        std::fs::write(&stats_path, "{ newer corruption").unwrap();
569        let loaded = io::load_from_disk();
570        assert_eq!(loaded.total_commands, 0);
571        assert_eq!(
572            std::fs::read_to_string(&quarantine).unwrap(),
573            truncated,
574            "the OLDER quarantine wins — it is closest to the lost history"
575        );
576
577        // And a healthy file still loads normally.
578        let healthy = make_store(42, 9000, 1000);
579        std::fs::write(&stats_path, serde_json::to_string(&healthy).unwrap()).unwrap();
580        assert_eq!(io::load_from_disk().total_commands, 42);
581    }
582
583    /// Work done in a project is counted into that project's own store (a
584    /// worktree into its main checkout's); outside any project into none.
585    #[test]
586    fn each_project_keeps_its_own_counters() {
587        let _dir = crate::core::data_dir::isolated_data_dir();
588        let projects = tempfile::tempdir().unwrap();
589        let base = crate::core::pathutil::safe_canonicalize_or_self(projects.path());
590        let alpha = base.join("alpha");
591        let beta = base.join("beta");
592        let worktree = base.join("alpha-wt");
593        std::fs::create_dir_all(alpha.join(".git/worktrees/wt")).unwrap();
594        std::fs::create_dir_all(beta.join(".git")).unwrap();
595        std::fs::create_dir_all(&worktree).unwrap();
596        std::fs::write(
597            worktree.join(".git"),
598            format!("gitdir: {}\n", alpha.join(".git/worktrees/wt").display()),
599        )
600        .unwrap();
601        for (folder, calls) in [(&alpha, 2), (&worktree, 1), (&beta, 1)] {
602            crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
603                std::cell::RefCell::new(Some(folder.clone())),
604                || {
605                    for _ in 0..calls {
606                        record_for_project(|s| apply_record(s, "ctx_read", 100, 40, 0));
607                    }
608                },
609            );
610        }
611
612        let scope =
613            crate::core::savings_ledger::ProjectScope::from_sessions(&alpha, std::iter::empty());
614        let mine = load_for_project(&scope);
615        assert_eq!(
616            mine.total_commands, 3,
617            "the worktree counts for its project"
618        );
619        assert_eq!(mine.commands["ctx_read"].input_tokens, 300);
620        let all = load_all_projects();
621        assert_eq!(all.len(), 2);
622        assert_eq!(
623            all.iter().map(|(_, s)| s.total_commands).sum::<u64>(),
624            4,
625            "the projects add up"
626        );
627    }
628
629    #[test]
630    fn aggregate_for_display_is_noop_without_siblings() {
631        // The common case: only the primary dir has stats. Aggregation must
632        // return the primary untouched so non-split users see no change (#500).
633        let primary = make_store(7, 1000, 250);
634        let agg = aggregate_for_display(primary.clone(), &[]);
635        assert_eq!(agg.total_commands, 7);
636        assert_eq!(agg.total_input_tokens, 1000);
637        assert_eq!(agg.total_output_tokens, 250);
638    }
639
640    #[test]
641    fn aggregate_for_display_sums_split_dirs() {
642        // A data-dir split (#408/#414/#500): the CLI's primary dir is empty but
643        // the MCP server wrote its savings into a sibling tree. The displayed
644        // total must reflect both so `gain` no longer reports a false `0`.
645        let primary = make_store(0, 0, 0);
646        let mcp_dir = make_store(12, 8000, 1200);
647        let legacy_dir = make_store(3, 500, 100);
648
649        let agg = aggregate_for_display(primary, &[mcp_dir, legacy_dir]);
650
651        assert_eq!(agg.total_commands, 15, "12 (mcp) + 3 (legacy)");
652        assert_eq!(agg.total_input_tokens, 8500);
653        assert_eq!(agg.total_output_tokens, 1300);
654        let saved = agg
655            .total_input_tokens
656            .saturating_sub(agg.total_output_tokens);
657        assert_eq!(saved, 7200, "savings surface despite an empty primary dir");
658    }
659
660    #[test]
661    fn apply_deltas_merges_mcp_and_shell() {
662        let baseline = make_store(0, 0, 0);
663        let mut current = make_store(0, 0, 0);
664        current.total_commands = 5;
665        current.total_input_tokens = 1000;
666        current.total_output_tokens = 200;
667        current.commands.insert(
668            "ctx_read".to_string(),
669            CommandStats {
670                count: 5,
671                input_tokens: 1000,
672                output_tokens: 200,
673            },
674        );
675        current
676            .command_classes
677            .insert("ctx_read".into(), TrafficClass::Compressible);
678
679        let mut disk = make_store(20, 500, 490);
680        disk.commands.insert(
681            "echo".to_string(),
682            CommandStats {
683                count: 20,
684                input_tokens: 500,
685                output_tokens: 490,
686            },
687        );
688
689        let merged = io::apply_deltas(&disk, &current, &baseline);
690
691        assert_eq!(merged.total_commands, 25);
692        assert_eq!(merged.total_input_tokens, 1500);
693        assert_eq!(merged.total_output_tokens, 690);
694        assert_eq!(merged.commands["ctx_read"].count, 5);
695        assert_eq!(merged.commands["echo"].count, 20);
696        assert_eq!(
697            merged.command_classes["ctx_read"],
698            TrafficClass::Compressible
699        );
700    }
701
702    #[test]
703    fn apply_deltas_incremental_flush() {
704        let baseline = make_store(10, 200, 100);
705        let current = make_store(15, 700, 300);
706
707        let disk = make_store(30, 600, 500);
708
709        let merged = io::apply_deltas(&disk, &current, &baseline);
710
711        assert_eq!(merged.total_commands, 35);
712        assert_eq!(merged.total_input_tokens, 1100);
713        assert_eq!(merged.total_output_tokens, 700);
714    }
715
716    #[test]
717    fn apply_deltas_merges_stream_counters_without_replaying_baseline() {
718        let mut baseline = StatsStore {
719            first_inject_tokens_saved: 1_000,
720            reread_tokens_saved: 2_000,
721            active_tool_result_tokens_saved: 500,
722            last_tool_result_turn: 8,
723            stream_tracked_results: 2,
724            ..StatsStore::default()
725        };
726        let mut current = baseline.clone();
727        current.first_inject_tokens_saved = 1_400;
728        current.reread_tokens_saved = 2_900;
729        current.active_tool_result_tokens_saved = 700;
730        current.last_tool_result_turn = 10;
731        current.stream_tracked_results = 3;
732        let disk = StatsStore {
733            first_inject_tokens_saved: 5_000,
734            reread_tokens_saved: 7_000,
735            active_tool_result_tokens_saved: 1_000,
736            last_tool_result_turn: 9,
737            stream_tracked_results: 10,
738            ..StatsStore::default()
739        };
740
741        let merged = io::apply_deltas(&disk, &current, &baseline);
742        assert_eq!(merged.first_inject_tokens_saved, 5_400);
743        assert_eq!(merged.reread_tokens_saved, 7_900);
744        assert_eq!(merged.active_tool_result_tokens_saved, 1_200);
745        assert_eq!(merged.last_tool_result_turn, 10);
746        assert_eq!(merged.stream_tracked_results, 11);
747
748        baseline.first_inject_tokens_saved = current.first_inject_tokens_saved;
749        let no_replay = io::apply_deltas(&merged, &current, &baseline);
750        assert_eq!(no_replay.first_inject_tokens_saved, 5_400);
751    }
752
753    #[test]
754    fn merge_and_save_keeps_delta_when_lock_is_busy() {
755        let dir = crate::core::data_dir::isolated_data_dir();
756        let baseline = make_store(10, 200, 100);
757        let current = make_store(11, 300, 120);
758        let lock_path = dir.path().join(".stats.lock");
759        std::fs::write(&lock_path, "busy").unwrap();
760
761        assert!(io::merge_and_save(&current, &baseline).is_none());
762
763        std::fs::remove_file(lock_path).unwrap();
764        let merged = io::merge_and_save(&current, &baseline).unwrap();
765        assert_eq!(merged.total_commands, 1);
766        assert_eq!(merged.total_input_tokens, 100);
767        assert_eq!(merged.total_output_tokens, 20);
768    }
769
770    #[test]
771    fn apply_deltas_preserves_disk_commands() {
772        let baseline = make_store(0, 0, 0);
773        let mut current = make_store(2, 100, 50);
774        current.commands.insert(
775            "ctx_read".to_string(),
776            CommandStats {
777                count: 2,
778                input_tokens: 100,
779                output_tokens: 50,
780            },
781        );
782
783        let mut disk = make_store(10, 300, 280);
784        disk.commands.insert(
785            "echo".to_string(),
786            CommandStats {
787                count: 8,
788                input_tokens: 200,
789                output_tokens: 200,
790            },
791        );
792        disk.commands.insert(
793            "ctx_read".to_string(),
794            CommandStats {
795                count: 3,
796                input_tokens: 150,
797                output_tokens: 80,
798            },
799        );
800
801        let merged = io::apply_deltas(&disk, &current, &baseline);
802
803        assert_eq!(merged.commands["echo"].count, 8);
804        assert_eq!(merged.commands["ctx_read"].count, 5);
805        assert_eq!(merged.commands["ctx_read"].input_tokens, 250);
806    }
807
808    #[test]
809    fn merge_daily_combines_same_date() {
810        let baseline_daily = vec![];
811        let current_daily = vec![DayStats {
812            date: "2026-04-18".to_string(),
813            commands: 5,
814            input_tokens: 1000,
815            output_tokens: 200,
816            version: "3.7.0".to_string(),
817            compressible_input_tokens: 0,
818            compressible_output_tokens: 0,
819        }];
820        let mut merged_daily = vec![DayStats {
821            date: "2026-04-18".to_string(),
822            commands: 20,
823            input_tokens: 500,
824            output_tokens: 490,
825            version: String::new(),
826            compressible_input_tokens: 0,
827            compressible_output_tokens: 0,
828        }];
829
830        io::merge_daily(&mut merged_daily, &current_daily, &baseline_daily);
831
832        assert_eq!(merged_daily.len(), 1);
833        assert_eq!(merged_daily[0].commands, 25);
834        assert_eq!(merged_daily[0].input_tokens, 1500);
835        // #307: the most recent known version is carried into the merge.
836        assert_eq!(merged_daily[0].version, "3.7.0");
837    }
838
839    #[test]
840    fn cep_snapshot_seeds_new_session() {
841        let mut cep = CepStats::default();
842        let modes = HashMap::from([("full".to_string(), 3)]);
843        apply_cep_snapshot(&mut cep, 100, 80, 5, 10, 1000, 200, &modes, 4, "Medium");
844        assert_eq!(cep.sessions, 1);
845        assert_eq!(cep.total_cache_hits, 5);
846        assert_eq!(cep.total_cache_reads, 10);
847        assert_eq!(cep.total_tokens_original, 1000);
848        assert_eq!(cep.total_tokens_compressed, 200);
849        assert_eq!(cep.scores.len(), 1);
850    }
851
852    #[test]
853    fn cep_snapshot_same_pid_accumulates_cache_delta() {
854        // #361: repeated snapshots within one process must keep counting cache
855        // hits/reads (cumulative counters → add the delta), not freeze at the
856        // first checkpoint's value while only tokens advanced.
857        let mut cep = CepStats::default();
858        let modes = HashMap::new();
859        apply_cep_snapshot(&mut cep, 100, 80, 2, 4, 500, 100, &modes, 2, "Low");
860        // Same PID, cumulative counters grew: hits 2→9, reads 4→20.
861        apply_cep_snapshot(&mut cep, 100, 85, 9, 20, 1500, 300, &modes, 6, "Low");
862
863        assert_eq!(cep.sessions, 1, "same PID must not start a new session");
864        assert_eq!(cep.total_cache_hits, 9, "2 + delta(9-2)");
865        assert_eq!(cep.total_cache_reads, 20, "4 + delta(20-4)");
866        assert_eq!(cep.total_tokens_original, 1500);
867        assert_eq!(cep.total_tokens_compressed, 300);
868        assert_eq!(cep.scores.len(), 2);
869    }
870
871    #[test]
872    fn cep_snapshot_new_pid_starts_fresh_session() {
873        let mut cep = CepStats::default();
874        let modes = HashMap::new();
875        apply_cep_snapshot(&mut cep, 100, 80, 5, 10, 1000, 200, &modes, 4, "Medium");
876        apply_cep_snapshot(&mut cep, 200, 80, 3, 6, 800, 150, &modes, 4, "Medium");
877        assert_eq!(cep.sessions, 2);
878        assert_eq!(
879            cep.total_cache_hits, 8,
880            "5 (session 1) + 3 (session 2, fresh)"
881        );
882        assert_eq!(cep.total_cache_reads, 16);
883    }
884}