cha-cli 1.4.2

Cha — pluggable code smell detection CLI (察)
use crate::{analyze::filter_excluded, collect_files};

pub fn cmd_calibrate(paths: &[String], apply: bool) {
    let cwd = std::env::current_dir().unwrap_or_default();
    let root_config = crate::load_config(&cwd);
    let files = filter_excluded(collect_files(paths), &root_config.exclude, &cwd);

    let mut cache = crate::open_project_cache(&cwd);
    let (mut lines, mut cx, mut cog) = collect_stats(&files, &cwd, &mut cache);
    cache.flush();
    if lines.is_empty() {
        println!("No functions found.");
        return;
    }
    lines.sort();
    cx.sort();
    cog.sort();

    print_stats(&lines, &cx, &cog, files.len());

    if apply {
        write_calibration(&cwd, &lines, &cx, &cog, files.len());
    } else {
        println!("\nRun `cha calibrate --apply` to save thresholds to .cha/calibration.toml");
        println!("(cha analyze will auto-apply them as defaults)");
    }
}

fn collect_stats(
    files: &[std::path::PathBuf],
    cwd: &std::path::Path,
    cache: &mut cha_core::ProjectCache,
) -> (Vec<usize>, Vec<usize>, Vec<usize>) {
    let (mut lines, mut cx, mut cog) = (Vec::new(), Vec::new(), Vec::new());
    for path in files {
        let Some((_, model)) = crate::cached_parse(path, cache, cwd) else {
            continue;
        };
        for f in &model.functions {
            lines.push(f.line_count);
            cx.push(f.complexity);
            cog.push(f.cognitive_complexity);
        }
    }
    (lines, cx, cog)
}

fn pct(v: &[usize], p: usize) -> usize {
    v[v.len() * p / 100]
}

fn print_stats(lines: &[usize], cx: &[usize], cog: &[usize], file_count: usize) {
    println!(
        "Analyzed {} functions across {file_count} files.\n",
        lines.len()
    );
    println!(
        "{:<25} {:>10} {:>10}",
        "Metric", "Warning(P90)", "Error(P95)"
    );
    println!("{}", "".repeat(48));
    println!(
        "{:<25} {:>10} {:>10}",
        "long_method",
        pct(lines, 90),
        pct(lines, 95)
    );
    println!(
        "{:<25} {:>10} {:>10}",
        "high_complexity",
        pct(cx, 90),
        pct(cx, 95)
    );
    println!(
        "{:<25} {:>10} {:>10}",
        "cognitive_complexity",
        pct(cog, 90),
        pct(cog, 95)
    );
}

fn write_calibration(
    cwd: &std::path::Path,
    lines: &[usize],
    cx: &[usize],
    cog: &[usize],
    file_count: usize,
) {
    let dir = cwd.join(".cha");
    std::fs::create_dir_all(&dir).ok();
    let content = format!(
        "# Auto-generated by `cha calibrate`. Override in .cha.toml.\n\n\
         [stats]\n\
         functions = {}\n\
         files = {file_count}\n\n\
         [thresholds]\n\
         max_function_lines = {}\n\
         max_complexity = {}\n\
         max_cognitive_complexity = {}\n\n\
         [distribution]\n\
         lines_p50 = {}\n\
         lines_p75 = {}\n\
         lines_p90 = {}\n\
         lines_p95 = {}\n\
         complexity_p50 = {}\n\
         complexity_p75 = {}\n\
         complexity_p90 = {}\n\
         complexity_p95 = {}\n\
         cognitive_p50 = {}\n\
         cognitive_p75 = {}\n\
         cognitive_p90 = {}\n\
         cognitive_p95 = {}\n",
        lines.len(),
        pct(lines, 90),
        pct(cx, 90),
        pct(cog, 90),
        pct(lines, 50),
        pct(lines, 75),
        pct(lines, 90),
        pct(lines, 95),
        pct(cx, 50),
        pct(cx, 75),
        pct(cx, 90),
        pct(cx, 95),
        pct(cog, 50),
        pct(cog, 75),
        pct(cog, 90),
        pct(cog, 95),
    );
    let path = dir.join("calibration.toml");
    std::fs::write(&path, content).unwrap();
    println!("\n✓ Saved to {}", path.display());
    println!("  cha analyze will now use these as default thresholds.");
    println!("  Override in .cha.toml for custom values.");
}

/// Load calibration thresholds from .cha/calibration.toml if it exists.
pub fn load_calibration(cwd: &std::path::Path) -> Option<(usize, usize, usize)> {
    let content = std::fs::read_to_string(cwd.join(".cha/calibration.toml")).ok()?;
    let get = |key: &str| -> Option<usize> {
        for line in content.lines() {
            let line = line.trim();
            if line.starts_with(key) {
                return line.split('=').nth(1)?.trim().parse().ok();
            }
        }
        None
    };
    Some((
        get("max_function_lines")?,
        get("max_complexity")?,
        get("max_cognitive_complexity")?,
    ))
}