Skip to main content

lean_ctx/core/stats/format/
cep.rs

1//! CEP (Compact Efficiency Protocol) report: live MCP stats + scorecard.
2
3use super::util::{active_theme, format_big, format_pct_1dp, usd_estimate};
4use crate::core::theme::{self, Theme};
5
6#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
7fn format_cep_live(lv: &serde_json::Value, t: &Theme) -> String {
8    let mut out = Vec::new();
9    let rst = theme::rst();
10    let bold = theme::bold();
11    let dim = theme::dim();
12
13    let score = lv["cep_score"].as_u64().unwrap_or(0) as u32;
14    let cache_util = lv["cache_utilization"].as_u64().unwrap_or(0);
15    let mode_div = lv["mode_diversity"].as_u64().unwrap_or(0);
16    let comp_rate = lv["compression_rate"].as_u64().unwrap_or(0);
17    let tok_saved = lv["tokens_saved"].as_u64().unwrap_or(0);
18    let tok_orig = lv["tokens_original"].as_u64().unwrap_or(0);
19    let tool_calls = lv["tool_calls"].as_u64().unwrap_or(0);
20    let cache_hits = lv["cache_hits"].as_u64().unwrap_or(0);
21    let total_reads = lv["total_reads"].as_u64().unwrap_or(0);
22    let complexity = lv["task_complexity"].as_str().unwrap_or("Standard");
23
24    out.push(String::new());
25    out.push(format!(
26        "  {icon} {brand} {cep}  {dim}Live Session (no historical data yet){rst}",
27        icon = t.header_icon(),
28        brand = t.brand_title(),
29        cep = t.section_title("CEP"),
30    ));
31    out.push(format!("  {ln}", ln = t.border_line(56)));
32    out.push(String::new());
33
34    let txt = t.text.fg();
35    let sc = t.success.fg();
36    let sec = t.secondary.fg();
37
38    out.push(format!(
39        "  {bold}{txt}CEP Score{rst}         {bold}{pc}{score:>3}/100{rst}",
40        pc = t.pct_color(score as f64),
41    ));
42    out.push(format!(
43        "  {bold}{txt}Cache Hit Rate{rst}    {bold}{pc}{cache_util}%{rst}  {dim}({cache_hits} hits / {total_reads} reads){rst}",
44        pc = t.pct_color(cache_util as f64),
45    ));
46    out.push(format!(
47        "  {bold}{txt}Mode Diversity{rst}    {bold}{pc}{mode_div}%{rst}",
48        pc = t.pct_color(mode_div as f64),
49    ));
50    out.push(format!(
51        "  {bold}{txt}Compression{rst}       {bold}{pc}{comp_rate}%{rst}  {dim}({} → {}){rst}",
52        format_big(tok_orig),
53        format_big(tok_orig.saturating_sub(tok_saved)),
54        pc = t.pct_color(comp_rate as f64),
55    ));
56    out.push(format!(
57        "  {bold}{txt}Tokens Saved{rst}      {bold}{sc}{}{rst}  {dim}(≈ {}){rst}",
58        format_big(tok_saved),
59        usd_estimate(tok_saved),
60    ));
61    out.push(format!(
62        "  {bold}{txt}Tool Calls{rst}        {bold}{sec}{tool_calls}{rst}"
63    ));
64    out.push(format!(
65        "  {bold}{txt}Complexity{rst}        {dim}{complexity}{rst}"
66    ));
67    out.push(String::new());
68    out.push(format!("  {ln}", ln = t.border_line(56)));
69    out.push(format!(
70        "  {dim}This is live data from the current MCP session.{rst}"
71    ));
72    out.push(format!(
73        "  {dim}Historical CEP trends appear after more sessions.{rst}"
74    ));
75    out.push(String::new());
76
77    out.join("\n")
78}
79
80fn load_mcp_live() -> Option<serde_json::Value> {
81    // Must resolve the same directory the writer uses
82    // (`server_metrics::write_mcp_live_stats` → `state_dir()`). `mcp-live.json`
83    // is runtime STATE (GH #408); `state_dir()` honors `LEAN_CTX_STATE_DIR` and
84    // collapses to the legacy data dir for single-dir installs, so live CEP data
85    // is found regardless of layout (#361).
86    let path = crate::core::paths::state_dir().ok()?.join("mcp-live.json");
87    let content = std::fs::read_to_string(path).ok()?;
88    serde_json::from_str(&content).ok()
89}
90
91/// Renders the full CEP (Cognitive Efficiency Protocol) report with themes.
92#[allow(clippy::many_single_char_names)] // ANSI formatting: t=theme, r=reset, b=bold, d=dim
93pub fn format_cep_report() -> String {
94    let theme = active_theme();
95    let store = crate::core::stats::load();
96    let cep = &store.cep;
97    let live = load_mcp_live();
98    let mut out = Vec::new();
99    let rst = theme::rst();
100    let bold = theme::bold();
101    let dim = theme::dim();
102
103    if cep.sessions == 0 && live.is_none() {
104        // A short / phase-isolated bridge run can do real work (commands, proxy
105        // turns, token savings) before any per-session CEP snapshot lands. Don't
106        // mislabel that as "nothing recorded" — point at the meter that has the
107        // real numbers (#361).
108        let proxy_turns = crate::proxy::metrics::load_persisted().map_or(0, |m| m.requests_total);
109        if store.total_commands > 0 || proxy_turns > 0 {
110            return format!(
111                "{dim}No per-session CEP snapshot yet, but lean-ctx is active \
112                 ({cmds} commands, {proxy_turns} proxy turns).{rst}\n\
113                 Run `lean-ctx gain` for token savings and net-of-injection bill impact.",
114                cmds = store.total_commands,
115            );
116        }
117        return format!(
118            "{dim}No CEP sessions recorded yet.{rst}\n\
119             Use lean-ctx as an MCP server in your editor to start tracking.\n\
120             CEP metrics are recorded automatically during MCP sessions."
121        );
122    }
123
124    if cep.sessions == 0
125        && let Some(ref lv) = live
126    {
127        return format_cep_live(lv, &theme);
128    }
129
130    let total_saved = cep
131        .total_tokens_original
132        .saturating_sub(cep.total_tokens_compressed);
133    let overall_compression = if cep.total_tokens_original > 0 {
134        total_saved as f64 / cep.total_tokens_original as f64 * 100.0
135    } else {
136        0.0
137    };
138    let cache_hit_rate = if cep.total_cache_reads > 0 {
139        cep.total_cache_hits as f64 / cep.total_cache_reads as f64 * 100.0
140    } else {
141        0.0
142    };
143    let avg_score = if cep.scores.is_empty() {
144        0.0
145    } else {
146        cep.scores.iter().map(|s| s.score as f64).sum::<f64>() / cep.scores.len() as f64
147    };
148    let latest_score = cep.scores.last().map_or(0, |s| s.score);
149
150    let shell_saved = store
151        .total_input_tokens
152        .saturating_sub(store.total_output_tokens)
153        .saturating_sub(total_saved);
154    let total_all_saved = store
155        .total_input_tokens
156        .saturating_sub(store.total_output_tokens);
157    let cep_share = if total_all_saved > 0 {
158        total_saved as f64 / total_all_saved as f64 * 100.0
159    } else {
160        0.0
161    };
162
163    let txt = theme.text.fg();
164    let sc = theme.success.fg();
165    let sec = theme.secondary.fg();
166    let wrn = theme.warning.fg();
167
168    let cep_w = 60;
169    let cep_ss = theme.box_side_square();
170    let cep_line = |content: &str| -> String {
171        let padded = theme::pad_right(content, cep_w);
172        format!("  {cep_ss}{padded}{cep_ss}")
173    };
174
175    out.push(String::new());
176    out.push(format!("  {}", theme.box_top(cep_w)));
177    let cep_side = theme.box_side();
178    out.push(format!(
179        "  {cep_side}{}{cep_side}",
180        theme::pad_right(
181            &format!(
182                "  {icon}  {brand}  {dim}CEP Report{rst}",
183                icon = theme.header_icon(),
184                brand = theme.brand_title(),
185            ),
186            cep_w,
187        )
188    ));
189    out.push(format!("  {}", theme.box_bottom(cep_w)));
190    out.push(String::new());
191
192    let score_ratio = (latest_score as f64 / 100.0).min(1.0);
193    let score_bar = theme.gradient_bar(score_ratio, 20);
194    let score_pc = theme.pct_color(latest_score as f64);
195
196    out.push(format!("  {}", theme.box_top_labeled(cep_w, "CEP SCORE")));
197    out.push(cep_line(&format!(
198        "  {score_bar}  {score_pc}{bold}{latest_score}/100{rst}  {dim}avg: {avg_score:.0}{rst}"
199    )));
200    out.push(cep_line(&format!(
201        "  {bold}{txt}Sessions{rst} {sec}{}{rst}  {bold}{txt}Cache{rst} {pc}{cache_hit_rate:.1}%{rst}  {bold}{txt}Compression{rst} {pc2}{overall_compression:.1}%{rst}",
202        cep.sessions,
203        pc = theme.pct_color(cache_hit_rate),
204        pc2 = theme.pct_color(overall_compression),
205    )));
206    out.push(cep_line(&format!(
207        "  {bold}{txt}Saved{rst} {sc}{}{rst} {dim}({} → {} · ≈ {}){rst}",
208        format_big(total_saved),
209        format_big(cep.total_tokens_original),
210        format_big(cep.total_tokens_compressed),
211        usd_estimate(total_saved),
212    )));
213    out.push(format!("  {}", theme.box_bottom_square(cep_w)));
214    out.push(String::new());
215
216    out.push(format!(
217        "  {}",
218        theme.box_top_labeled(cep_w, "SAVINGS BREAKDOWN")
219    ));
220
221    let bar_w = 26;
222    let shell_ratio = if total_all_saved > 0 {
223        shell_saved as f64 / total_all_saved as f64
224    } else {
225        0.0
226    };
227    let cep_ratio = if total_all_saved > 0 {
228        total_saved as f64 / total_all_saved as f64
229    } else {
230        0.0
231    };
232    let m = theme.muted.fg();
233    let shell_bar = theme::pad_right(&theme.gradient_bar(shell_ratio, bar_w), bar_w);
234    // `cep_share` is already a percentage (0..100), so the shell share is its
235    // complement — not `(1.0 - cep_share) * 100`, which produced absurd values.
236    let shell_pct_display = format_pct_1dp(100.0 - cep_share);
237    out.push(cep_line(&format!(
238        "  {m}Shell Hook{rst}  {shell_bar} {bold}{:>6}{rst} {dim}({shell_pct_display}){rst}",
239        format_big(shell_saved),
240    )));
241    let cep_bar = theme::pad_right(&theme.gradient_bar(cep_ratio, bar_w), bar_w);
242    let cep_pct_display = format_pct_1dp(cep_share);
243    out.push(cep_line(&format!(
244        "  {m}MCP/CEP{rst}     {cep_bar} {bold}{:>6}{rst} {dim}({cep_pct_display}){rst}",
245        format_big(total_saved),
246    )));
247    out.push(format!("  {}", theme.box_bottom_square(cep_w)));
248    out.push(String::new());
249
250    if total_saved == 0 && cep.modes.is_empty() {
251        if store.total_commands > 20 {
252            out.push(format!(
253                "  {wrn}⚠  MCP tools configured but not being used by your AI client.{rst}"
254            ));
255            out.push(
256                "     Your AI client may be using native Read/Shell instead of ctx_read/ctx_shell."
257                    .to_string(),
258            );
259            out.push(format!(
260                "     Run {sec}lean-ctx init{rst} to update rules, then restart your AI session."
261            ));
262            out.push(format!(
263                "     Run {sec}lean-ctx doctor{rst} for detailed adoption diagnostics."
264            ));
265        } else {
266            out.push(format!(
267                "  {wrn}⚠  MCP server not configured.{rst} Shell hook compresses output, but"
268            ));
269            out.push(
270                "     full token savings require MCP tools (ctx_read, ctx_shell, ctx_search)."
271                    .to_string(),
272            );
273            out.push(format!(
274                "     Run {sec}lean-ctx setup{rst} to auto-configure your editors."
275            ));
276        }
277        out.push(String::new());
278    }
279
280    if !cep.modes.is_empty() {
281        out.push(format!("  {}", theme.box_top_labeled(cep_w, "READ MODES")));
282
283        let mut sorted_modes: Vec<_> = cep.modes.iter().collect();
284        sorted_modes.sort_by_key(|item| std::cmp::Reverse(*item.1));
285        let max_mode = (*sorted_modes.first().map_or(&1, |(_, c)| *c)).max(1);
286
287        for (mode, count) in &sorted_modes {
288            let ratio = **count as f64 / max_mode as f64;
289            let bar = theme::pad_right(&theme.gradient_bar(ratio, 20), 20);
290            let mode_disp = theme::truncate_visual(mode.as_str(), 16);
291            out.push(cep_line(&format!(
292                "  {sec}{mode_disp:<16}{rst} {count:>4}x  {bar}"
293            )));
294        }
295
296        let total_mode_calls: u64 = sorted_modes.iter().map(|(_, c)| **c).sum();
297        let full_count = cep.modes.get("full").copied().unwrap_or(0);
298        let optimized = total_mode_calls.saturating_sub(full_count);
299        let opt_pct = if total_mode_calls > 0 {
300            optimized as f64 / total_mode_calls as f64 * 100.0
301        } else {
302            0.0
303        };
304        out.push(cep_line(&format!(
305            "  {dim}{optimized}/{total_mode_calls} reads optimized \u{00b7} {opt_pct:.0}% non-full{rst}"
306        )));
307        out.push(format!("  {}", theme.box_bottom_square(cep_w)));
308        out.push(String::new());
309    }
310
311    if cep.scores.len() >= 2 {
312        out.push(format!("  {}", theme.box_top_labeled(cep_w, "SCORE TREND")));
313
314        let score_values: Vec<u64> = cep.scores.iter().map(|s| s.score as u64).collect();
315        // Cap to the most recent points so the sparkline fits inside the box.
316        let spark_vals: Vec<u64> = score_values.iter().rev().take(54).rev().copied().collect();
317        let spark = theme.gradient_sparkline(&spark_vals);
318        out.push(cep_line(&format!("  {spark}")));
319
320        let recent: Vec<_> = cep.scores.iter().rev().take(5).collect();
321        for snap in recent.iter().rev() {
322            let ts = snap.timestamp.get(..16).unwrap_or(&snap.timestamp);
323            let pc = theme.pct_color(snap.score as f64);
324            let cplx = theme::truncate_visual(&snap.complexity, 14);
325            out.push(cep_line(&format!(
326                "  {m}{ts}{rst}  {pc}{bold}{:>3}{rst}/100  {dim}cache {:>3}%  {cplx}{rst}",
327                snap.score, snap.cache_hit_rate,
328            )));
329        }
330        out.push(format!("  {}", theme.box_bottom_square(cep_w)));
331        out.push(String::new());
332    }
333
334    out.push(format!("  {}", theme.box_top_labeled(cep_w, "IMPROVE")));
335    let mut tips: Vec<String> = Vec::new();
336    if cache_hit_rate < 50.0 {
337        tips.push(format!(
338            "  {wrn}\u{2191}{rst} Re-read files with ctx_read to leverage caching"
339        ));
340    }
341    if cep.modes.len() < 3 {
342        tips.push(format!(
343            "  {wrn}\u{2191}{rst} Use map/signatures modes for context-only files"
344        ));
345    }
346    if avg_score >= 70.0 {
347        tips.push(format!(
348            "  {sc}\u{2713}{rst} Great score! You're using lean-ctx effectively"
349        ));
350    }
351    if tips.is_empty() {
352        tips.push(format!(
353            "  {sc}\u{2713}{rst} Solid usage \u{2014} keep leaning on cached, compressed reads"
354        ));
355    }
356    for tip in tips {
357        out.push(cep_line(&tip));
358    }
359    out.push(format!("  {}", theme.box_bottom_square(cep_w)));
360    out.push(String::new());
361
362    out.join("\n")
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    /// #361: the live-stats reader must look in the same category the writer uses
370    /// (now `state_dir()`, GH #408), honoring the configured dirs — not a
371    /// hardcoded `~/.lean-ctx`. Otherwise `gain` / `/lean-ctx` report 0.
372    #[test]
373    fn load_mcp_live_reads_from_configured_state_dir() {
374        let iso = crate::core::data_dir::isolated_data_dir();
375        std::fs::write(
376            iso.path().join("mcp-live.json"),
377            r#"{"cep_score":42,"tokens_saved":123}"#,
378        )
379        .unwrap();
380
381        let live = load_mcp_live().expect("live stats must load from the configured state dir");
382        assert_eq!(
383            live.get("cep_score").and_then(serde_json::Value::as_u64),
384            Some(42)
385        );
386    }
387
388    #[test]
389    fn load_mcp_live_none_when_file_absent() {
390        let _iso = crate::core::data_dir::isolated_data_dir();
391        assert!(load_mcp_live().is_none(), "no mcp-live.json → None");
392    }
393}