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