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