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