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 simulate_session(files: &[FileMeasurement]) -> SessionSimResult {
449 if files.is_empty() {
450 return SessionSimResult {
451 raw_tokens: 0,
452 lean_tokens: 0,
453 lean_ccp_tokens: 0,
454 raw_cost: 0.0,
455 lean_cost: 0.0,
456 ccp_cost: 0.0,
457 };
458 }
459
460 let file_count = files.len().min(15);
461 let selected = &files[..file_count];
462
463 let first_read_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
464
465 let first_read_lean: usize = selected
466 .iter()
467 .enumerate()
468 .map(|(i, f)| {
469 let mode = if i % 3 == 0 { "aggressive" } else { "map" };
470 f.modes
471 .iter()
472 .find(|m| m.mode == mode)
473 .map_or(f.raw_tokens, |m| m.tokens)
474 })
475 .sum();
476
477 let cache_reread_count = 10usize.min(file_count);
478 let cache_raw: usize = selected[..cache_reread_count]
479 .iter()
480 .map(|f| f.raw_tokens)
481 .sum();
482 let cache_lean: usize = cache_reread_count * cache_hit_tokens();
483
484 let shell_count = 8usize;
485 let shell_raw = shell_count * 500;
486 let shell_lean = shell_count * 200;
487
488 let resume_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
489 let resume_lean: usize = selected
490 .iter()
491 .map(|f| {
492 f.modes
493 .iter()
494 .find(|m| m.mode == "map")
495 .map_or(f.raw_tokens, |m| m.tokens)
496 })
497 .sum();
498 let resume_ccp = 400usize;
499
500 let raw_total = first_read_raw + cache_raw + shell_raw + resume_raw;
501 let lean_total = first_read_lean + cache_lean + shell_lean + resume_lean;
502 let ccp_total = first_read_lean + cache_lean + shell_lean + resume_ccp;
503
504 SessionSimResult {
505 raw_tokens: raw_total,
506 lean_tokens: lean_total,
507 lean_ccp_tokens: ccp_total,
508 raw_cost: raw_total as f64 * COST_PER_TOKEN,
509 lean_cost: lean_total as f64 * COST_PER_TOKEN,
510 ccp_cost: ccp_total as f64 * COST_PER_TOKEN,
511 }
512}
513
514pub fn run_project_benchmark(path: &str) -> ProjectBenchmark {
517 let root = if path.is_empty() { "." } else { path };
518 let scanned = scan_project(root);
519 let files_scanned = scanned.len();
520
521 let file_results: Vec<FileMeasurement> = scanned
522 .iter()
523 .filter_map(|p| measure_file(p, root))
524 .collect();
525
526 let total_raw_tokens: usize = file_results.iter().map(|f| f.raw_tokens).sum();
527 let languages = aggregate_languages(&file_results);
528 let mode_summaries = aggregate_modes(&file_results);
529 let session_sim = simulate_session(&file_results);
530
531 ProjectBenchmark {
532 root: root.to_string(),
533 files_scanned,
534 files_measured: file_results.len(),
535 total_raw_tokens,
536 languages,
537 mode_summaries,
538 session_sim,
539 file_results,
540 }
541}
542
543pub fn format_terminal(b: &ProjectBenchmark) -> String {
546 let mut out = Vec::new();
547 let sep = "\u{2550}".repeat(66);
548
549 out.push(sep.clone());
550 out.push(format!(" lean-ctx Benchmark — {}", b.root));
551 out.push(sep.clone());
552
553 let lang_summary: Vec<String> = b
554 .languages
555 .iter()
556 .take(5)
557 .map(|l| format!("{} {}", l.count, l.ext))
558 .collect();
559 out.push(format!(
560 " Scanned: {} files ({})",
561 b.files_measured,
562 lang_summary.join(", ")
563 ));
564 out.push(format!(
565 " Total raw tokens: {}",
566 format_num(b.total_raw_tokens)
567 ));
568 out.push(String::new());
569
570 out.push(" Compression by Language:".to_string());
571 out.push(format!(
572 " {:<10} {:>6} {:>10} {:>10} {:>10} {:>10}",
573 "Lang", "Files", "Raw Tok", "Best Mode", "Compressed", "Savings"
574 ));
575 out.push(format!(" {}", "\u{2500}".repeat(62)));
576 for l in &b.languages {
577 out.push(format!(
578 " {:<10} {:>6} {:>10} {:>10} {:>10} {:>9.1}%",
579 l.ext,
580 l.count,
581 format_num(l.total_tokens),
582 l.best_mode,
583 format_num(l.best_mode_tokens),
584 l.best_savings_pct,
585 ));
586 }
587 out.push(String::new());
588
589 out.push(" Mode Performance:".to_string());
590 out.push(format!(
591 " {:<14} {:>10} {:>10} {:>10} {:>10}",
592 "Mode", "Tokens", "Savings", "Latency", "Quality"
593 ));
594 out.push(format!(" {}", "\u{2500}".repeat(58)));
595
596 for m in &b.mode_summaries {
597 let qual = if m.avg_preservation < 0.0 {
598 "N/A".to_string()
599 } else {
600 format!("{:.1}%", m.avg_preservation * 100.0)
601 };
602 let latency = if m.avg_latency_us > 1000 {
603 format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
604 } else {
605 format!("{}μs", m.avg_latency_us)
606 };
607 out.push(format!(
608 " {:<14} {:>10} {:>9.1}% {:>10} {:>10}",
609 m.mode,
610 format_num(m.total_compressed_tokens),
611 m.avg_savings_pct,
612 latency,
613 qual,
614 ));
615 }
616
617 out.push(String::new());
618 out.push(" Session Simulation (30-min coding):".to_string());
619 out.push(format!(
620 " {:<24} {:>10} {:>10} {:>10}",
621 "Approach", "Tokens", "Cost", "Savings"
622 ));
623 out.push(format!(" {}", "\u{2500}".repeat(58)));
624
625 let s = &b.session_sim;
626 out.push(format!(
627 " {:<24} {:>10} {:>10} {:>10}",
628 "Raw (no compression)",
629 format_num(s.raw_tokens),
630 format!("${:.3}", s.raw_cost),
631 "\u{2014}",
632 ));
633
634 let lean_pct = if s.raw_tokens > 0 {
635 (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
636 } else {
637 0.0
638 };
639 out.push(format!(
640 " {:<24} {:>10} {:>10} {:>9.1}%",
641 "lean-ctx (no CCP)",
642 format_num(s.lean_tokens),
643 format!("${:.3}", s.lean_cost),
644 lean_pct,
645 ));
646
647 let ccp_pct = if s.raw_tokens > 0 {
648 (1.0 - s.lean_ccp_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 + CCP",
655 format_num(s.lean_ccp_tokens),
656 format!("${:.3}", s.ccp_cost),
657 ccp_pct,
658 ));
659
660 out.push(sep.clone());
661 out.join("\n")
662}
663
664pub fn format_markdown(b: &ProjectBenchmark) -> String {
667 let mut out = Vec::new();
668
669 out.push("# lean-ctx Benchmark Report".to_string());
670 out.push(String::new());
671 out.push(format!("**Project:** `{}`", b.root));
672 out.push(format!("**Files measured:** {}", b.files_measured));
673 out.push(format!(
674 "**Total raw tokens:** {}",
675 format_num(b.total_raw_tokens)
676 ));
677 out.push(String::new());
678
679 out.push("## Compression by Language".to_string());
680 out.push(String::new());
681 out.push("| Language | Files | Raw Tokens | Best Mode | Compressed | Savings |".to_string());
682 out.push("|----------|------:|-----------:|-----------|----------:|--------:|".to_string());
683 for l in &b.languages {
684 out.push(format!(
685 "| {} | {} | {} | {} | {} | {:.1}% |",
686 l.ext,
687 l.count,
688 format_num(l.total_tokens),
689 l.best_mode,
690 format_num(l.best_mode_tokens),
691 l.best_savings_pct,
692 ));
693 }
694 out.push(String::new());
695
696 out.push("## Mode Performance".to_string());
697 out.push(String::new());
698 out.push("| Mode | Tokens | Savings | Latency | Quality |".to_string());
699 out.push("|------|-------:|--------:|--------:|--------:|".to_string());
700 for m in &b.mode_summaries {
701 let qual = if m.avg_preservation < 0.0 {
702 "N/A".to_string()
703 } else {
704 format!("{:.1}%", m.avg_preservation * 100.0)
705 };
706 let latency = if m.avg_latency_us > 1000 {
707 format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
708 } else {
709 format!("{}μs", m.avg_latency_us)
710 };
711 out.push(format!(
712 "| {} | {} | {:.1}% | {} | {} |",
713 m.mode,
714 format_num(m.total_compressed_tokens),
715 m.avg_savings_pct,
716 latency,
717 qual
718 ));
719 }
720 out.push(String::new());
721
722 out.push("## Session Simulation (30-min coding)".to_string());
723 out.push(String::new());
724 out.push("| Approach | Tokens | Cost | Savings |".to_string());
725 out.push("|----------|-------:|-----:|--------:|".to_string());
726
727 let s = &b.session_sim;
728 out.push(format!(
729 "| Raw (no compression) | {} | ${:.3} | — |",
730 format_num(s.raw_tokens),
731 s.raw_cost
732 ));
733
734 let lean_pct = if s.raw_tokens > 0 {
735 (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
736 } else {
737 0.0
738 };
739 out.push(format!(
740 "| lean-ctx (no CCP) | {} | ${:.3} | {:.1}% |",
741 format_num(s.lean_tokens),
742 s.lean_cost,
743 lean_pct
744 ));
745
746 let ccp_pct = if s.raw_tokens > 0 {
747 (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
748 } else {
749 0.0
750 };
751 out.push(format!(
752 "| lean-ctx + CCP | {} | ${:.3} | {:.1}% |",
753 format_num(s.lean_ccp_tokens),
754 s.ccp_cost,
755 ccp_pct
756 ));
757
758 out.push(String::new());
759 out.push(format!(
760 "*Generated by lean-ctx benchmark v{} — https://leanctx.com*",
761 env!("CARGO_PKG_VERSION")
762 ));
763
764 out.join("\n")
765}
766
767pub fn format_json(b: &ProjectBenchmark) -> String {
770 let modes: Vec<serde_json::Value> = b.mode_summaries.iter().map(|m| {
771 serde_json::json!({
772 "mode": m.mode,
773 "total_compressed_tokens": m.total_compressed_tokens,
774 "avg_savings_pct": round2(m.avg_savings_pct),
775 "avg_latency_us": m.avg_latency_us,
776 "avg_preservation": if m.avg_preservation < 0.0 { serde_json::Value::Null } else { serde_json::json!(round2(m.avg_preservation * 100.0)) },
777 })
778 }).collect();
779
780 let languages: Vec<serde_json::Value> = b
781 .languages
782 .iter()
783 .map(|l| {
784 serde_json::json!({
785 "ext": l.ext,
786 "count": l.count,
787 "total_tokens": l.total_tokens,
788 "best_mode": l.best_mode,
789 "best_mode_tokens": l.best_mode_tokens,
790 "best_savings_pct": round2(l.best_savings_pct),
791 })
792 })
793 .collect();
794
795 let file_details: Vec<serde_json::Value> = b
796 .file_results
797 .iter()
798 .map(|f| {
799 let file_modes: Vec<serde_json::Value> = f
800 .modes
801 .iter()
802 .map(|m| {
803 serde_json::json!({
804 "mode": m.mode,
805 "tokens": m.tokens,
806 "savings_pct": round2(m.savings_pct),
807 "latency_us": m.latency_us,
808 "preservation": if m.preservation_score < 0.0 {
809 serde_json::Value::Null
810 } else {
811 serde_json::json!(round2(m.preservation_score * 100.0))
812 },
813 })
814 })
815 .collect();
816 serde_json::json!({
817 "path": f.path,
818 "ext": f.ext,
819 "raw_tokens": f.raw_tokens,
820 "modes": file_modes,
821 })
822 })
823 .collect();
824
825 let s = &b.session_sim;
826 let report = serde_json::json!({
827 "version": env!("CARGO_PKG_VERSION"),
828 "root": b.root,
829 "files_scanned": b.files_scanned,
830 "files_measured": b.files_measured,
831 "total_raw_tokens": b.total_raw_tokens,
832 "languages": languages,
833 "mode_summaries": modes,
834 "files": file_details,
835 "session_simulation": {
836 "raw_tokens": s.raw_tokens,
837 "lean_tokens": s.lean_tokens,
838 "lean_ccp_tokens": s.lean_ccp_tokens,
839 "raw_cost_usd": round2(s.raw_cost),
840 "lean_cost_usd": round2(s.lean_cost),
841 "ccp_cost_usd": round2(s.ccp_cost),
842 },
843 });
844
845 serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
846}
847
848fn format_num(n: usize) -> String {
851 if n >= 1_000_000 {
852 format!("{:.1}M", n as f64 / 1_000_000.0)
853 } else if n >= 1_000 {
854 format!("{:.1}K", n as f64 / 1_000.0)
855 } else {
856 format!("{n}")
857 }
858}
859
860fn round2(v: f64) -> f64 {
861 (v * 100.0).round() / 100.0
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 fn mock_file(path: &str, ext: &str, raw: usize, modes: Vec<(&str, usize)>) -> FileMeasurement {
869 FileMeasurement {
870 path: path.to_string(),
871 ext: ext.to_string(),
872 raw_tokens: raw,
873 modes: modes
874 .into_iter()
875 .map(|(mode, tokens)| ModeMeasurement {
876 mode: mode.to_string(),
877 tokens,
878 savings_pct: if raw > 0 {
879 (1.0 - tokens as f64 / raw as f64) * 100.0
880 } else {
881 0.0
882 },
883 latency_us: 100,
884 preservation_score: 0.85,
885 })
886 .collect(),
887 }
888 }
889
890 #[test]
891 fn aggregate_languages_computes_best_mode() {
892 let files = vec![
893 mock_file(
894 "a.rs",
895 "rs",
896 1000,
897 vec![("map", 400), ("signatures", 200), ("aggressive", 300)],
898 ),
899 mock_file(
900 "b.rs",
901 "rs",
902 800,
903 vec![("map", 300), ("signatures", 150), ("aggressive", 250)],
904 ),
905 mock_file(
906 "c.py",
907 "py",
908 600,
909 vec![("map", 100), ("signatures", 250), ("aggressive", 200)],
910 ),
911 ];
912
913 let langs = aggregate_languages(&files);
914 assert_eq!(langs.len(), 2);
915
916 let rs = langs.iter().find(|l| l.ext == "rs").unwrap();
917 assert_eq!(rs.count, 2);
918 assert_eq!(rs.total_tokens, 1800);
919 assert_eq!(rs.best_mode, "signatures");
920 assert_eq!(rs.best_mode_tokens, 350);
921 assert!(rs.best_savings_pct > 80.0);
922
923 let py = langs.iter().find(|l| l.ext == "py").unwrap();
924 assert_eq!(py.best_mode, "map");
925 assert_eq!(py.best_mode_tokens, 100);
926 }
927
928 #[test]
929 fn aggregate_modes_averages() {
930 let files = vec![
931 mock_file("a.rs", "rs", 1000, vec![("map", 400), ("aggressive", 300)]),
932 mock_file("b.rs", "rs", 500, vec![("map", 200), ("aggressive", 100)]),
933 ];
934
935 let modes = aggregate_modes(&files);
936 let map = modes.iter().find(|m| m.mode == "map").unwrap();
937 assert_eq!(map.total_compressed_tokens, 600);
938 assert!(map.avg_savings_pct > 50.0);
939 }
940
941 #[test]
942 fn session_sim_empty_files() {
943 let result = simulate_session(&[]);
944 assert_eq!(result.raw_tokens, 0);
945 assert_eq!(result.lean_tokens, 0);
946 assert!((result.raw_cost).abs() < f64::EPSILON);
947 }
948
949 #[test]
950 fn session_sim_basic() {
951 let files: Vec<FileMeasurement> = (0..5)
952 .map(|i| {
953 mock_file(
954 &format!("file_{i}.rs"),
955 "rs",
956 2000,
957 vec![
958 ("map", 800),
959 ("aggressive", 600),
960 ("cache_hit", cache_hit_tokens()),
961 ],
962 )
963 })
964 .collect();
965 let result = simulate_session(&files);
966 assert!(result.raw_tokens > 0);
967 assert!(result.lean_tokens < result.raw_tokens);
968 assert!(
969 result.lean_ccp_tokens < result.lean_tokens,
970 "CCP resume ({}) should beat map-based resume ({}) with enough files",
971 result.lean_ccp_tokens,
972 result.lean_tokens
973 );
974 }
975
976 #[test]
977 fn format_json_includes_files_and_language_savings() {
978 let files = vec![mock_file(
979 "src/main.rs",
980 "rs",
981 500,
982 vec![("map", 200), ("signatures", 100), ("cache_hit", 13)],
983 )];
984 let bench = ProjectBenchmark {
985 root: ".".to_string(),
986 files_scanned: 1,
987 files_measured: 1,
988 total_raw_tokens: 500,
989 languages: aggregate_languages(&files),
990 mode_summaries: aggregate_modes(&files),
991 session_sim: simulate_session(&files),
992 file_results: files,
993 };
994
995 let json_str = format_json(&bench);
996 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
997
998 assert!(parsed["files"].is_array());
999 assert_eq!(parsed["files"].as_array().unwrap().len(), 1);
1000 assert_eq!(parsed["files"][0]["path"], "src/main.rs");
1001 assert!(parsed["files"][0]["modes"].is_array());
1002
1003 assert!(parsed["languages"][0]["best_mode"].is_string());
1004 assert!(parsed["languages"][0]["best_savings_pct"].is_number());
1005 }
1006
1007 #[test]
1008 fn format_markdown_contains_language_savings() {
1009 let files = vec![mock_file(
1010 "lib.rs",
1011 "rs",
1012 1000,
1013 vec![("map", 300), ("signatures", 200)],
1014 )];
1015 let bench = ProjectBenchmark {
1016 root: ".".to_string(),
1017 files_scanned: 1,
1018 files_measured: 1,
1019 total_raw_tokens: 1000,
1020 languages: aggregate_languages(&files),
1021 mode_summaries: aggregate_modes(&files),
1022 session_sim: simulate_session(&files),
1023 file_results: files,
1024 };
1025
1026 let md = format_markdown(&bench);
1027 assert!(md.contains("Compression by Language"));
1028 assert!(md.contains("Best Mode"));
1029 assert!(md.contains("Savings"));
1030 }
1031
1032 #[test]
1033 fn format_terminal_contains_language_section() {
1034 let files = vec![mock_file(
1035 "app.py",
1036 "py",
1037 800,
1038 vec![("map", 200), ("aggressive", 300)],
1039 )];
1040 let bench = ProjectBenchmark {
1041 root: ".".to_string(),
1042 files_scanned: 1,
1043 files_measured: 1,
1044 total_raw_tokens: 800,
1045 languages: aggregate_languages(&files),
1046 mode_summaries: aggregate_modes(&files),
1047 session_sim: simulate_session(&files),
1048 file_results: files,
1049 };
1050
1051 let out = format_terminal(&bench);
1052 assert!(out.contains("Compression by Language"));
1053 assert!(out.contains("py"));
1054 assert!(out.contains("Best Mode"));
1055 }
1056
1057 #[test]
1058 fn run_project_benchmark_on_current_crate() {
1059 let bench = run_project_benchmark("src");
1060 assert!(bench.files_measured > 0);
1061 assert!(bench.total_raw_tokens > 0);
1062 assert!(!bench.languages.is_empty());
1063 assert!(!bench.mode_summaries.is_empty());
1064
1065 for lang in &bench.languages {
1066 assert!(!lang.best_mode.is_empty());
1067 assert!(lang.best_savings_pct >= 0.0);
1068 }
1069
1070 let json = format_json(&bench);
1071 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1072 assert!(!parsed["files"].as_array().unwrap().is_empty());
1073
1074 let md = format_markdown(&bench);
1075 assert!(md.contains("lean-ctx Benchmark Report"));
1076
1077 let term = format_terminal(&bench);
1078 assert!(term.contains("Session Simulation"));
1079 }
1080}