cha-cli 1.0.7

Cha — pluggable code smell detection CLI (察)
use std::collections::HashMap;
use std::path::PathBuf;

use cha_core::SourceFile;
use cha_core::graph;

use crate::{DepsFormat, analyze::filter_excluded, collect_files};

pub fn cmd_layers(paths: &[String], save: bool, format: &DepsFormat, depth: Option<usize>) {
    let cwd = std::env::current_dir().unwrap_or_default();
    let root_config = cha_core::Config::load(&cwd);
    let files = filter_excluded(collect_files(paths), &root_config.exclude, &cwd);

    let (file_imports, all_files) = build_import_edges(&files, &cwd);

    let modules = graph::infer_modules(&file_imports, &all_files, depth);
    let (layers, violations) = graph::infer_layers(&modules, &file_imports);

    match format {
        DepsFormat::Dot => render_dot(&layers, &violations),
        DepsFormat::Mermaid => render_mermaid(&layers, &violations),
        DepsFormat::Json => render_json(&layers, &violations),
        DepsFormat::Plantuml => render_plantuml(&layers, &violations),
        DepsFormat::Dsm => render_dsm(&layers, &file_imports, &modules),
        DepsFormat::Terminal => render_terminal(&layers, &violations),
    }

    if save {
        let layers_str = layers
            .iter()
            .map(|l| format!("{}:{}", l.name, l.level))
            .collect::<Vec<_>>()
            .join(",");
        println!("\nTo use in .cha.toml:\n");
        println!("[plugins.layer_violation]");
        println!("layers = \"{layers_str}\"");
    }
}

fn build_import_edges(
    files: &[PathBuf],
    cwd: &std::path::Path,
) -> (Vec<(String, String)>, Vec<String>) {
    let (name_to_paths, all_files) = index_files(files, cwd);
    let edges = resolve_edges(files, cwd, &name_to_paths);
    (edges, all_files)
}

fn index_files(
    files: &[PathBuf],
    cwd: &std::path::Path,
) -> (HashMap<String, Vec<String>>, Vec<String>) {
    let mut name_to_paths: HashMap<String, Vec<String>> = HashMap::new();
    let mut all_files = Vec::new();
    for path in files {
        let rel = path
            .strip_prefix(cwd)
            .unwrap_or(path)
            .to_string_lossy()
            .to_string();
        all_files.push(rel.clone());
        if let Some(name) = path.file_name() {
            name_to_paths
                .entry(name.to_string_lossy().to_string())
                .or_default()
                .push(rel);
        }
    }
    (name_to_paths, all_files)
}

fn resolve_edges(
    files: &[PathBuf],
    cwd: &std::path::Path,
    name_to_paths: &HashMap<String, Vec<String>>,
) -> Vec<(String, String)> {
    let mut edges = Vec::new();
    for path in files {
        let rel = path
            .strip_prefix(cwd)
            .unwrap_or(path)
            .to_string_lossy()
            .to_string();
        let Ok(content) = std::fs::read_to_string(path) else {
            continue;
        };
        let file = SourceFile::new(path.clone(), content);
        let Some(model) = cha_parser::parse_file(&file) else {
            continue;
        };

        let src_dir = std::path::Path::new(&rel)
            .parent()
            .unwrap_or(std::path::Path::new(""));
        for imp in &model.imports {
            let target_name = imp.source.split('/').next_back().unwrap_or(&imp.source);
            if let Some(candidates) = name_to_paths.get(target_name)
                && let Some(target) = closest_candidate(candidates, src_dir)
                && *target != rel
            {
                edges.push((rel.clone(), target.clone()));
            }
        }
    }
    edges
}

fn closest_candidate<'a>(
    candidates: &'a [String],
    src_dir: &std::path::Path,
) -> Option<&'a String> {
    candidates.iter().min_by_key(|c| {
        let c_dir = std::path::Path::new(c.as_str())
            .parent()
            .unwrap_or(std::path::Path::new(""));
        usize::from(c_dir != src_dir)
    })
}

