Skip to main content

lean_ctx/core/
benchmark.rs

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// ── Types ───────────────────────────────────────────────────
23
24#[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
84// ── Scanner ─────────────────────────────────────────────────
85
86fn 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
217// ── Measurement ─────────────────────────────────────────────
218
219fn 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        // Deterministic variant: benchmark numbers feed the scorecard's
252        // reproducibility digest (#211) and must not depend on whether the
253        // shared embedding engine happens to be loaded in this process.
254        "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
323// ── Aggregation ─────────────────────────────────────────────
324
325/// Structural modes (map, signatures) only produce meaningful output for
326/// programming languages with parseable code structures. For data/markup
327/// files, these modes return near-empty output that would be misleading
328/// as "compression". Only `aggressive` and `entropy` are honest for these.
329fn 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    // Extensions with actual code structures (functions, classes, types)
338    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
446// ── Session Simulation ──────────────────────────────────────
447
448fn 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
514// ── Public API ──────────────────────────────────────────────
515
516pub 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
543// ── Report: Terminal ────────────────────────────────────────
544
545pub 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
664// ── Report: Markdown ────────────────────────────────────────
665
666pub 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
767// ── Report: JSON ────────────────────────────────────────────
768
769pub 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
848// ── Helpers ─────────────────────────────────────────────────
849
850fn 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}