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 short_module_name(full: &str, all: &[&str]) -> String {
let prefix = common_path_prefix(all);
let rel = full
.strip_prefix(&prefix)
.unwrap_or(full)
.trim_start_matches('/')
.trim_end_matches("/*")
.trim_end_matches('/');
if rel.is_empty() {
return "(root)".to_string();
}
let parts: Vec<&str> = rel.split('/').collect();
if parts.len() >= 2 {
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
parts[0].to_string()
}
}
fn common_path_prefix(names: &[&str]) -> String {
if names.is_empty() {
return String::new();
}
let parts: Vec<Vec<&str>> = names.iter().map(|n| n.split('/').collect()).collect();
let prefix: Vec<&str> = (0..)
.map_while(|i| {
let first = parts[0].get(i)?;
parts
.iter()
.all(|p| p.get(i) == Some(first))
.then_some(*first)
})
.collect();
prefix.join("/")
}
fn render_dsm(
layers: &[graph::LayerInfo],
file_imports: &[(String, String)],
modules: &[graph::Module],
) {
let mut 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;
}
shown.sort_by_key(|l| std::cmp::Reverse(l.file_count));
shown.truncate(25);
shown.sort_by(|a, b| {
a.instability
.partial_cmp(&b.instability)
.unwrap_or(std::cmp::Ordering::Equal)
});
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| {
let s = short_module_name(n, &names);
s.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]) {
let all_names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
println!(
"Modules: {}, Violations: {}\n",
layers.len(),
violations.len()
);
render_terminal_bands(layers, &all_names);
render_terminal_violations(violations, &all_names);
}
fn render_terminal_bands(layers: &[graph::LayerInfo], all_names: &[&str]) {
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 = short_module_name(&l.name, all_names);
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!(
" {:<30} {:>4}f I={:.2} TCC={:>4} {}",
short, l.file_count, l.instability, tcc, lcom
);
}
println!();
}
}
fn render_terminal_violations(violations: &[graph::LayerViolation], all_names: &[&str]) {
if violations.is_empty() {
return;
}
println!(" ⚡ Violations (stable → volatile):");
for v in violations.iter().take(10) {
let f = short_module_name(&v.from_module, all_names);
let t = short_module_name(&v.to_module, all_names);
println!(" {f} → {t}");
}
if violations.len() > 10 {
println!(" ... and {} more", violations.len() - 10);
}
}