fn render_mermaid(layers: &[graph::LayerInfo], violations: &[graph::LayerViolation]) {
    println!("graph TD");
    let bands = [
        ("Stable", 0.0, 0.2),
        ("Core", 0.2, 0.4),
        ("Mid", 0.4, 0.6),
        ("Volatile", 0.6, 0.8),
        ("Leaf", 0.8, 1.01),
    ];
    for (label, lo, hi) in &bands {
        let members: Vec<&graph::LayerInfo> = layers
            .iter()
            .filter(|l| l.instability >= *lo && l.instability < *hi && l.fan_in + l.fan_out > 0)
            .collect();
        if members.is_empty() {
            continue;
        }
        let id = sanitize(label);
        println!("  subgraph {id}[\"{label}\"]");
        for l in &members {
            let nid = sanitize(&l.name);
            let short = l.name.split('/').next_back().unwrap_or(&l.name);
            println!(
                "    {nid}[\"{short} ({}f, I={:.2})\"]",
                l.file_count, l.instability
            );
        }
        println!("  end");
    }
    for v in violations {
        let from = sanitize(&v.from_module);
        let to = sanitize(&v.to_module);
        println!("  {from} -->|violation| {to}");
    }
}

fn render_dot(layers: &[graph::LayerInfo], violations: &[graph::LayerViolation]) {
    let active: std::collections::HashSet<&str> = violations
        .iter()
        .flat_map(|v| [v.from_module.as_str(), v.to_module.as_str()])
        .collect();
    let shown: Vec<&graph::LayerInfo> = layers
        .iter()
        .filter(|l| l.fan_in + l.fan_out > 0 || active.contains(l.name.as_str()))
        .collect();

    println!("digraph layers {{");
    println!("  rankdir=LR;");
    println!("  node [shape=box style=filled fillcolor=lightyellow fontsize=10];");
    println!("  edge [color=gray];");
    render_dot_bands(&shown);
    for v in violations {
        println!(
            "  {:?} -> {:?} [color=red penwidth=2];",
            v.from_module, v.to_module
        );
    }
    println!("}}");
}

fn render_dot_bands(shown: &[&graph::LayerInfo]) {
    const BANDS: &[(&str, f64, f64)] = &[
        ("Stable (I<0.2)", 0.0, 0.2),
        ("Core (0.2<=I<0.4)", 0.2, 0.4),
        ("Mid (0.4<=I<0.6)", 0.4, 0.6),
        ("Volatile (0.6<=I<0.8)", 0.6, 0.8),
        ("Leaf (I>=0.8)", 0.8, 1.01),
    ];
    for (i, &(label, lo, hi)) in BANDS.iter().enumerate() {
        let members: Vec<&&graph::LayerInfo> = shown
            .iter()
            .filter(|l| l.instability >= lo && l.instability < hi)
            .collect();
        if members.is_empty() {
            continue;
        }
        println!("  subgraph cluster_{i} {{");
        println!("    label={:?};", label);
        println!("    style=dashed;");
        for l in &members {
            let short = l.name.split('/').next_back().unwrap_or(&l.name);
            println!(
                "    {:?} [label={:?}];",
                l.name,
                format!("{}\n{}f, I={:.2}", short, l.file_count, l.instability)
            );
        }
        println!("  }}");
    }
}

fn render_json(layers: &[graph::LayerInfo], violations: &[graph::LayerViolation]) {
    let layers_json: Vec<serde_json::Value> = layers
        .iter()
        .map(|l| {
            serde_json::json!({
                "name": l.name, "level": l.level, "files": l.file_count,
                "fan_in": l.fan_in, "fan_out": l.fan_out, "instability": l.instability,
                "lcom4": l.lcom4, "tcc": l.tcc, "cohesion": l.cohesion
            })
        })
        .collect();
    let violations_json: Vec<serde_json::Value> = violations
        .iter()
        .map(|v| {
            serde_json::json!({
                "from": v.from_module, "to": v.to_module,
                "from_level": v.from_level, "to_level": v.to_level
            })
        })
        .collect();
    println!(
        "{}",
        serde_json::to_string_pretty(&serde_json::json!({
            "layers": layers_json, "violations": violations_json
        }))
        .unwrap_or_default()
    );
}

fn render_plantuml(layers: &[graph::LayerInfo], violations: &[graph::LayerViolation]) {
    println!("@startuml");
    for l in layers {
        println!("package \"{}\" as L{} {{", l.name, l.level);
        println!(
            "  note \"{}f, I={:.2}\" as N{}",
            l.file_count, l.instability, l.level
        );
        println!("}}");
    }
    for v in violations {
        println!("L{} --> L{} #red : violation", v.from_level, v.to_level);
    }
    println!("@enduml");
}

fn sanitize(s: &str) -> String {
    s.replace(['/', '.', '-'], "_")
}

