lean_ctx/core/stats/format/
cep.rs1use 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)] fn 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 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#[allow(clippy::many_single_char_names)] pub 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 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 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 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 #[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}