1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use walkdir::WalkDir;
6
7use crate::core::compressor;
8use crate::core::deps;
9use crate::core::entropy;
10use crate::core::preservation;
11use crate::core::signatures;
12use crate::core::tokens::count_tokens;
13
14const COST_PER_TOKEN: f64 = crate::core::stats::DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
15const MAX_FILE_SIZE: u64 = 100 * 1024;
16const MAX_FILES: usize = 50;
17fn cache_hit_tokens() -> usize {
18 let stub = "F1=src/example.rs [unchanged, 500L, use cached context]";
19 count_tokens(stub)
20}
21
22#[derive(Debug, Clone)]
25pub struct ModeMeasurement {
26 pub mode: String,
27 pub tokens: usize,
28 pub savings_pct: f64,
29 pub latency_us: u64,
30 pub preservation_score: f64,
31}
32
33#[derive(Debug, Clone)]
34pub struct FileMeasurement {
35 pub path: String,
36 pub ext: String,
37 pub raw_tokens: usize,
38 pub modes: Vec<ModeMeasurement>,
39}
40
41#[derive(Debug, Clone)]
42pub struct LanguageStats {
43 pub ext: String,
44 pub count: usize,
45 pub total_tokens: usize,
46 pub best_mode: String,
47 pub best_mode_tokens: usize,
48 pub best_savings_pct: f64,
49}
50
51#[derive(Debug, Clone)]
52pub struct ModeSummary {
53 pub mode: String,
54 pub total_compressed_tokens: usize,
55 pub avg_savings_pct: f64,
56 pub avg_latency_us: u64,
57 pub avg_preservation: f64,
58}
59
60#[derive(Debug, Clone)]
61pub struct SessionSimResult {
62 pub raw_tokens: usize,
63 pub lean_tokens: usize,
64 pub lean_ccp_tokens: usize,
65 pub raw_cost: f64,
66 pub lean_cost: f64,
67 pub ccp_cost: f64,
68}
69
70#[derive(Debug, Clone)]
71pub struct ProjectBenchmark {
72 pub root: String,
73 pub files_scanned: usize,
74 pub files_measured: usize,
75 pub total_raw_tokens: usize,
76 pub languages: Vec<LanguageStats>,
77 pub mode_summaries: Vec<ModeSummary>,
78 pub session_sim: SessionSimResult,
79 pub file_results: Vec<FileMeasurement>,
80}
81
82fn is_skipped_dir(name: &str) -> bool {
85 matches!(
86 name,
87 "node_modules"
88 | ".git"
89 | "target"
90 | "dist"
91 | "build"
92 | ".next"
93 | ".nuxt"
94 | "__pycache__"
95 | ".cache"
96 | "coverage"
97 | "vendor"
98 | ".svn"
99 | ".hg"
100 )
101}
102
103fn is_text_ext(ext: &str) -> bool {
104 matches!(
105 ext,
106 "rs" | "ts"
107 | "tsx"
108 | "js"
109 | "jsx"
110 | "py"
111 | "go"
112 | "java"
113 | "c"
114 | "cpp"
115 | "h"
116 | "hpp"
117 | "cs"
118 | "kt"
119 | "swift"
120 | "rb"
121 | "php"
122 | "vue"
123 | "svelte"
124 | "html"
125 | "css"
126 | "scss"
127 | "less"
128 | "json"
129 | "yaml"
130 | "yml"
131 | "toml"
132 | "xml"
133 | "md"
134 | "txt"
135 | "sh"
136 | "bash"
137 | "zsh"
138 | "fish"
139 | "sql"
140 | "graphql"
141 | "proto"
142 | "ex"
143 | "exs"
144 | "zig"
145 | "lua"
146 | "r"
147 | "R"
148 | "dart"
149 | "scala"
150 )
151}
152
153fn scan_project(root: &str) -> Vec<PathBuf> {
154 let mut files: Vec<(PathBuf, u64)> = Vec::new();
155
156 for entry in WalkDir::new(root)
157 .max_depth(8)
158 .into_iter()
159 .filter_entry(|e| {
160 let name = e.file_name().to_string_lossy();
161 if e.file_type().is_dir() {
162 if e.depth() > 0 && name.starts_with('.') {
163 return false;
164 }
165 return !is_skipped_dir(&name);
166 }
167 true
168 })
169 {
170 let Ok(entry) = entry else { continue };
171
172 if entry.file_type().is_dir() {
173 continue;
174 }
175
176 let path = entry.path().to_path_buf();
177 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
178
179 if !is_text_ext(ext) {
180 continue;
181 }
182
183 let size = entry.metadata().map_or(0, |m| m.len());
184 if size == 0 || size > MAX_FILE_SIZE {
185 continue;
186 }
187
188 files.push((path, size));
189 }
190
191 files.sort_by_key(|x| std::cmp::Reverse(x.1));
192
193 let mut selected = Vec::new();
194 let mut ext_counts: HashMap<String, usize> = HashMap::new();
195
196 for (path, _size) in &files {
197 if selected.len() >= MAX_FILES {
198 break;
199 }
200 let ext = path
201 .extension()
202 .and_then(|e| e.to_str())
203 .unwrap_or("")
204 .to_string();
205 let count = ext_counts.entry(ext.clone()).or_insert(0);
206 if *count < 10 {
207 *count += 1;
208 selected.push(path.clone());
209 }
210 }
211
212 selected
213}
214
215fn measure_mode(content: &str, ext: &str, mode: &str, raw_tokens: usize) -> ModeMeasurement {
218 let start = Instant::now();
219
220 let compressed = match mode {
221 "map" => {
222 let sigs = signatures::extract_signatures(content, ext);
223 let dep_info = deps::extract_deps(content, ext);
224 let mut parts = Vec::new();
225 if !dep_info.imports.is_empty() {
226 parts.push(format!("deps: {}", dep_info.imports.join(", ")));
227 }
228 let key_refs: Vec<&signatures::Signature> = sigs
229 .iter()
230 .filter(|s| s.is_exported || s.indent == 0)
231 .collect();
232 let extra_exports = signatures::exports_not_in_signatures(&dep_info.exports, &key_refs);
235 if !extra_exports.is_empty() {
236 parts.push(format!("exports: {}", extra_exports.join(", ")));
237 }
238 if !key_refs.is_empty() {
239 let key_sigs: Vec<String> = key_refs.iter().map(|s| s.to_compact()).collect();
240 parts.push(key_sigs.join("\n"));
241 }
242 parts.join("\n")
243 }
244 "signatures" => {
245 let sigs = signatures::extract_signatures(content, ext);
246 sigs.iter()
247 .map(super::signatures::Signature::to_compact)
248 .collect::<Vec<_>>()
249 .join("\n")
250 }
251 "aggressive" => compressor::aggressive_compress(content, Some(ext)),
252 "entropy" => entropy::entropy_compress_deterministic(content).output,
256 "cache_hit" => format!(
257 "F1=src/file.{ext} [unchanged, {}L, use cached context]",
258 content.lines().count()
259 ),
260 _ => content.to_string(),
261 };
262
263 let latency = start.elapsed();
264 let tokens = count_tokens(&compressed);
265
266 let savings_pct = if raw_tokens > 0 {
267 (1.0 - tokens as f64 / raw_tokens as f64) * 100.0
268 } else {
269 0.0
270 };
271
272 let preservation_score = if mode == "cache_hit" {
273 -1.0
274 } else {
275 preservation::measure(content, &compressed, ext).overall()
276 };
277
278 ModeMeasurement {
279 mode: mode.to_string(),
280 tokens,
281 savings_pct,
282 latency_us: latency.as_micros() as u64,
283 preservation_score,
284 }
285}
286
287fn measure_file(path: &Path, root: &str) -> Option<FileMeasurement> {
288 let content = std::fs::read_to_string(path).ok()?;
289 if content.is_empty() {
290 return None;
291 }
292
293 let ext = path
294 .extension()
295 .and_then(|e| e.to_str())
296 .unwrap_or("")
297 .to_string();
298
299 let raw_tokens = count_tokens(&content);
300 if raw_tokens == 0 {
301 return None;
302 }
303
304 let modes = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
305 let measurements: Vec<ModeMeasurement> = modes
306 .iter()
307 .map(|m| measure_mode(&content, &ext, m, raw_tokens))
308 .collect();
309
310 let display_path = path
311 .strip_prefix(root)
312 .unwrap_or(path)
313 .to_string_lossy()
314 .to_string();
315
316 Some(FileMeasurement {
317 path: display_path,
318 ext,
319 raw_tokens,
320 modes: measurements,
321 })
322}
323
324fn is_mode_applicable_for_ext(mode: &str, ext: &str, tokens: usize) -> bool {
331 if tokens == 0 {
332 return false;
333 }
334 let is_structural_mode = matches!(mode, "map" | "signatures");
335 if !is_structural_mode {
336 return true;
337 }
338 let code_exts = [
340 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "kt", "c", "cpp", "h", "hpp", "cs",
341 "rb", "swift", "scala", "zig", "lua", "php", "dart", "ex", "exs", "elm", "hs", "ml",
342 "svelte", "vue", "sh", "bash", "zsh",
343 ];
344 code_exts.contains(&ext)
345}
346
347fn aggregate_languages(files: &[FileMeasurement]) -> Vec<LanguageStats> {
348 struct LangAccum {
349 count: usize,
350 total_tokens: usize,
351 mode_tokens: HashMap<String, usize>,
352 }
353
354 let mut map: HashMap<String, LangAccum> = HashMap::new();
355 for f in files {
356 let entry = map.entry(f.ext.clone()).or_insert_with(|| LangAccum {
357 count: 0,
358 total_tokens: 0,
359 mode_tokens: HashMap::new(),
360 });
361 entry.count += 1;
362 entry.total_tokens += f.raw_tokens;
363 for m in &f.modes {
364 *entry.mode_tokens.entry(m.mode.clone()).or_insert(0) += m.tokens;
365 }
366 }
367
368 let mut stats: Vec<LanguageStats> = map
369 .into_iter()
370 .map(|(ext, acc)| {
371 let (best_mode, best_tokens) = acc
372 .mode_tokens
373 .iter()
374 .filter(|(m, _)| m.as_str() != "cache_hit")
375 .filter(|(m, t)| is_mode_applicable_for_ext(m, &ext, **t))
376 .min_by_key(|(_, t)| **t)
377 .map_or_else(
378 || ("full".to_string(), acc.total_tokens),
379 |(m, t)| (m.clone(), *t),
380 );
381
382 let savings = if acc.total_tokens > 0 {
383 (1.0 - best_tokens as f64 / acc.total_tokens as f64) * 100.0
384 } else {
385 0.0
386 };
387
388 LanguageStats {
389 ext,
390 count: acc.count,
391 total_tokens: acc.total_tokens,
392 best_mode,
393 best_mode_tokens: best_tokens,
394 best_savings_pct: savings,
395 }
396 })
397 .collect();
398 stats.sort_by_key(|x| std::cmp::Reverse(x.total_tokens));
399 stats
400}
401
402fn aggregate_modes(files: &[FileMeasurement]) -> Vec<ModeSummary> {
403 let mode_names = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
404 let mut summaries = Vec::new();
405
406 for mode_name in &mode_names {
407 let mut total_tokens = 0usize;
408 let mut total_savings = 0.0f64;
409 let mut total_latency = 0u64;
410 let mut total_preservation = 0.0f64;
411 let mut preservation_count = 0usize;
412 let mut count = 0usize;
413
414 for f in files {
415 if let Some(m) = f.modes.iter().find(|m| m.mode == *mode_name) {
416 total_tokens += m.tokens;
417 total_savings += m.savings_pct;
418 total_latency += m.latency_us;
419 if m.preservation_score >= 0.0 {
420 total_preservation += m.preservation_score;
421 preservation_count += 1;
422 }
423 count += 1;
424 }
425 }
426
427 if count == 0 {
428 continue;
429 }
430
431 summaries.push(ModeSummary {
432 mode: mode_name.to_string(),
433 total_compressed_tokens: total_tokens,
434 avg_savings_pct: total_savings / count as f64,
435 avg_latency_us: total_latency / count as u64,
436 avg_preservation: if preservation_count > 0 {
437 total_preservation / preservation_count as f64
438 } else {
439 -1.0
440 },
441 });
442 }
443
444 summaries
445}
446
447fn measured_ccp_resume_tokens() -> usize {
453 const RESUME_CCP_MODEL_TOKENS: usize = 400;
454 crate::core::session::SessionState::load_latest().map_or(RESUME_CCP_MODEL_TOKENS, |s| {
455 crate::core::tokens::count_tokens(&format!("Session loaded.\n{}", s.format_compact()))
456 })
457}
458
459fn simulate_session(files: &[FileMeasurement]) -> SessionSimResult {
460 if files.is_empty() {
461 return SessionSimResult {
462 raw_tokens: 0,
463 lean_tokens: 0,
464 lean_ccp_tokens: 0,
465 raw_cost: 0.0,
466 lean_cost: 0.0,
467 ccp_cost: 0.0,
468 };
469 }
470
471 let file_count = files.len().min(15);
472 let selected = &files[..file_count];
473
474 let first_read_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
475
476 let first_read_lean: usize = selected
477 .iter()
478 .enumerate()
479 .map(|(i, f)| {
480 let mode = if i % 3 == 0 { "aggressive" } else { "map" };
481 f.modes
482 .iter()
483 .find(|m| m.mode == mode)
484 .map_or(f.raw_tokens, |m| m.tokens)
485 })
486 .sum();
487
488 let cache_reread_count = 10usize.min(file_count);
489 let cache_raw: usize = selected[..cache_reread_count]
490 .iter()
491 .map(|f| f.raw_tokens)
492 .sum();
493 let cache_lean: usize = cache_reread_count * cache_hit_tokens();
494
495 let shell_count = 8usize;
496 let shell_raw = shell_count * 500;
497 let shell_lean = shell_count * 200;
498
499 let resume_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
500 let resume_lean: usize = selected
501 .iter()
502 .map(|f| {
503 f.modes
504 .iter()
505 .find(|m| m.mode == "map")
506 .map_or(f.raw_tokens, |m| m.tokens)
507 })
508 .sum();
509 let resume_ccp = measured_ccp_resume_tokens();
510
511 let raw_total = first_read_raw + cache_raw + shell_raw + resume_raw;
512 let lean_total = first_read_lean + cache_lean + shell_lean + resume_lean;
513 let ccp_total = first_read_lean + cache_lean + shell_lean + resume_ccp;
514
515 SessionSimResult {
516 raw_tokens: raw_total,
517 lean_tokens: lean_total,
518 lean_ccp_tokens: ccp_total,
519 raw_cost: raw_total as f64 * COST_PER_TOKEN,
520 lean_cost: lean_total as f64 * COST_PER_TOKEN,
521 ccp_cost: ccp_total as f64 * COST_PER_TOKEN,
522 }
523}
524
525pub fn run_project_benchmark(path: &str) -> ProjectBenchmark {
528 let root = if path.is_empty() { "." } else { path };
529 let scanned = scan_project(root);
530 let files_scanned = scanned.len();
531
532 let file_results: Vec<FileMeasurement> = scanned
533 .iter()
534 .filter_map(|p| measure_file(p, root))
535 .collect();
536
537 let total_raw_tokens: usize = file_results.iter().map(|f| f.raw_tokens).sum();
538 let languages = aggregate_languages(&file_results);
539 let mode_summaries = aggregate_modes(&file_results);
540 let session_sim = simulate_session(&file_results);
541
542 ProjectBenchmark {
543 root: root.to_string(),
544 files_scanned,
545 files_measured: file_results.len(),
546 total_raw_tokens,
547 languages,
548 mode_summaries,
549 session_sim,
550 file_results,
551 }
552}
553
554pub fn format_terminal(b: &ProjectBenchmark) -> String {
557 let mut out = Vec::new();
558 let sep = "\u{2550}".repeat(66);
559
560 out.push(sep.clone());
561 out.push(format!(" lean-ctx Benchmark — {}", b.root));
562 out.push(sep.clone());
563
564 let lang_summary: Vec<String> = b
565 .languages
566 .iter()
567 .take(5)
568 .map(|l| format!("{} {}", l.count, l.ext))
569 .collect();
570 out.push(format!(
571 " Scanned: {} files ({})",
572 b.files_measured,
573 lang_summary.join(", ")
574 ));
575 out.push(format!(
576 " Total raw tokens: {}",
577 format_num(b.total_raw_tokens)
578 ));
579 out.push(String::new());
580
581 out.push(" Compression by Language:".to_string());
582 out.push(format!(
583 " {:<10} {:>6} {:>10} {:>10} {:>10} {:>10}",
584 "Lang", "Files", "Raw Tok", "Best Mode", "Compressed", "Savings"
585 ));
586 out.push(format!(" {}", "\u{2500}".repeat(62)));
587 for l in &b.languages {
588 out.push(format!(
589 " {:<10} {:>6} {:>10} {:>10} {:>10} {:>9.1}%",
590 l.ext,
591 l.count,
592 format_num(l.total_tokens),
593 l.best_mode,
594 format_num(l.best_mode_tokens),
595 l.best_savings_pct,
596 ));
597 }
598 out.push(String::new());
599
600 out.push(" Mode Performance:".to_string());
601 out.push(format!(
602 " {:<14} {:>10} {:>10} {:>10} {:>10}",
603 "Mode", "Tokens", "Savings", "Latency", "Quality"
604 ));
605 out.push(format!(" {}", "\u{2500}".repeat(58)));
606
607 for m in &b.mode_summaries {
608 let qual = if m.avg_preservation < 0.0 {
609 "N/A".to_string()
610 } else {
611 format!("{:.1}%", m.avg_preservation * 100.0)
612 };
613 let latency = if m.avg_latency_us > 1000 {
614 format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
615 } else {
616 format!("{}μs", m.avg_latency_us)
617 };
618 out.push(format!(
619 " {:<14} {:>10} {:>9.1}% {:>10} {:>10}",
620 m.mode,
621 format_num(m.total_compressed_tokens),
622 m.avg_savings_pct,
623 latency,
624 qual,
625 ));
626 }
627
628 out.push(String::new());
629 out.push(" Session Simulation (30-min coding):".to_string());
630 out.push(format!(
631 " {:<24} {:>10} {:>10} {:>10}",
632 "Approach", "Tokens", "Cost", "Savings"
633 ));
634 out.push(format!(" {}", "\u{2500}".repeat(58)));
635
636 let s = &b.session_sim;
637 out.push(format!(
638 " {:<24} {:>10} {:>10} {:>10}",
639 "Raw (no compression)",
640 format_num(s.raw_tokens),
641 format!("${:.3}", s.raw_cost),
642 "\u{2014}",
643 ));
644
645 let lean_pct = if s.raw_tokens > 0 {
646 (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
647 } else {
648 0.0
649 };
650 out.push(format!(
651 " {:<24} {:>10} {:>10} {:>9.1}%",
652 "lean-ctx (no CCP)",
653 format_num(s.lean_tokens),
654 format!("${:.3}", s.lean_cost),
655 lean_pct,
656 ));
657
658 let ccp_pct = if s.raw_tokens > 0 {
659 (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
660 } else {
661 0.0
662 };
663 out.push(format!(
664 " {:<24} {:>10} {:>10} {:>9.1}%",
665 "lean-ctx + CCP",
666 format_num(s.lean_ccp_tokens),
667 format!("${:.3}", s.ccp_cost),
668 ccp_pct,
669 ));
670
671 out.push(sep.clone());
672 out.join("\n")
673}
674
675pub fn format_markdown(b: &ProjectBenchmark) -> String {
678 let mut out = Vec::new();
679
680 out.push("# lean-ctx Benchmark Report".to_string());
681 out.push(String::new());
682 out.push(format!("**Project:** `{}`", b.root));
683 out.push(format!("**Files measured:** {}", b.files_measured));
684 out.push(format!(
685 "**Total raw tokens:** {}",
686 format_num(b.total_raw_tokens)
687 ));
688 out.push(String::new());
689
690 out.push("## Compression by Language".to_string());
691 out.push(String::new());
692 out.push("| Language | Files | Raw Tokens | Best Mode | Compressed | Savings |".to_string());
693 out.push("|----------|------:|-----------:|-----------|----------:|--------:|".to_string());
694 for l in &b.languages {
695 out.push(format!(
696 "| {} | {} | {} | {} | {} | {:.1}% |",
697 l.ext,
698 l.count,
699 format_num(l.total_tokens),
700 l.best_mode,
701 format_num(l.best_mode_tokens),
702 l.best_savings_pct,
703 ));
704 }
705 out.push(String::new());
706
707 out.push("## Mode Performance".to_string());
708 out.push(String::new());
709 out.push("| Mode | Tokens | Savings | Latency | Quality |".to_string());
710 out.push("|------|-------:|--------:|--------:|--------:|".to_string());
711 for m in &b.mode_summaries {
712 let qual = if m.avg_preservation < 0.0 {
713 "N/A".to_string()
714 } else {
715 format!("{:.1}%", m.avg_preservation * 100.0)
716 };
717 let latency = if m.avg_latency_us > 1000 {
718 format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
719 } else {
720 format!("{}μs", m.avg_latency_us)
721 };
722 out.push(format!(
723 "| {} | {} | {:.1}% | {} | {} |",
724 m.mode,
725 format_num(m.total_compressed_tokens),
726 m.avg_savings_pct,
727 latency,
728 qual
729 ));
730 }
731 out.push(String::new());
732
733 out.push("## Session Simulation (30-min coding)".to_string());
734 out.push(String::new());
735 out.push("| Approach | Tokens | Cost | Savings |".to_string());
736 out.push("|----------|-------:|-----:|--------:|".to_string());
737
738 let s = &b.session_sim;
739 out.push(format!(
740 "| Raw (no compression) | {} | ${:.3} | — |",
741 format_num(s.raw_tokens),
742 s.raw_cost
743 ));
744
745 let lean_pct = if s.raw_tokens > 0 {
746 (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
747 } else {
748 0.0
749 };
750 out.push(format!(
751 "| lean-ctx (no CCP) | {} | ${:.3} | {:.1}% |",
752 format_num(s.lean_tokens),
753 s.lean_cost,
754 lean_pct
755 ));
756
757 let ccp_pct = if s.raw_tokens > 0 {
758 (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
759 } else {
760 0.0
761 };
762 out.push(format!(
763 "| lean-ctx + CCP | {} | ${:.3} | {:.1}% |",
764 format_num(s.lean_ccp_tokens),
765 s.ccp_cost,
766 ccp_pct
767 ));
768
769 out.push(String::new());
770 out.push(format!(
771 "*Generated by lean-ctx benchmark v{} — https://leanctx.com*",
772 env!("CARGO_PKG_VERSION")
773 ));
774
775 out.join("\n")
776}
777
778pub fn format_json(b: &ProjectBenchmark) -> String {
781 let modes: Vec<serde_json::Value> = b.mode_summaries.iter().map(|m| {
782 serde_json::json!({
783 "mode": m.mode,
784 "total_compressed_tokens": m.total_compressed_tokens,
785 "avg_savings_pct": round2(m.avg_savings_pct),
786 "avg_latency_us": m.avg_latency_us,
787 "avg_preservation": if m.avg_preservation < 0.0 { serde_json::Value::Null } else { serde_json::json!(round2(m.avg_preservation * 100.0)) },
788 })
789 }).collect();
790
791 let languages: Vec<serde_json::Value> = b
792 .languages
793 .iter()
794 .map(|l| {
795 serde_json::json!({
796 "ext": l.ext,
797 "count": l.count,
798 "total_tokens": l.total_tokens,
799 "best_mode": l.best_mode,
800 "best_mode_tokens": l.best_mode_tokens,
801 "best_savings_pct": round2(l.best_savings_pct),
802 })
803 })
804 .collect();
805
806 let file_details: Vec<serde_json::Value> = b
807 .file_results
808 .iter()
809 .map(|f| {
810 let file_modes: Vec<serde_json::Value> = f
811 .modes
812 .iter()
813 .map(|m| {
814 serde_json::json!({
815 "mode": m.mode,
816 "tokens": m.tokens,
817 "savings_pct": round2(m.savings_pct),
818 "latency_us": m.latency_us,
819 "preservation": if m.preservation_score < 0.0 {
820 serde_json::Value::Null
821 } else {
822 serde_json::json!(round2(m.preservation_score * 100.0))
823 },
824 })
825 })
826 .collect();
827 serde_json::json!({
828 "path": f.path,
829 "ext": f.ext,
830 "raw_tokens": f.raw_tokens,
831 "modes": file_modes,
832 })
833 })
834 .collect();
835
836 let s = &b.session_sim;
837 let report = serde_json::json!({
838 "version": env!("CARGO_PKG_VERSION"),
839 "root": b.root,
840 "files_scanned": b.files_scanned,
841 "files_measured": b.files_measured,
842 "total_raw_tokens": b.total_raw_tokens,
843 "languages": languages,
844 "mode_summaries": modes,
845 "files": file_details,
846 "session_simulation": {
847 "raw_tokens": s.raw_tokens,
848 "lean_tokens": s.lean_tokens,
849 "lean_ccp_tokens": s.lean_ccp_tokens,
850 "raw_cost_usd": round2(s.raw_cost),
851 "lean_cost_usd": round2(s.lean_cost),
852 "ccp_cost_usd": round2(s.ccp_cost),
853 },
854 });
855
856 serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
857}
858
859fn format_num(n: usize) -> String {
862 if n >= 1_000_000 {
863 format!("{:.1}M", n as f64 / 1_000_000.0)
864 } else if n >= 1_000 {
865 format!("{:.1}K", n as f64 / 1_000.0)
866 } else {
867 format!("{n}")
868 }
869}
870
871fn round2(v: f64) -> f64 {
872 (v * 100.0).round() / 100.0
873}
874
875#[cfg(test)]
876mod tests {
877 use super::*;
878
879 fn mock_file(path: &str, ext: &str, raw: usize, modes: Vec<(&str, usize)>) -> FileMeasurement {
880 FileMeasurement {
881 path: path.to_string(),
882 ext: ext.to_string(),
883 raw_tokens: raw,
884 modes: modes
885 .into_iter()
886 .map(|(mode, tokens)| ModeMeasurement {
887 mode: mode.to_string(),
888 tokens,
889 savings_pct: if raw > 0 {
890 (1.0 - tokens as f64 / raw as f64) * 100.0
891 } else {
892 0.0
893 },
894 latency_us: 100,
895 preservation_score: 0.85,
896 })
897 .collect(),
898 }
899 }
900
901 #[test]
902 fn aggregate_languages_computes_best_mode() {
903 let files = vec![
904 mock_file(
905 "a.rs",
906 "rs",
907 1000,
908 vec![("map", 400), ("signatures", 200), ("aggressive", 300)],
909 ),
910 mock_file(
911 "b.rs",
912 "rs",
913 800,
914 vec![("map", 300), ("signatures", 150), ("aggressive", 250)],
915 ),
916 mock_file(
917 "c.py",
918 "py",
919 600,
920 vec![("map", 100), ("signatures", 250), ("aggressive", 200)],
921 ),
922 ];
923
924 let langs = aggregate_languages(&files);
925 assert_eq!(langs.len(), 2);
926
927 let rs = langs.iter().find(|l| l.ext == "rs").unwrap();
928 assert_eq!(rs.count, 2);
929 assert_eq!(rs.total_tokens, 1800);
930 assert_eq!(rs.best_mode, "signatures");
931 assert_eq!(rs.best_mode_tokens, 350);
932 assert!(rs.best_savings_pct > 80.0);
933
934 let py = langs.iter().find(|l| l.ext == "py").unwrap();
935 assert_eq!(py.best_mode, "map");
936 assert_eq!(py.best_mode_tokens, 100);
937 }
938
939 #[test]
940 fn aggregate_modes_averages() {
941 let files = vec![
942 mock_file("a.rs", "rs", 1000, vec![("map", 400), ("aggressive", 300)]),
943 mock_file("b.rs", "rs", 500, vec![("map", 200), ("aggressive", 100)]),
944 ];
945
946 let modes = aggregate_modes(&files);
947 let map = modes.iter().find(|m| m.mode == "map").unwrap();
948 assert_eq!(map.total_compressed_tokens, 600);
949 assert!(map.avg_savings_pct > 50.0);
950 }
951
952 #[test]
953 fn session_sim_empty_files() {
954 let result = simulate_session(&[]);
955 assert_eq!(result.raw_tokens, 0);
956 assert_eq!(result.lean_tokens, 0);
957 assert!((result.raw_cost).abs() < f64::EPSILON);
958 }
959
960 #[test]
961 fn session_sim_basic() {
962 let files: Vec<FileMeasurement> = (0..5)
963 .map(|i| {
964 mock_file(
965 &format!("file_{i}.rs"),
966 "rs",
967 2000,
968 vec![
969 ("map", 800),
970 ("aggressive", 600),
971 ("cache_hit", cache_hit_tokens()),
972 ],
973 )
974 })
975 .collect();
976 let result = simulate_session(&files);
977 assert!(result.raw_tokens > 0);
978 assert!(result.lean_tokens < result.raw_tokens);
979 assert!(
980 result.lean_ccp_tokens < result.lean_tokens,
981 "CCP resume ({}) should beat map-based resume ({}) with enough files",
982 result.lean_ccp_tokens,
983 result.lean_tokens
984 );
985 }
986
987 #[test]
988 fn format_json_includes_files_and_language_savings() {
989 let files = vec![mock_file(
990 "src/main.rs",
991 "rs",
992 500,
993 vec![("map", 200), ("signatures", 100), ("cache_hit", 13)],
994 )];
995 let bench = ProjectBenchmark {
996 root: ".".to_string(),
997 files_scanned: 1,
998 files_measured: 1,
999 total_raw_tokens: 500,
1000 languages: aggregate_languages(&files),
1001 mode_summaries: aggregate_modes(&files),
1002 session_sim: simulate_session(&files),
1003 file_results: files,
1004 };
1005
1006 let json_str = format_json(&bench);
1007 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1008
1009 assert!(parsed["files"].is_array());
1010 assert_eq!(parsed["files"].as_array().unwrap().len(), 1);
1011 assert_eq!(parsed["files"][0]["path"], "src/main.rs");
1012 assert!(parsed["files"][0]["modes"].is_array());
1013
1014 assert!(parsed["languages"][0]["best_mode"].is_string());
1015 assert!(parsed["languages"][0]["best_savings_pct"].is_number());
1016 }
1017
1018 #[test]
1019 fn format_markdown_contains_language_savings() {
1020 let files = vec![mock_file(
1021 "lib.rs",
1022 "rs",
1023 1000,
1024 vec![("map", 300), ("signatures", 200)],
1025 )];
1026 let bench = ProjectBenchmark {
1027 root: ".".to_string(),
1028 files_scanned: 1,
1029 files_measured: 1,
1030 total_raw_tokens: 1000,
1031 languages: aggregate_languages(&files),
1032 mode_summaries: aggregate_modes(&files),
1033 session_sim: simulate_session(&files),
1034 file_results: files,
1035 };
1036
1037 let md = format_markdown(&bench);
1038 assert!(md.contains("Compression by Language"));
1039 assert!(md.contains("Best Mode"));
1040 assert!(md.contains("Savings"));
1041 }
1042
1043 #[test]
1044 fn format_terminal_contains_language_section() {
1045 let files = vec![mock_file(
1046 "app.py",
1047 "py",
1048 800,
1049 vec![("map", 200), ("aggressive", 300)],
1050 )];
1051 let bench = ProjectBenchmark {
1052 root: ".".to_string(),
1053 files_scanned: 1,
1054 files_measured: 1,
1055 total_raw_tokens: 800,
1056 languages: aggregate_languages(&files),
1057 mode_summaries: aggregate_modes(&files),
1058 session_sim: simulate_session(&files),
1059 file_results: files,
1060 };
1061
1062 let out = format_terminal(&bench);
1063 assert!(out.contains("Compression by Language"));
1064 assert!(out.contains("py"));
1065 assert!(out.contains("Best Mode"));
1066 }
1067
1068 #[test]
1069 fn run_project_benchmark_on_current_crate() {
1070 let bench = run_project_benchmark("src");
1071 assert!(bench.files_measured > 0);
1072 assert!(bench.total_raw_tokens > 0);
1073 assert!(!bench.languages.is_empty());
1074 assert!(!bench.mode_summaries.is_empty());
1075
1076 for lang in &bench.languages {
1077 assert!(!lang.best_mode.is_empty());
1078 assert!(lang.best_savings_pct >= 0.0);
1079 }
1080
1081 let json = format_json(&bench);
1082 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1083 assert!(!parsed["files"].as_array().unwrap().is_empty());
1084
1085 let md = format_markdown(&bench);
1086 assert!(md.contains("lean-ctx Benchmark Report"));
1087
1088 let term = format_terminal(&bench);
1089 assert!(term.contains("Session Simulation"));
1090 }
1091}