fn render_dsm(
    layers: &[graph::LayerInfo],
    file_imports: &[(String, String)],
    modules: &[graph::Module],
) {
    let shown: Vec<&graph::LayerInfo> =
        layers.iter().filter(|l| l.fan_in + l.fan_out > 0).collect();
    if shown.is_empty() {
        println!("No cross-module dependencies found.");
        return;
    }
    let f2m = dsm_file_to_mod(modules);
    let ec = dsm_edge_counts(file_imports, &f2m);
    let names: Vec<&str> = shown.iter().map(|l| l.name.as_str()).collect();
    let short: Vec<String> = names
        .iter()
        .map(|n| {
            n.split('/')
                .next_back()
                .unwrap_or(n)
                .chars()
                .take(8)
                .collect()
        })
        .collect();
    dsm_print(&names, &short, &ec);
}

fn dsm_file_to_mod(modules: &[graph::Module]) -> HashMap<&str, &str> {
    modules
        .iter()
        .flat_map(|m| m.files.iter().map(move |f| (f.as_str(), m.name.as_str())))
        .collect()
}

fn dsm_edge_counts<'a>(
    imports: &[(String, String)],
    f2m: &HashMap<&'a str, &'a str>,
) -> HashMap<(&'a str, &'a str), usize> {
    let mut ec = HashMap::new();
    for (from, to) in imports {
        let fm = f2m.get(from.as_str()).copied().unwrap_or("");
        let tm = f2m.get(to.as_str()).copied().unwrap_or("");
        if !fm.is_empty() && !tm.is_empty() && fm != tm {
            *ec.entry((fm, tm)).or_default() += 1;
        }
    }
    ec
}

fn dsm_print(names: &[&str], short: &[String], ec: &HashMap<(&str, &str), usize>) {
    let w = 10;
    print!("{:>w$}", "");
    for s in short {
        print!(" {:>5}", &s[..s.len().min(5)]);
    }
    println!();
    for (i, &from) in names.iter().enumerate() {
        print!("{:>w$}", &short[i]);
        for (j, &to) in names.iter().enumerate() {
            if i == j {
                print!("    ██");
            } else {
                let c = ec.get(&(from, to)).copied().unwrap_or(0);
                if c == 0 {
                    print!("     ·");
                } else {
                    print!(" {:>5}", c);
                }
            }
        }
        println!();
    }
}

fn render_terminal(layers: &[graph::LayerInfo], violations: &[graph::LayerViolation]) {
    println!(
        "Modules: {}, Violations: {}\n",
        layers.len(),
        violations.len()
    );
    render_terminal_bands(layers);
    render_terminal_violations(violations);
}

fn render_terminal_bands(layers: &[graph::LayerInfo]) {
    const BANDS: &[(&str, f64, f64)] = &[
        ("🟢 Stable (I<0.2)", 0.0, 0.2),
        ("🔵 Core (0.2≤I<0.4)", 0.2, 0.4),
        ("🟡 Mid (0.4≤I<0.6)", 0.4, 0.6),
        ("🟠 Volatile (0.6≤I<0.8)", 0.6, 0.8),
        ("🔴 Leaf (I≥0.8)", 0.8, 1.01),
    ];
    for &(label, lo, hi) in BANDS {
        let members: Vec<&graph::LayerInfo> = layers
            .iter()
            .filter(|l| l.instability >= lo && l.instability < hi && l.file_count >= 3)
            .collect();
        if members.is_empty() {
            continue;
        }
        println!("  {label}");
        for l in &members {
            let short = l.name.split('/').next_back().unwrap_or(&l.name);
            let tcc = if l.tcc >= 0.0 {
                format!("{:.0}%", l.tcc * 100.0)
            } else {
                "n/a".into()
            };
            let lcom = if l.lcom4 == 1 {
                "".into()
            } else {
                format!("{}", l.lcom4)
            };
            println!(
                "    {:<35} {:>4}f  I={:.2}  TCC={:>4}  {}",
                short, l.file_count, l.instability, tcc, lcom
            );
        }
        println!();
    }
}

fn render_terminal_violations(violations: &[graph::LayerViolation]) {
    if violations.is_empty() {
        return;
    }
    println!("  ⚡ Violations (stable → volatile):");
    for v in violations.iter().take(10) {
        let f = v
            .from_module
            .split('/')
            .next_back()
            .unwrap_or(&v.from_module);
        let t = v.to_module.split('/').next_back().unwrap_or(&v.to_module);
        println!("    {f}{t}");
    }
    if violations.len() > 10 {
        println!("    ... and {} more", violations.len() - 10);
    }
}