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