use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use crate::core::property_graph::CodeGraph;
use crate::core::tokens::count_tokens;
use serde_json::{json, Value};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OutputFormat {
Text,
Json,
}
fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
let f = format.unwrap_or("text").trim().to_lowercase();
match f.as_str() {
"text" => Ok(OutputFormat::Text),
"json" => Ok(OutputFormat::Json),
_ => Err("Error: format must be text|json".to_string()),
}
}
pub fn handle(action: &str, path: Option<&str>, root: &str, format: Option<&str>) -> String {
let fmt = match parse_format(format) {
Ok(f) => f,
Err(e) => return e,
};
match action {
"overview" => handle_overview(root, fmt),
"clusters" => handle_clusters(root, fmt),
"communities" => handle_communities(root, fmt),
"layers" => handle_layers(root, fmt),
"cycles" => handle_cycles(root, fmt),
"entrypoints" => handle_entrypoints(root, fmt),
"hotspots" => handle_hotspots(root, fmt),
"health" => handle_health(root, fmt),
"module" => handle_module(path, root, fmt),
_ => "Unknown action. Use: overview, clusters, communities, layers, cycles, entrypoints, hotspots, health, module"
.to_string(),
}
}
fn open_graph(root: &str) -> Result<CodeGraph, String> {
CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
}
struct GraphData {
forward: HashMap<String, Vec<String>>,
reverse: HashMap<String, Vec<String>>,
all_files: HashSet<String>,
}
fn ensure_graph_built(root: &str) {
let Ok(graph) = CodeGraph::open(root) else {
return;
};
if graph.node_count().unwrap_or(0) == 0 {
drop(graph);
let result = crate::tools::ctx_impact::handle("build", None, root, None, None);
tracing::info!(
"Auto-built graph for architecture: {}",
&result[..result.len().min(100)]
);
}
}
fn load_graph_data(graph: &CodeGraph) -> Result<GraphData, String> {
let nodes = graph.node_count().map_err(|e| format!("{e}"))?;
if nodes == 0 {
return Err(
"Graph is empty after auto-build. No supported source files found.".to_string(),
);
}
let conn = &graph.connection();
let mut stmt = conn
.prepare(
"SELECT DISTINCT n_src.file_path, n_tgt.file_path
FROM edges e
JOIN nodes n_src ON e.source_id = n_src.id
JOIN nodes n_tgt ON e.target_id = n_tgt.id
WHERE e.kind = 'imports'
AND n_src.kind = 'file' AND n_tgt.kind = 'file'
AND n_src.file_path != n_tgt.file_path",
)
.map_err(|e| format!("{e}"))?;
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
let mut all_files: HashSet<String> = HashSet::new();
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| format!("{e}"))?;
for row in rows {
let (src, tgt) = row.map_err(|e| format!("{e}"))?;
all_files.insert(src.clone());
all_files.insert(tgt.clone());
forward.entry(src.clone()).or_default().push(tgt.clone());
reverse.entry(tgt).or_default().push(src);
}
let mut file_stmt = conn
.prepare("SELECT DISTINCT file_path FROM nodes WHERE kind = 'file'")
.map_err(|e| format!("{e}"))?;
let file_rows = file_stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("{e}"))?;
for f in file_rows.flatten() {
all_files.insert(f);
}
for deps in forward.values_mut() {
deps.sort();
deps.dedup();
}
for deps in reverse.values_mut() {
deps.sort();
deps.dedup();
}
Ok(GraphData {
forward,
reverse,
all_files,
})
}
fn handle_overview(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let clusters = compute_clusters(&data);
let layers = compute_layers(&data);
let entrypoints = find_entrypoints(&data);
let cycles = find_cycles(&data);
let files_total = data.all_files.len();
let import_edges = data.forward.values().map(std::vec::Vec::len).sum::<usize>();
let clusters_total = clusters.len();
let clusters_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CLUSTERS_LIMIT.max(1);
let clusters_truncated = clusters_total > clusters_limit;
let layers_total = layers.len();
let layers_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_LAYERS_LIMIT.max(1);
let layers_truncated = layers_total > layers_limit;
let entrypoints_total = entrypoints.len();
let entrypoints_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_ENTRYPOINTS_LIMIT.max(1);
let entrypoints_truncated = entrypoints_total > entrypoints_limit;
let cycles_total = cycles.len();
let cycles_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CYCLES_LIMIT.max(1);
let cycles_truncated = cycles_total > cycles_limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let clusters_json: Vec<Value> = clusters
.iter()
.take(clusters_limit)
.map(|c| {
json!({
"dir": common_prefix(&c.files),
"file_count": c.files.len(),
"internal_edges": c.internal_edges
})
})
.collect();
let layers_json: Vec<Value> = layers
.iter()
.take(layers_limit)
.map(|l| {
json!({
"depth": l.depth,
"file_count": l.files.len()
})
})
.collect();
let entrypoints_json: Vec<Value> = entrypoints
.iter()
.take(entrypoints_limit)
.map(|ep| {
let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
json!({ "file": ep, "imports": imports })
})
.collect();
let cycles_json: Vec<Value> = cycles
.iter()
.take(cycles_limit)
.map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "overview",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root),
"files_total": files_total,
"import_edges": import_edges,
"clusters_total": clusters_total,
"clusters": clusters_json,
"clusters_truncated": clusters_truncated,
"layers_total": layers_total,
"layers": layers_json,
"layers_truncated": layers_truncated,
"entrypoints_total": entrypoints_total,
"entrypoints": entrypoints_json,
"entrypoints_truncated": entrypoints_truncated,
"cycles_total": cycles_total,
"cycles": cycles_json,
"cycles_truncated": cycles_truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!(
"Architecture Overview ({files_total} files, {import_edges} import edges)\n"
);
result.push_str(&format!("\nClusters: {clusters_total}\n"));
for (i, cluster) in clusters.iter().enumerate().take(clusters_limit) {
let dir = common_prefix(&cluster.files);
result.push_str(&format!(
" #{}: {} files ({})\n",
i + 1,
cluster.files.len(),
dir
));
}
if clusters_truncated {
result.push_str(&format!(
" ... +{} more\n",
clusters_total - clusters_limit
));
}
result.push_str(&format!("\nLayers: {layers_total}\n"));
for layer in layers.iter().take(layers_limit) {
result.push_str(&format!(
" L{}: {} files\n",
layer.depth,
layer.files.len()
));
}
if layers_truncated {
result.push_str(&format!(" ... +{} more\n", layers_total - layers_limit));
}
result.push_str(&format!("\nEntrypoints: {entrypoints_total}\n"));
for ep in entrypoints.iter().take(entrypoints_limit) {
result.push_str(&format!(" {ep}\n"));
}
if entrypoints_truncated {
result.push_str(&format!(
" ... +{} more\n",
entrypoints_total - entrypoints_limit
));
}
result.push_str(&format!("\nCycles: {cycles_total}\n"));
for cycle in cycles.iter().take(cycles_limit) {
result.push_str(&format!(" {}\n", cycle.join(" -> ")));
}
if cycles_truncated {
result.push_str(&format!(" ... +{} more\n", cycles_total - cycles_limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture: {tokens} tok]")
}
}
}
fn handle_clusters(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let clusters = compute_clusters(&data);
let total = clusters.len();
let limit = crate::core::budgets::ARCHITECTURE_CLUSTERS_LIMIT.max(1);
let file_limit = crate::core::budgets::ARCHITECTURE_CLUSTER_FILES_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = clusters
.iter()
.take(limit)
.map(|c| {
let dir = common_prefix(&c.files);
let files_total = c.files.len();
let files_truncated = files_total > file_limit;
let mut files = c.files.clone();
if files_truncated {
files.truncate(file_limit);
}
json!({
"dir": dir,
"file_count": files_total,
"internal_edges": c.internal_edges,
"files": files,
"files_truncated": files_truncated
})
})
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "clusters",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root),
"clusters_total": total,
"clusters": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Module Clusters ({total}):\n");
for (i, cluster) in clusters.iter().take(limit).enumerate() {
let dir = common_prefix(&cluster.files);
result.push_str(&format!(
"\n#{} — {} ({} files, {} internal edges)\n",
i + 1,
dir,
cluster.files.len(),
cluster.internal_edges
));
for file in cluster.files.iter().take(file_limit) {
result.push_str(&format!(" {file}\n"));
}
if cluster.files.len() > file_limit {
result.push_str(&format!(
" ... +{} more\n",
cluster.files.len() - file_limit
));
}
}
if truncated {
result.push_str(&format!("\n... +{} more clusters\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture clusters: {tokens} tok]")
}
}
}
fn handle_communities(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let result = crate::core::community::detect_communities(graph.connection());
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let comms: Vec<Value> = result
.communities
.iter()
.take(30)
.map(|c| {
json!({
"id": c.id,
"file_count": c.files.len(),
"files": c.files.iter().take(20).collect::<Vec<_>>(),
"internal_edges": c.internal_edges,
"external_edges": c.external_edges,
"cohesion": (c.cohesion * 100.0).round() / 100.0,
})
})
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "communities",
"project": project_meta(root),
"graph": graph_summary(root_path),
"modularity": (result.modularity * 1000.0).round() / 1000.0,
"node_count": result.node_count,
"edge_count": result.edge_count,
"community_count": result.communities.len(),
"communities": comms
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut out = format!(
"Community Detection (Louvain) — {} communities, modularity {:.3}\n\n",
result.communities.len(),
result.modularity
);
for c in result.communities.iter().take(20) {
out.push_str(&format!(
" Community #{}: {} files, cohesion {:.0}%, {} internal / {} external edges\n",
c.id,
c.files.len(),
c.cohesion * 100.0,
c.internal_edges,
c.external_edges
));
for f in c.files.iter().take(10) {
out.push_str(&format!(" {f}\n"));
}
if c.files.len() > 10 {
out.push_str(&format!(" ... +{} more\n", c.files.len() - 10));
}
}
if result.communities.len() > 20 {
out.push_str(&format!(
"\n ... +{} more communities\n",
result.communities.len() - 20
));
}
let tokens = count_tokens(&out);
format!("{out}\n[ctx_architecture communities: {tokens} tok]")
}
}
}
fn handle_layers(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let layers = compute_layers(&data);
let total = layers.len();
let limit = crate::core::budgets::ARCHITECTURE_LAYERS_LIMIT.max(1);
let file_limit = crate::core::budgets::ARCHITECTURE_LAYER_FILES_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = layers
.iter()
.take(limit)
.map(|l| {
let files_total = l.files.len();
let files_truncated = files_total > file_limit;
let mut files = l.files.clone();
if files_truncated {
files.truncate(file_limit);
}
json!({
"depth": l.depth,
"file_count": files_total,
"files": files,
"files_truncated": files_truncated
})
})
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "layers",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root),
"layers_total": total,
"layers": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Dependency Layers ({total}):\n");
for layer in layers.iter().take(limit) {
result.push_str(&format!(
"\nLayer {} ({} files):\n",
layer.depth,
layer.files.len()
));
for file in layer.files.iter().take(file_limit) {
result.push_str(&format!(" {file}\n"));
}
if layer.files.len() > file_limit {
result.push_str(&format!(" ... +{} more\n", layer.files.len() - file_limit));
}
}
if truncated {
result.push_str(&format!("\n... +{} more layers\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture layers: {tokens} tok]")
}
}
}
fn handle_cycles(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let cycles = find_cycles(&data);
if cycles.is_empty() {
return match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "cycles",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root),
"cycles_total": 0,
"cycles": []
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => "No dependency cycles found.".to_string(),
};
}
let total = cycles.len();
let limit = crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = cycles
.iter()
.take(limit)
.map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "cycles",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root),
"cycles_total": total,
"cycles": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Dependency Cycles ({total}):\n");
for (i, cycle) in cycles.iter().take(limit).enumerate() {
result.push_str(&format!("\n#{}: {}\n", i + 1, cycle.join(" -> ")));
}
if truncated {
result.push_str(&format!("\n... +{} more cycles\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture cycles: {tokens} tok]")
}
}
}
fn handle_entrypoints(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let entrypoints = find_entrypoints(&data);
let total = entrypoints.len();
let limit = crate::core::budgets::ARCHITECTURE_ENTRYPOINTS_LIMIT.max(1);
let truncated = total > limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let items: Vec<Value> = entrypoints
.iter()
.take(limit)
.map(|ep| {
let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
json!({ "file": ep, "imports": imports })
})
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "entrypoints",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root),
"entrypoints_total": total,
"entrypoints": items,
"truncated": truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!("Entrypoints ({total} — files with no dependents):\n");
for ep in entrypoints.iter().take(limit) {
let dep_count = data.forward.get(ep).map_or(0, std::vec::Vec::len);
result.push_str(&format!(" {ep} (imports {dep_count} files)\n"));
}
if truncated {
result.push_str(&format!(" ... +{} more\n", total - limit));
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture entrypoints: {tokens} tok]")
}
}
}
fn handle_hotspots(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let pr_input = crate::core::pagerank::PageRankInput {
files: data.all_files.clone(),
forward: data.forward.clone(),
};
let pagerank = crate::core::pagerank::compute(&pr_input, 0.85, 30);
let cfg = crate::core::smells::SmellConfig::default();
let findings = crate::core::smells::scan_all(graph.connection(), &cfg);
let mut smell_count: HashMap<String, usize> = HashMap::new();
for f in &findings {
*smell_count.entry(f.file_path.clone()).or_default() += 1;
}
let mut hotspots: Vec<(String, f64, f64, usize, usize)> = pagerank
.iter()
.map(|(file, &rank)| {
let in_edges = data.reverse.get(file).map_or(0, Vec::len);
let out_edges = data.forward.get(file).map_or(0, Vec::len);
let smells = smell_count.get(file).copied().unwrap_or(0);
let score = rank * 0.4
+ (in_edges + out_edges) as f64 * 0.01 * 0.3
+ smells as f64 * 0.05 * 0.3;
(file.clone(), score, rank, in_edges + out_edges, smells)
})
.collect();
hotspots.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let limit = 30;
match fmt {
OutputFormat::Json => {
let items: Vec<Value> = hotspots
.iter()
.take(limit)
.map(|(file, score, rank, edges, smells)| {
json!({
"file": file,
"score": (score * 1000.0).round() / 1000.0,
"pagerank": (rank * 10000.0).round() / 10000.0,
"edges": edges,
"smells": smells
})
})
.collect();
let v = json!({
"tool": "ctx_architecture",
"action": "hotspots",
"total_files": data.all_files.len(),
"hotspots": items
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!(
"Hotspots ({} files analyzed)\n\n {:<50} {:>8} {:>8} {:>6} {:>6}\n",
data.all_files.len(),
"File",
"Score",
"PageRank",
"Edges",
"Smells"
);
result.push_str(&format!(" {}\n", "-".repeat(82)));
for (file, score, rank, edges, smells) in hotspots.iter().take(limit) {
let display = if file.len() > 48 {
format!("...{}", &file[file.len() - 45..])
} else {
file.clone()
};
result.push_str(&format!(
" {display:<50} {score:>8.3} {rank:>8.4} {edges:>6} {smells:>6}\n"
));
}
if hotspots.len() > limit {
result.push_str(&format!("\n ... +{} more\n", hotspots.len() - limit));
}
let tokens = count_tokens(&result);
format!("{result}\n[ctx_architecture hotspots: {tokens} tok]")
}
}
}
fn handle_health(root: &str, fmt: OutputFormat) -> String {
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let communities = crate::core::community::detect_communities(graph.connection());
let cfg = crate::core::smells::SmellConfig::default();
let findings = crate::core::smells::scan_all(graph.connection(), &cfg);
let summary = crate::core::smells::summarize(&findings);
let cycles = find_cycles(&data);
let layers = compute_layers(&data);
let total_smells: usize = summary.iter().map(|s| s.findings).sum();
let files = data.all_files.len();
let edges = data.forward.values().map(Vec::len).sum::<usize>();
let smell_density = if files > 0 {
total_smells as f64 / files as f64
} else {
0.0
};
let avg_cohesion = if communities.communities.is_empty() {
0.0
} else {
communities
.communities
.iter()
.map(|c| c.cohesion)
.sum::<f64>()
/ communities.communities.len() as f64
};
let health_score = compute_health_score(
smell_density,
avg_cohesion,
communities.modularity,
cycles.len(),
files,
);
let grade = match health_score {
s if s >= 90.0 => "A",
s if s >= 80.0 => "B",
s if s >= 65.0 => "C",
s if s >= 50.0 => "D",
_ => "F",
};
match fmt {
OutputFormat::Json => {
let smell_items: Vec<Value> = summary
.iter()
.filter(|s| s.findings > 0)
.map(|s| json!({"rule": s.rule, "findings": s.findings}))
.collect();
let v = json!({
"tool": "ctx_architecture",
"action": "health",
"health_score": (health_score * 10.0).round() / 10.0,
"grade": grade,
"files": files,
"edges": edges,
"total_smells": total_smells,
"smell_density": (smell_density * 100.0).round() / 100.0,
"modularity": (communities.modularity * 1000.0).round() / 1000.0,
"avg_cohesion": (avg_cohesion * 100.0).round() / 100.0,
"communities": communities.communities.len(),
"cycles": cycles.len(),
"layers": layers.len(),
"smells_by_rule": smell_items
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!(
"Architecture Health Report\n\n Score: {health_score:.0}/100 (Grade: {grade})\n Files: {files}\n Edges: {edges}\n"
);
result.push_str(&format!(
" Communities: {} (modularity {:.3}, avg cohesion {:.0}%)\n",
communities.communities.len(),
communities.modularity,
avg_cohesion * 100.0
));
result.push_str(&format!(
" Cycles: {}\n Layers: {}\n Smells: {} (density {:.2}/file)\n",
cycles.len(),
layers.len(),
total_smells,
smell_density
));
if total_smells > 0 {
result.push_str("\n Smell breakdown:\n");
for s in &summary {
if s.findings > 0 {
result.push_str(&format!(" {:<25} {:>3}\n", s.rule, s.findings));
}
}
}
let tokens = count_tokens(&result);
format!("{result}\n[ctx_architecture health: {tokens} tok]")
}
}
}
fn compute_health_score(
smell_density: f64,
avg_cohesion: f64,
modularity: f64,
cycle_count: usize,
file_count: usize,
) -> f64 {
let smell_penalty = (smell_density * 10.0).min(30.0);
let cohesion_bonus = avg_cohesion * 20.0;
let modularity_bonus = modularity.max(0.0) * 30.0;
let cycle_penalty = (cycle_count as f64 * 5.0).min(20.0);
let size_factor = if file_count > 1000 { 0.95 } else { 1.0 };
let raw =
(50.0 + cohesion_bonus + modularity_bonus - smell_penalty - cycle_penalty) * size_factor;
raw.clamp(0.0, 100.0)
}
fn handle_module(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
let Some(target) = path else {
return "path is required for 'module' action".to_string();
};
ensure_graph_built(root);
let graph = match open_graph(root) {
Ok(g) => g,
Err(e) => return e,
};
let data = match load_graph_data(&graph) {
Ok(d) => d,
Err(e) => return e,
};
let canon_root = crate::core::pathutil::safe_canonicalize(std::path::Path::new(root))
.map_or_else(|_| root.to_string(), |p| p.to_string_lossy().to_string());
let canon_target = crate::core::pathutil::safe_canonicalize(std::path::Path::new(target))
.map_or_else(|_| target.to_string(), |p| p.to_string_lossy().to_string());
let root_slash = if canon_root.ends_with('/') {
canon_root.clone()
} else {
format!("{canon_root}/")
};
let rel = canon_target
.strip_prefix(&root_slash)
.or_else(|| canon_target.strip_prefix(&canon_root))
.unwrap_or(&canon_target)
.trim_start_matches('/');
let prefix = if rel.contains('/') {
rel.rsplitn(2, '/').last().unwrap_or(rel)
} else {
rel
};
let mut module_files: Vec<String> = data
.all_files
.iter()
.filter(|f| f.starts_with(prefix))
.cloned()
.collect();
module_files.sort();
if module_files.is_empty() {
return format!("No files found in module path '{prefix}'");
}
let file_set: HashSet<&str> = module_files
.iter()
.map(std::string::String::as_str)
.collect();
let mut internal_edges = 0;
let mut external_imports: Vec<String> = Vec::new();
let mut external_dependents: Vec<String> = Vec::new();
for file in &module_files {
if let Some(deps) = data.forward.get(file) {
for dep in deps {
if file_set.contains(dep.as_str()) {
internal_edges += 1;
} else {
external_imports.push(format!("{file} -> {dep}"));
}
}
}
if let Some(revs) = data.reverse.get(file) {
for rev in revs {
if !file_set.contains(rev.as_str()) {
external_dependents.push(format!("{rev} -> {file}"));
}
}
}
}
external_imports.sort();
external_imports.dedup();
external_dependents.sort();
external_dependents.dedup();
let files_total = module_files.len();
let file_limit = crate::core::budgets::ARCHITECTURE_MODULE_FILES_LIMIT.max(1);
let files_truncated = files_total > file_limit;
match fmt {
OutputFormat::Json => {
let root_path = Path::new(root);
let files: Vec<String> = module_files.iter().take(file_limit).cloned().collect();
let ext_limit = 50usize;
let ext_imports_total = external_imports.len();
let ext_dependents_total = external_dependents.len();
let imports_truncated = ext_imports_total > ext_limit;
let dependents_truncated = ext_dependents_total > ext_limit;
let imports: Vec<String> = external_imports.iter().take(ext_limit).cloned().collect();
let dependents: Vec<String> = external_dependents
.iter()
.take(ext_limit)
.cloned()
.collect();
let v = json!({
"schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
"tool": "ctx_architecture",
"action": "module",
"project": project_meta(root),
"graph": graph_summary(root_path),
"graph_meta": crate::core::property_graph::load_meta(root),
"module_prefix": prefix,
"file_count": files_total,
"internal_edges": internal_edges,
"files": files,
"files_truncated": files_truncated,
"external_imports_total": ext_imports_total,
"external_imports": imports,
"external_imports_truncated": imports_truncated,
"external_dependents_total": ext_dependents_total,
"external_dependents": dependents,
"external_dependents_truncated": dependents_truncated
});
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
}
OutputFormat::Text => {
let mut result = format!(
"Module '{prefix}' ({files_total} files, {internal_edges} internal edges)\n"
);
result.push_str("\nFiles:\n");
for f in module_files.iter().take(file_limit) {
result.push_str(&format!(" {f}\n"));
}
if files_truncated {
result.push_str(&format!(" ... +{} more\n", files_total - file_limit));
}
if !external_imports.is_empty() {
result.push_str(&format!(
"\nExternal imports ({}):\n",
external_imports.len()
));
for imp in external_imports.iter().take(15) {
result.push_str(&format!(" {imp}\n"));
}
if external_imports.len() > 15 {
result.push_str(&format!(" ... +{} more\n", external_imports.len() - 15));
}
}
if !external_dependents.is_empty() {
result.push_str(&format!(
"\nExternal dependents ({}):\n",
external_dependents.len()
));
for dep in external_dependents.iter().take(15) {
result.push_str(&format!(" {dep}\n"));
}
if external_dependents.len() > 15 {
result.push_str(&format!(" ... +{} more\n", external_dependents.len() - 15));
}
}
let tokens = count_tokens(&result);
format!("{result}[ctx_architecture module: {tokens} tok]")
}
}
}
#[derive(Debug)]
struct Cluster {
files: Vec<String>,
internal_edges: usize,
}
fn compute_clusters(data: &GraphData) -> Vec<Cluster> {
let mut dir_groups: HashMap<String, Vec<String>> = HashMap::new();
for file in &data.all_files {
let dir = file.rsplitn(2, '/').last().unwrap_or("").to_string();
dir_groups.entry(dir).or_default().push(file.clone());
}
let mut clusters: Vec<Cluster> = Vec::new();
for files in dir_groups.values() {
if files.len() < 2 {
continue;
}
let file_set: HashSet<&str> = files.iter().map(std::string::String::as_str).collect();
let mut internal = 0;
for file in files {
if let Some(deps) = data.forward.get(file) {
for dep in deps {
if file_set.contains(dep.as_str()) {
internal += 1;
}
}
}
}
let mut sorted = files.clone();
sorted.sort();
clusters.push(Cluster {
files: sorted,
internal_edges: internal,
});
}
clusters.sort_by(|a, b| {
b.files
.len()
.cmp(&a.files.len())
.then_with(|| a.files[0].cmp(&b.files[0]))
});
clusters
}
struct Layer {
depth: usize,
files: Vec<String>,
}
fn compute_layers(data: &GraphData) -> Vec<Layer> {
let leaf_files: HashSet<&String> = data
.all_files
.iter()
.filter(|f| data.forward.get(*f).is_none_or(std::vec::Vec::is_empty))
.collect();
let mut depth_map: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
for leaf in &leaf_files {
depth_map.insert((*leaf).clone(), 0);
queue.push_back(((*leaf).clone(), 0));
}
while let Some((file, depth)) = queue.pop_front() {
if let Some(dependents) = data.reverse.get(&file) {
for dep in dependents {
let new_depth = depth + 1;
let current = depth_map.get(dep).copied().unwrap_or(0);
if new_depth > current {
depth_map.insert(dep.clone(), new_depth);
queue.push_back((dep.clone(), new_depth));
}
}
}
}
for file in &data.all_files {
depth_map.entry(file.clone()).or_insert(0);
}
let max_depth = depth_map.values().copied().max().unwrap_or(0);
let mut layers: Vec<Layer> = Vec::new();
for d in 0..=max_depth {
let mut files: Vec<String> = depth_map
.iter()
.filter(|(_, &depth)| depth == d)
.map(|(f, _)| f.clone())
.collect();
if !files.is_empty() {
files.sort();
layers.push(Layer { depth: d, files });
}
}
layers
}
fn find_entrypoints(data: &GraphData) -> Vec<String> {
let mut entrypoints: Vec<String> = data
.all_files
.iter()
.filter(|f| !data.reverse.contains_key(*f))
.cloned()
.collect();
entrypoints.sort();
entrypoints
}
fn find_cycles(data: &GraphData) -> Vec<Vec<String>> {
let mut cycles: Vec<Vec<String>> = Vec::new();
let mut visited: HashSet<String> = HashSet::new();
let mut starts: Vec<&String> = data.all_files.iter().collect();
starts.sort();
for start in starts {
if visited.contains(start) {
continue;
}
let mut stack: Vec<String> = Vec::new();
let mut on_stack: HashSet<String> = HashSet::new();
dfs_cycles(
start,
&data.forward,
&mut stack,
&mut on_stack,
&mut visited,
&mut cycles,
);
}
cycles.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
cycles.truncate(crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1));
cycles
}
fn dfs_cycles(
node: &str,
graph: &HashMap<String, Vec<String>>,
stack: &mut Vec<String>,
on_stack: &mut HashSet<String>,
visited: &mut HashSet<String>,
cycles: &mut Vec<Vec<String>>,
) {
if on_stack.contains(node) {
let cycle_start = stack.iter().position(|n| n == node).unwrap_or(0);
let mut cycle: Vec<String> = stack[cycle_start..].to_vec();
cycle.push(node.to_string());
cycles.push(cycle);
return;
}
if visited.contains(node) {
return;
}
on_stack.insert(node.to_string());
stack.push(node.to_string());
if let Some(deps) = graph.get(node) {
for dep in deps {
dfs_cycles(dep, graph, stack, on_stack, visited, cycles);
}
}
stack.pop();
on_stack.remove(node);
visited.insert(node.to_string());
}
fn common_prefix(files: &[String]) -> String {
if files.is_empty() {
return String::new();
}
if files.len() == 1 {
return files[0]
.rsplitn(2, '/')
.last()
.unwrap_or(&files[0])
.to_string();
}
let parts: Vec<Vec<&str>> = files.iter().map(|f| f.split('/').collect()).collect();
let min_len = parts.iter().map(std::vec::Vec::len).min().unwrap_or(0);
let mut common = Vec::new();
for i in 0..min_len {
let segment = parts[0][i];
if parts.iter().all(|p| p[i] == segment) {
common.push(segment);
} else {
break;
}
}
if common.is_empty() {
"(root)".to_string()
} else {
common.join("/")
}
}
fn project_meta(root: &str) -> Value {
let root_hash = crate::core::project_hash::hash_project_root(root);
let identity_hash = crate::core::project_hash::project_identity(root)
.as_deref()
.map(crate::core::hasher::hash_str);
let root_path = Path::new(root);
json!({
"project_root_hash": root_hash,
"project_identity_hash": identity_hash,
"git": {
"head": git_out(root_path, &["rev-parse", "--short", "HEAD"]),
"branch": git_out(root_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
"dirty": git_dirty(root_path)
}
})
}
fn graph_summary(project_root: &Path) -> Value {
let root_str = project_root.to_string_lossy();
let graph_dir = crate::core::property_graph::graph_dir(&root_str);
let db_path = graph_dir.join("graph.db");
let db_path_display = db_path.display().to_string();
if !db_path.exists() {
return json!({
"exists": false,
"db_path": db_path_display,
"nodes": null,
"edges": null
});
}
match CodeGraph::open(&root_str) {
Ok(g) => json!({
"exists": true,
"db_path": g.db_path().display().to_string(),
"nodes": g.node_count().ok(),
"edges": g.edge_count().ok()
}),
Err(_) => json!({
"exists": true,
"db_path": db_path_display,
"nodes": null,
"edges": null
}),
}
}
fn git_dirty(project_root: &Path) -> bool {
let out = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(project_root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => !o.stdout.is_empty(),
_ => false,
}
}
fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
let out = std::process::Command::new("git")
.args(args)
.current_dir(project_root)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let s = s.trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn common_prefix_single() {
let files = vec!["src/core/cache.rs".to_string()];
assert_eq!(common_prefix(&files), "src/core");
}
#[test]
fn common_prefix_multiple() {
let files = vec![
"src/core/cache.rs".to_string(),
"src/core/config.rs".to_string(),
"src/core/session.rs".to_string(),
];
assert_eq!(common_prefix(&files), "src/core");
}
#[test]
fn common_prefix_different_dirs() {
let files = vec![
"src/tools/ctx_read.rs".to_string(),
"src/core/cache.rs".to_string(),
];
assert_eq!(common_prefix(&files), "src");
}
#[test]
fn entrypoints_no_dependents() {
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
forward.insert("main.rs".to_string(), vec!["lib.rs".to_string()]);
let all_files: HashSet<String> = ["main.rs", "lib.rs"]
.iter()
.map(std::string::ToString::to_string)
.collect();
let data = GraphData {
forward,
reverse: {
let mut r = HashMap::new();
r.insert("lib.rs".to_string(), vec!["main.rs".to_string()]);
r
},
all_files,
};
let eps = find_entrypoints(&data);
assert_eq!(eps, vec!["main.rs"]);
}
#[test]
fn layers_simple_chain() {
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
forward.insert("b.rs".to_string(), vec!["c.rs".to_string()]);
let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
reverse.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
reverse.insert("c.rs".to_string(), vec!["b.rs".to_string()]);
let all_files: HashSet<String> = ["a.rs", "b.rs", "c.rs"]
.iter()
.map(std::string::ToString::to_string)
.collect();
let data = GraphData {
forward,
reverse,
all_files,
};
let layers = compute_layers(&data);
assert!(layers.len() >= 2);
let layer0 = layers.iter().find(|l| l.depth == 0).unwrap();
assert!(layer0.files.contains(&"c.rs".to_string()));
let layer2 = layers.iter().find(|l| l.depth == 2).unwrap();
assert!(layer2.files.contains(&"a.rs".to_string()));
}
#[test]
fn cycles_detection() {
let mut forward: HashMap<String, Vec<String>> = HashMap::new();
forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
forward.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
let all_files: HashSet<String> = ["a.rs", "b.rs"]
.iter()
.map(std::string::ToString::to_string)
.collect();
let data = GraphData {
forward,
reverse: HashMap::new(),
all_files,
};
let cycles = find_cycles(&data);
assert!(!cycles.is_empty());
}
#[test]
fn handle_unknown() {
let result = handle("invalid", None, "/tmp", None);
assert!(result.contains("Unknown action"));
}
}