1use std::collections::HashMap;
2
3use crate::core::cache::SessionCache;
4use crate::tools::{CrpMode, ToolCallRecord};
5
6pub fn handle(cache: &SessionCache, tool_calls: &[ToolCallRecord], crp_mode: CrpMode) -> String {
7 let cache_stats = cache.get_stats();
8 let refs = cache.file_ref_map();
9
10 let total_original: u64 = tool_calls.iter().map(|c| c.original_tokens as u64).sum();
11 let total_saved: u64 = tool_calls.iter().map(|c| c.saved_tokens as u64).sum();
12 let total_sent = total_original.saturating_sub(total_saved);
13 let pct = if total_original > 0 {
14 total_saved as f64 / total_original as f64 * 100.0
15 } else {
16 0.0
17 };
18
19 let mut out = Vec::new();
20 let env_model = std::env::var("LEAN_CTX_MODEL")
21 .or_else(|_| std::env::var("LCTX_MODEL"))
22 .ok();
23 let pricing = crate::core::gain::model_pricing::ModelPricing::load();
24 let quote = pricing.quote(env_model.as_deref());
25
26 if crp_mode.is_tdd() {
27 out.push("§metrics".to_string());
28 out.push("═".repeat(40));
29
30 out.push(format!(
31 "files:{} reads:{} hits:{} ({:.0}%)",
32 cache_stats.files_tracked,
33 cache_stats.total_reads,
34 cache_stats.cache_hits,
35 cache_stats.hit_rate()
36 ));
37
38 out.push(format!(
39 "tok: {}→{} | saved:{} ({:.1}%)",
40 format_tokens(total_original),
41 format_tokens(total_sent),
42 format_tokens(total_saved),
43 pct
44 ));
45
46 let cost_saved = total_saved as f64 / 1_000_000.0 * quote.cost.input_per_m;
47 let cost_without = total_original as f64 / 1_000_000.0 * quote.cost.input_per_m;
48 out.push(format!(
49 "cost: ${:.4}→${:.4} | -${:.4}",
50 cost_without,
51 cost_without - cost_saved,
52 cost_saved
53 ));
54 } else {
55 out.push("lean-ctx session metrics".to_string());
56 out.push("═".repeat(50));
57
58 out.push(format!(
59 "Files tracked: {} | Reads: {} | Cache hits: {} ({:.0}%)",
60 cache_stats.files_tracked,
61 cache_stats.total_reads,
62 cache_stats.cache_hits,
63 cache_stats.hit_rate()
64 ));
65
66 out.push(format!(
67 "Input tokens: {} original → {} sent | {} saved ({:.1}%)",
68 format_tokens(total_original),
69 format_tokens(total_sent),
70 format_tokens(total_saved),
71 pct
72 ));
73
74 let cost_saved = total_saved as f64 / 1_000_000.0 * quote.cost.input_per_m;
75 let cost_without = total_original as f64 / 1_000_000.0 * quote.cost.input_per_m;
76 let cost_with = total_sent as f64 / 1_000_000.0 * quote.cost.input_per_m;
77 out.push(format!(
78 "Cost estimate: ${cost_without:.4} without → ${cost_with:.4} with lean-ctx | ${cost_saved:.4} saved"
79 ));
80 }
81
82 if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
83 let bounces = bt.total_bounces();
84 let wasted = bt.total_wasted_tokens();
85 if bounces > 0 {
86 let adjusted = bt.adjusted_savings(total_saved as usize);
87 out.push(String::new());
88 if crp_mode.is_tdd() {
89 out.push("§bounce".to_string());
90 } else {
91 out.push("Bounce Detection:".to_string());
92 }
93 out.push(format!(
94 " bounces: {bounces} | wasted: {} tok",
95 format_tokens(wasted as u64)
96 ));
97 out.push(format!(
98 " adjusted savings: {} tok ({:.1}%)",
99 format_tokens(adjusted.max(0) as u64),
100 if total_original > 0 {
101 adjusted.max(0) as f64 / total_original as f64 * 100.0
102 } else {
103 0.0
104 }
105 ));
106 }
107 }
108
109 if !tool_calls.is_empty() {
110 out.push(String::new());
111
112 let sep_w = if crp_mode.is_tdd() { 40 } else { 50 };
113 if crp_mode.is_tdd() {
114 out.push(format!(
115 "{:<12} {:>4} {:>7} {:>7} {:>4}",
116 "tool", "n", "orig", "saved", "%"
117 ));
118 } else {
119 out.push("By Tool:".to_string());
120 out.push(format!(
121 "{:<14} {:>5} {:>8} {:>8} {:>5}",
122 "Tool", "Calls", "Original", "Saved", "Avg%"
123 ));
124 }
125 out.push("─".repeat(sep_w));
126
127 let mut by_tool: HashMap<&str, ToolStats> = HashMap::new();
128 for call in tool_calls {
129 let entry = by_tool.entry(&call.tool).or_default();
130 entry.calls += 1;
131 entry.original += call.original_tokens;
132 entry.saved += call.saved_tokens;
133 }
134
135 let mut sorted: Vec<_> = by_tool
136 .iter()
137 .filter(|(_, ts)| ts.original > 0 || ts.saved > 0)
138 .collect();
139 sorted.sort_by_key(|x| std::cmp::Reverse(x.1.saved));
140
141 for (tool, ts) in &sorted {
142 let avg = if ts.original > 0 {
143 ts.saved as f64 / ts.original as f64 * 100.0
144 } else {
145 0.0
146 };
147 if crp_mode.is_tdd() {
148 out.push(format!(
149 "{:<12} {:>4} {:>7} {:>7} {:>3.0}%",
150 tool,
151 ts.calls,
152 format_tokens(ts.original as u64),
153 format_tokens(ts.saved as u64),
154 avg
155 ));
156 } else {
157 out.push(format!(
158 "{:<14} {:>5} {:>8} {:>8} {:>4.0}%",
159 tool,
160 ts.calls,
161 format_tokens(ts.original as u64),
162 format_tokens(ts.saved as u64),
163 avg
164 ));
165 }
166 }
167
168 let mut by_mode: HashMap<&str, ModeStats> = HashMap::new();
169 for call in tool_calls {
170 if let Some(ref mode) = call.mode {
171 let entry = by_mode.entry(mode).or_default();
172 entry.calls += 1;
173 entry.saved += call.saved_tokens;
174 }
175 }
176
177 if !by_mode.is_empty() {
178 out.push(String::new());
179 if crp_mode.is_tdd() {
180 out.push(format!("{:<12} {:>4} {:>7}", "mode", "n", "saved"));
181 } else {
182 out.push("By Mode:".to_string());
183 out.push(format!("{:<14} {:>5} {:>8}", "Mode", "Calls", "Saved"));
184 }
185 out.push("─".repeat(if crp_mode.is_tdd() { 28 } else { 30 }));
186
187 let mut sorted_modes: Vec<_> = by_mode.iter().collect();
188 sorted_modes.sort_by_key(|x| std::cmp::Reverse(x.1.saved));
189
190 for (mode, ms) in &sorted_modes {
191 if crp_mode.is_tdd() {
192 out.push(format!(
193 "{:<12} {:>4} {:>7}",
194 mode,
195 ms.calls,
196 format_tokens(ms.saved as u64)
197 ));
198 } else {
199 out.push(format!(
200 "{:<14} {:>5} {:>8}",
201 mode,
202 ms.calls,
203 format_tokens(ms.saved as u64)
204 ));
205 }
206 }
207 }
208 }
209
210 if !refs.is_empty() {
211 out.push(String::new());
212 if crp_mode.is_tdd() {
213 out.push("§refs:".to_string());
214 } else {
215 out.push("File Refs:".to_string());
216 }
217 let mut ref_list: Vec<_> = refs.iter().collect();
218 ref_list.sort_by_key(|(_, r)| (*r).clone());
219 for (path, r) in &ref_list {
220 let short = crate::core::protocol::shorten_path(path);
221 if let Some(entry) = cache.get(path) {
222 out.push(format!(
223 " {r}={short} [{}L {}t r:{}]",
224 entry.line_count, entry.original_tokens, entry.read_count
225 ));
226 } else {
227 out.push(format!(" {r}={short}"));
228 }
229 }
230 }
231
232 let projected_session =
233 total_saved as f64 / 1_000_000.0 * (quote.cost.input_per_m + quote.cost.output_per_m * 0.3);
234 if projected_session > 0.001 {
235 out.push(String::new());
236 if crp_mode.is_tdd() {
237 out.push(format!(
238 "∴ session savings (incl. thinking): ${projected_session:.3}"
239 ));
240 } else {
241 out.push(format!(
242 "Projected session savings (incl. thinking): ${projected_session:.3}"
243 ));
244 }
245 }
246
247 let cep = compute_cep_compliance(cache, tool_calls);
248 out.push(String::new());
249 if crp_mode.is_tdd() {
250 out.push("§CEP compliance".to_string());
251 } else {
252 out.push("CEP Compliance:".to_string());
253 }
254 out.push(format!(
255 " Cache utilization: {:.0}% (hit rate for repeated files)",
256 cep.cache_utilization * 100.0
257 ));
258 out.push(format!(
259 " Mode diversity: {:.0}% (using optimal modes per file)",
260 cep.mode_diversity * 100.0
261 ));
262 out.push(format!(
263 " Compression rate: {:.0}% (overall token reduction)",
264 cep.compression_rate * 100.0
265 ));
266 out.push(format!(
267 " CEP Score: {:.0}/100",
268 cep.overall_score * 100.0
269 ));
270
271 let complexity = crate::core::adaptive::classify_from_context(cache);
272 out.push(format!(" Task complexity: {complexity:?}"));
273
274 out.join("\n")
275}
276
277struct CepCompliance {
278 cache_utilization: f64,
279 mode_diversity: f64,
280 compression_rate: f64,
281 overall_score: f64,
282}
283
284fn compute_cep_compliance(cache: &SessionCache, tool_calls: &[ToolCallRecord]) -> CepCompliance {
285 let stats = cache.get_stats();
286
287 let cache_utilization = stats.hit_rate() / 100.0;
288
289 let modes_used: std::collections::HashSet<&str> = tool_calls
290 .iter()
291 .filter_map(|c| c.mode.as_deref())
292 .collect();
293 let possible_modes = crate::core::budgets::READ_MODE_COUNT;
294 let mode_diversity = (modes_used.len() as f64 / possible_modes).min(1.0);
295
296 let total_original: u64 = tool_calls.iter().map(|c| c.original_tokens as u64).sum();
297 let total_saved: u64 = tool_calls.iter().map(|c| c.saved_tokens as u64).sum();
298 let compression_rate = if total_original > 0 {
299 total_saved as f64 / total_original as f64
300 } else {
301 0.0
302 };
303
304 let overall_score = cache_utilization * 0.3 + mode_diversity * 0.2 + compression_rate * 0.5;
305
306 CepCompliance {
307 cache_utilization,
308 mode_diversity,
309 compression_rate,
310 overall_score,
311 }
312}
313
314fn format_tokens(n: u64) -> String {
315 if n >= 1_000_000 {
316 format!("{:.1}M", n as f64 / 1_000_000.0)
317 } else if n >= 1_000 {
318 format!("{:.1}K", n as f64 / 1_000.0)
319 } else {
320 format!("{n}")
321 }
322}
323
324#[derive(Default)]
325struct ToolStats {
326 calls: u32,
327 original: usize,
328 saved: usize,
329}
330
331#[derive(Default)]
332struct ModeStats {
333 calls: u32,
334 saved: usize,
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn test_cep_compliance_section_present_tdd() {
343 let cache = SessionCache::new();
344 let calls = vec![ToolCallRecord {
345 tool: "ctx_read".to_string(),
346 original_tokens: 1000,
347 saved_tokens: 300,
348 mode: Some("full".to_string()),
349 duration_ms: 0,
350 timestamp: String::new(),
351 }];
352 let output = handle(&cache, &calls, CrpMode::Tdd);
353 assert!(
354 output.contains("§CEP compliance"),
355 "TDD output must contain CEP compliance section"
356 );
357 assert!(output.contains("Cache utilization:"));
358 assert!(output.contains("Mode diversity:"));
359 assert!(output.contains("Compression rate:"));
360 assert!(output.contains("CEP Score:"));
361 assert!(output.contains("Task complexity:"));
362 }
363
364 #[test]
365 fn test_cep_compliance_section_present_normal() {
366 let cache = SessionCache::new();
367 let calls = vec![];
368 let output = handle(&cache, &calls, CrpMode::Off);
369 assert!(
370 output.contains("CEP Compliance:"),
371 "Normal output must contain CEP Compliance section"
372 );
373 assert!(output.contains("Task complexity:"));
374 }
375
376 #[test]
377 fn test_cep_scores_zero_with_no_calls() {
378 let cache = SessionCache::new();
379 let calls = vec![];
380 let output = handle(&cache, &calls, CrpMode::Tdd);
381 assert!(output.contains("CEP Score: 0/100"));
382 assert!(output.contains("Cache utilization: 0%"));
383 }
384
385 #[test]
386 fn test_format_tokens_units() {
387 assert_eq!(format_tokens(500), "500");
388 assert_eq!(format_tokens(1500), "1.5K");
389 assert_eq!(format_tokens(1_500_000), "1.5M");
390 }
391}