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