Skip to main content

lean_ctx/core/stats/
mod.rs

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