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;
17const CACHE_HIT_TOKENS: usize = 13;
18
19#[derive(Debug, Clone)]
22pub struct ModeMeasurement {
23 pub mode: String,
24 pub tokens: usize,
25 pub savings_pct: f64,
26 pub latency_us: u64,
27 pub preservation_score: f64,
28}
29
30#[derive(Debug, Clone)]
31pub struct FileMeasurement {
32 #[allow(dead_code)]
33 pub path: String,
34 pub ext: String,
35 pub raw_tokens: usize,
36 pub modes: Vec<ModeMeasurement>,
37}
38
39#[derive(Debug, Clone)]
40pub struct LanguageStats {
41 pub ext: String,
42 pub count: usize,
43 pub total_tokens: usize,
44}
45
46#[derive(Debug, Clone)]
47pub struct ModeSummary {
48 pub mode: String,
49 pub total_compressed_tokens: usize,
50 pub avg_savings_pct: f64,
51 pub avg_latency_us: u64,
52 pub avg_preservation: f64,
53}
54
55#[derive(Debug, Clone)]
56pub struct SessionSimResult {
57 pub raw_tokens: usize,
58 pub lean_tokens: usize,
59 pub lean_ccp_tokens: usize,
60 pub raw_cost: f64,
61 pub lean_cost: f64,
62 pub ccp_cost: f64,
63}
64
65#[derive(Debug, Clone)]
66pub struct ProjectBenchmark {
67 pub root: String,
68 pub files_scanned: usize,
69 pub files_measured: usize,
70 pub total_raw_tokens: usize,
71 pub languages: Vec<LanguageStats>,
72 pub mode_summaries: Vec<ModeSummary>,
73 pub session_sim: SessionSimResult,
74 #[allow(dead_code)]
75 pub file_results: Vec<FileMeasurement>,
76}
77
78fn is_skipped_dir(name: &str) -> bool {
81 matches!(
82 name,
83 "node_modules"
84 | ".git"
85 | "target"
86 | "dist"
87 | "build"
88 | ".next"
89 | ".nuxt"
90 | "__pycache__"
91 | ".cache"
92 | "coverage"
93 | "vendor"
94 | ".svn"
95 | ".hg"
96 )
97}
98
99fn is_text_ext(ext: &str) -> bool {
100 matches!(
101 ext,
102 "rs" | "ts"
103 | "tsx"
104 | "js"
105 | "jsx"
106 | "py"
107 | "go"
108 | "java"
109 | "c"
110 | "cpp"
111 | "h"
112 | "hpp"
113 | "cs"
114 | "kt"
115 | "swift"
116 | "rb"
117 | "php"
118 | "vue"
119 | "svelte"
120 | "html"
121 | "css"
122 | "scss"
123 | "less"
124 | "json"
125 | "yaml"
126 | "yml"
127 | "toml"
128 | "xml"
129 | "md"
130 | "txt"
131 | "sh"
132 | "bash"
133 | "zsh"
134 | "fish"
135 | "sql"
136 | "graphql"
137 | "proto"
138 | "ex"
139 | "exs"
140 | "zig"
141 | "lua"
142 | "r"
143 | "R"
144 | "dart"
145 | "scala"
146 )
147}
148
149fn scan_project(root: &str) -> Vec<PathBuf> {
150 let mut files: Vec<(PathBuf, u64)> = Vec::new();
151
152 for entry in WalkDir::new(root)
153 .max_depth(8)
154 .into_iter()
155 .filter_entry(|e| {
156 let name = e.file_name().to_string_lossy();
157 if e.file_type().is_dir() {
158 if e.depth() > 0 && name.starts_with('.') {
159 return false;
160 }
161 return !is_skipped_dir(&name);
162 }
163 true
164 })
165 {
166 let Ok(entry) = entry else { continue };
167
168 if entry.file_type().is_dir() {
169 continue;
170 }
171
172 let path = entry.path().to_path_buf();
173 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
174
175 if !is_text_ext(ext) {
176 continue;
177 }
178
179 let size = entry.metadata().map_or(0, |m| m.len());
180 if size == 0 || size > MAX_FILE_SIZE {
181 continue;
182 }
183
184 files.push((path, size));
185 }
186
187 files.sort_by_key(|x| std::cmp::Reverse(x.1));
188
189 let mut selected = Vec::new();
190 let mut ext_counts: HashMap<String, usize> = HashMap::new();
191
192 for (path, _size) in &files {
193 if selected.len() >= MAX_FILES {
194 break;
195 }
196 let ext = path
197 .extension()
198 .and_then(|e| e.to_str())
199 .unwrap_or("")
200 .to_string();
201 let count = ext_counts.entry(ext.clone()).or_insert(0);
202 if *count < 10 {
203 *count += 1;
204 selected.push(path.clone());
205 }
206 }
207
208 selected
209}
210
211fn measure_mode(content: &str, ext: &str, mode: &str, raw_tokens: usize) -> ModeMeasurement {
214 let start = Instant::now();
215
216 let compressed = match mode {
217 "map" => {
218 let sigs = signatures::extract_signatures(content, ext);
219 let dep_info = deps::extract_deps(content, ext);
220 let mut parts = Vec::new();
221 if !dep_info.imports.is_empty() {
222 parts.push(format!("deps: {}", dep_info.imports.join(", ")));
223 }
224 if !dep_info.exports.is_empty() {
225 parts.push(format!("exports: {}", dep_info.exports.join(", ")));
226 }
227 let key_sigs: Vec<String> = sigs
228 .iter()
229 .filter(|s| s.is_exported || s.indent == 0)
230 .map(super::signatures::Signature::to_compact)
231 .collect();
232 if !key_sigs.is_empty() {
233 parts.push(key_sigs.join("\n"));
234 }
235 parts.join("\n")
236 }
237 "signatures" => {
238 let sigs = signatures::extract_signatures(content, ext);
239 sigs.iter()
240 .map(super::signatures::Signature::to_compact)
241 .collect::<Vec<_>>()
242 .join("\n")
243 }
244 "aggressive" => compressor::aggressive_compress(content, Some(ext)),
245 "entropy" => entropy::entropy_compress(content).output,
246 "cache_hit" => "cached re-read ~13tok".to_string(),
247 _ => content.to_string(),
248 };
249
250 let latency = start.elapsed();
251 let tokens = if mode == "cache_hit" {
252 CACHE_HIT_TOKENS
253 } else {
254 count_tokens(&compressed)
255 };
256
257 let savings_pct = if raw_tokens > 0 {
258 (1.0 - tokens as f64 / raw_tokens as f64) * 100.0
259 } else {
260 0.0
261 };
262
263 let preservation_score = if mode == "cache_hit" {
264 -1.0
265 } else {
266 preservation::measure(content, &compressed, ext).overall()
267 };
268
269 ModeMeasurement {
270 mode: mode.to_string(),
271 tokens,
272 savings_pct,
273 latency_us: latency.as_micros() as u64,
274 preservation_score,
275 }
276}
277
278fn measure_file(path: &Path, root: &str) -> Option<FileMeasurement> {
279 let content = std::fs::read_to_string(path).ok()?;
280 if content.is_empty() {
281 return None;
282 }
283
284 let ext = path
285 .extension()
286 .and_then(|e| e.to_str())
287 .unwrap_or("")
288 .to_string();
289
290 let raw_tokens = count_tokens(&content);
291 if raw_tokens == 0 {
292 return None;
293 }
294
295 let modes = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
296 let measurements: Vec<ModeMeasurement> = modes
297 .iter()
298 .map(|m| measure_mode(&content, &ext, m, raw_tokens))
299 .collect();
300
301 let display_path = path
302 .strip_prefix(root)
303 .unwrap_or(path)
304 .to_string_lossy()
305 .to_string();
306
307 Some(FileMeasurement {
308 path: display_path,
309 ext,
310 raw_tokens,
311 modes: measurements,
312 })
313}
314
315fn aggregate_languages(files: &[FileMeasurement]) -> Vec<LanguageStats> {
318 let mut map: HashMap<String, (usize, usize)> = HashMap::new();
319 for f in files {
320 let entry = map.entry(f.ext.clone()).or_insert((0, 0));
321 entry.0 += 1;
322 entry.1 += f.raw_tokens;
323 }
324 let mut stats: Vec<LanguageStats> = map
325 .into_iter()
326 .map(|(ext, (count, total_tokens))| LanguageStats {
327 ext,
328 count,
329 total_tokens,
330 })
331 .collect();
332 stats.sort_by_key(|x| std::cmp::Reverse(x.total_tokens));
333 stats
334}
335
336fn aggregate_modes(files: &[FileMeasurement]) -> Vec<ModeSummary> {
337 let mode_names = ["map", "signatures", "aggressive", "entropy", "cache_hit"];
338 let mut summaries = Vec::new();
339
340 for mode_name in &mode_names {
341 let mut total_tokens = 0usize;
342 let mut total_savings = 0.0f64;
343 let mut total_latency = 0u64;
344 let mut total_preservation = 0.0f64;
345 let mut preservation_count = 0usize;
346 let mut count = 0usize;
347
348 for f in files {
349 if let Some(m) = f.modes.iter().find(|m| m.mode == *mode_name) {
350 total_tokens += m.tokens;
351 total_savings += m.savings_pct;
352 total_latency += m.latency_us;
353 if m.preservation_score >= 0.0 {
354 total_preservation += m.preservation_score;
355 preservation_count += 1;
356 }
357 count += 1;
358 }
359 }
360
361 if count == 0 {
362 continue;
363 }
364
365 summaries.push(ModeSummary {
366 mode: mode_name.to_string(),
367 total_compressed_tokens: total_tokens,
368 avg_savings_pct: total_savings / count as f64,
369 avg_latency_us: total_latency / count as u64,
370 avg_preservation: if preservation_count > 0 {
371 total_preservation / preservation_count as f64
372 } else {
373 -1.0
374 },
375 });
376 }
377
378 summaries
379}
380
381fn simulate_session(files: &[FileMeasurement]) -> SessionSimResult {
384 if files.is_empty() {
385 return SessionSimResult {
386 raw_tokens: 0,
387 lean_tokens: 0,
388 lean_ccp_tokens: 0,
389 raw_cost: 0.0,
390 lean_cost: 0.0,
391 ccp_cost: 0.0,
392 };
393 }
394
395 let file_count = files.len().min(15);
396 let selected = &files[..file_count];
397
398 let first_read_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
399
400 let first_read_lean: usize = selected
401 .iter()
402 .enumerate()
403 .map(|(i, f)| {
404 let mode = if i % 3 == 0 { "aggressive" } else { "map" };
405 f.modes
406 .iter()
407 .find(|m| m.mode == mode)
408 .map_or(f.raw_tokens, |m| m.tokens)
409 })
410 .sum();
411
412 let cache_reread_count = 10usize.min(file_count);
413 let cache_raw: usize = selected[..cache_reread_count]
414 .iter()
415 .map(|f| f.raw_tokens)
416 .sum();
417 let cache_lean: usize = cache_reread_count * CACHE_HIT_TOKENS;
418
419 let shell_count = 8usize;
420 let shell_raw = shell_count * 500;
421 let shell_lean = shell_count * 200;
422
423 let resume_raw: usize = selected.iter().map(|f| f.raw_tokens).sum();
424 let resume_lean: usize = selected
425 .iter()
426 .map(|f| {
427 f.modes
428 .iter()
429 .find(|m| m.mode == "map")
430 .map_or(f.raw_tokens, |m| m.tokens)
431 })
432 .sum();
433 let resume_ccp = 400usize;
434
435 let raw_total = first_read_raw + cache_raw + shell_raw + resume_raw;
436 let lean_total = first_read_lean + cache_lean + shell_lean + resume_lean;
437 let ccp_total = first_read_lean + cache_lean + shell_lean + resume_ccp;
438
439 SessionSimResult {
440 raw_tokens: raw_total,
441 lean_tokens: lean_total,
442 lean_ccp_tokens: ccp_total,
443 raw_cost: raw_total as f64 * COST_PER_TOKEN,
444 lean_cost: lean_total as f64 * COST_PER_TOKEN,
445 ccp_cost: ccp_total as f64 * COST_PER_TOKEN,
446 }
447}
448
449pub fn run_project_benchmark(path: &str) -> ProjectBenchmark {
452 let root = if path.is_empty() { "." } else { path };
453 let scanned = scan_project(root);
454 let files_scanned = scanned.len();
455
456 let file_results: Vec<FileMeasurement> = scanned
457 .iter()
458 .filter_map(|p| measure_file(p, root))
459 .collect();
460
461 let total_raw_tokens: usize = file_results.iter().map(|f| f.raw_tokens).sum();
462 let languages = aggregate_languages(&file_results);
463 let mode_summaries = aggregate_modes(&file_results);
464 let session_sim = simulate_session(&file_results);
465
466 ProjectBenchmark {
467 root: root.to_string(),
468 files_scanned,
469 files_measured: file_results.len(),
470 total_raw_tokens,
471 languages,
472 mode_summaries,
473 session_sim,
474 file_results,
475 }
476}
477
478pub fn format_terminal(b: &ProjectBenchmark) -> String {
481 let mut out = Vec::new();
482 let sep = "\u{2550}".repeat(66);
483
484 out.push(sep.clone());
485 out.push(format!(" lean-ctx Benchmark — {}", b.root));
486 out.push(sep.clone());
487
488 let lang_summary: Vec<String> = b
489 .languages
490 .iter()
491 .take(5)
492 .map(|l| format!("{} {}", l.count, l.ext))
493 .collect();
494 out.push(format!(
495 " Scanned: {} files ({})",
496 b.files_measured,
497 lang_summary.join(", ")
498 ));
499 out.push(format!(
500 " Total raw tokens: {}",
501 format_num(b.total_raw_tokens)
502 ));
503 out.push(String::new());
504
505 out.push(" Mode Performance:".to_string());
506 out.push(format!(
507 " {:<14} {:>10} {:>10} {:>10} {:>10}",
508 "Mode", "Tokens", "Savings", "Latency", "Quality"
509 ));
510 out.push(format!(" {}", "\u{2500}".repeat(58)));
511
512 for m in &b.mode_summaries {
513 let qual = if m.avg_preservation < 0.0 {
514 "N/A".to_string()
515 } else {
516 format!("{:.1}%", m.avg_preservation * 100.0)
517 };
518 let latency = if m.avg_latency_us > 1000 {
519 format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
520 } else {
521 format!("{}μs", m.avg_latency_us)
522 };
523 out.push(format!(
524 " {:<14} {:>10} {:>9.1}% {:>10} {:>10}",
525 m.mode,
526 format_num(m.total_compressed_tokens),
527 m.avg_savings_pct,
528 latency,
529 qual,
530 ));
531 }
532
533 out.push(String::new());
534 out.push(" Session Simulation (30-min coding):".to_string());
535 out.push(format!(
536 " {:<24} {:>10} {:>10} {:>10}",
537 "Approach", "Tokens", "Cost", "Savings"
538 ));
539 out.push(format!(" {}", "\u{2500}".repeat(58)));
540
541 let s = &b.session_sim;
542 out.push(format!(
543 " {:<24} {:>10} {:>10} {:>10}",
544 "Raw (no compression)",
545 format_num(s.raw_tokens),
546 format!("${:.3}", s.raw_cost),
547 "\u{2014}",
548 ));
549
550 let lean_pct = if s.raw_tokens > 0 {
551 (1.0 - s.lean_tokens as f64 / s.raw_tokens as f64) * 100.0
552 } else {
553 0.0
554 };
555 out.push(format!(
556 " {:<24} {:>10} {:>10} {:>9.1}%",
557 "lean-ctx (no CCP)",
558 format_num(s.lean_tokens),
559 format!("${:.3}", s.lean_cost),
560 lean_pct,
561 ));
562
563 let ccp_pct = if s.raw_tokens > 0 {
564 (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
565 } else {
566 0.0
567 };
568 out.push(format!(
569 " {:<24} {:>10} {:>10} {:>9.1}%",
570 "lean-ctx + CCP",
571 format_num(s.lean_ccp_tokens),
572 format!("${:.3}", s.ccp_cost),
573 ccp_pct,
574 ));
575
576 out.push(sep.clone());
577 out.join("\n")
578}
579
580pub fn format_markdown(b: &ProjectBenchmark) -> String {
583 let mut out = Vec::new();
584
585 out.push("# lean-ctx Benchmark Report".to_string());
586 out.push(String::new());
587 out.push(format!("**Project:** `{}`", b.root));
588 out.push(format!("**Files measured:** {}", b.files_measured));
589 out.push(format!(
590 "**Total raw tokens:** {}",
591 format_num(b.total_raw_tokens)
592 ));
593 out.push(String::new());
594
595 out.push("## Languages".to_string());
596 out.push(String::new());
597 out.push("| Extension | Files | Tokens |".to_string());
598 out.push("|-----------|------:|-------:|".to_string());
599 for l in &b.languages {
600 out.push(format!(
601 "| {} | {} | {} |",
602 l.ext,
603 l.count,
604 format_num(l.total_tokens)
605 ));
606 }
607 out.push(String::new());
608
609 out.push("## Mode Performance".to_string());
610 out.push(String::new());
611 out.push("| Mode | Tokens | Savings | Latency | Quality |".to_string());
612 out.push("|------|-------:|--------:|--------:|--------:|".to_string());
613 for m in &b.mode_summaries {
614 let qual = if m.avg_preservation < 0.0 {
615 "N/A".to_string()
616 } else {
617 format!("{:.1}%", m.avg_preservation * 100.0)
618 };
619 let latency = if m.avg_latency_us > 1000 {
620 format!("{:.1}ms", m.avg_latency_us as f64 / 1000.0)
621 } else {
622 format!("{}μs", m.avg_latency_us)
623 };
624 out.push(format!(
625 "| {} | {} | {:.1}% | {} | {} |",
626 m.mode,
627 format_num(m.total_compressed_tokens),
628 m.avg_savings_pct,
629 latency,
630 qual
631 ));
632 }
633 out.push(String::new());
634
635 out.push("## Session Simulation (30-min coding)".to_string());
636 out.push(String::new());
637 out.push("| Approach | Tokens | Cost | Savings |".to_string());
638 out.push("|----------|-------:|-----:|--------:|".to_string());
639
640 let s = &b.session_sim;
641 out.push(format!(
642 "| Raw (no compression) | {} | ${:.3} | — |",
643 format_num(s.raw_tokens),
644 s.raw_cost
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 "| lean-ctx (no CCP) | {} | ${:.3} | {:.1}% |",
654 format_num(s.lean_tokens),
655 s.lean_cost,
656 lean_pct
657 ));
658
659 let ccp_pct = if s.raw_tokens > 0 {
660 (1.0 - s.lean_ccp_tokens as f64 / s.raw_tokens as f64) * 100.0
661 } else {
662 0.0
663 };
664 out.push(format!(
665 "| lean-ctx + CCP | {} | ${:.3} | {:.1}% |",
666 format_num(s.lean_ccp_tokens),
667 s.ccp_cost,
668 ccp_pct
669 ));
670
671 out.push(String::new());
672 out.push(format!(
673 "*Generated by lean-ctx benchmark v{} — https://leanctx.com*",
674 env!("CARGO_PKG_VERSION")
675 ));
676
677 out.join("\n")
678}
679
680pub fn format_json(b: &ProjectBenchmark) -> String {
683 let modes: Vec<serde_json::Value> = b.mode_summaries.iter().map(|m| {
684 serde_json::json!({
685 "mode": m.mode,
686 "total_compressed_tokens": m.total_compressed_tokens,
687 "avg_savings_pct": round2(m.avg_savings_pct),
688 "avg_latency_us": m.avg_latency_us,
689 "avg_preservation": if m.avg_preservation < 0.0 { serde_json::Value::Null } else { serde_json::json!(round2(m.avg_preservation * 100.0)) },
690 })
691 }).collect();
692
693 let languages: Vec<serde_json::Value> = b
694 .languages
695 .iter()
696 .map(|l| {
697 serde_json::json!({
698 "ext": l.ext,
699 "count": l.count,
700 "total_tokens": l.total_tokens,
701 })
702 })
703 .collect();
704
705 let s = &b.session_sim;
706 let report = serde_json::json!({
707 "version": env!("CARGO_PKG_VERSION"),
708 "root": b.root,
709 "files_scanned": b.files_scanned,
710 "files_measured": b.files_measured,
711 "total_raw_tokens": b.total_raw_tokens,
712 "languages": languages,
713 "mode_summaries": modes,
714 "session_simulation": {
715 "raw_tokens": s.raw_tokens,
716 "lean_tokens": s.lean_tokens,
717 "lean_ccp_tokens": s.lean_ccp_tokens,
718 "raw_cost_usd": round2(s.raw_cost),
719 "lean_cost_usd": round2(s.lean_cost),
720 "ccp_cost_usd": round2(s.ccp_cost),
721 },
722 });
723
724 serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
725}
726
727fn format_num(n: usize) -> String {
730 if n >= 1_000_000 {
731 format!("{:.1}M", n as f64 / 1_000_000.0)
732 } else if n >= 1_000 {
733 format!("{:.1}K", n as f64 / 1_000.0)
734 } else {
735 format!("{n}")
736 }
737}
738
739fn round2(v: f64) -> f64 {
740 (v * 100.0).round() / 100.0
741}