use std::collections::HashSet;
use std::path::Path;
use anyhow::{Context, Result};
use tracing::info;
use crate::analysis;
use crate::config::ProjectConfig;
use crate::db::Database;
use crate::graph_builder;
use crate::models::DriftScore;
use crate::scoring;
pub fn run_analyze(
repo_path: &Path,
repo_id: &str,
commit_ish: Option<&str>,
db: &Database,
project_config: &ProjectConfig,
) -> Result<()> {
let commit_hash = resolve_commit(repo_path, commit_ish)?;
let short_hash = if commit_hash.len() >= 7 {
&commit_hash[..7]
} else {
&commit_hash
};
info!(hash = %commit_hash, "Analyzing commit");
let mut snapshot = db
.get_graph_snapshot(repo_id, &commit_hash)?
.with_context(|| format!("No graph snapshot found for this commit: {short_hash}"))?;
if snapshot.requires_core_recompute() || snapshot.needs_full_analysis() {
let prev_snapshot = match db.get_scan_order(repo_id, &commit_hash)? {
Some(scan_order) => db.get_previous_snapshot(repo_id, scan_order)?,
None => None,
};
let prev_graph = prev_snapshot.map(|previous| {
let nodes: HashSet<String> = previous.nodes.into_iter().collect();
graph_builder::build_graph(&nodes, &previous.edges)
});
let nodes: HashSet<String> = snapshot.nodes.iter().cloned().collect();
let artifacts = analysis::build_snapshot_artifacts(
&nodes,
&snapshot.edges,
prev_graph.as_ref(),
snapshot.timestamp,
&project_config.scoring,
analysis::SnapshotAnalysisDetail::Full,
);
snapshot.node_count = artifacts.graph.node_count();
snapshot.edge_count = artifacts.graph.edge_count();
snapshot.drift = Some(artifacts.drift);
snapshot.blast_radius = artifacts.blast_radius;
snapshot.instability_metrics = artifacts.instability_metrics;
snapshot.diagnostics = artifacts.diagnostics;
}
println!(" Commit Analysis: {short_hash}");
println!();
if let Some(ref drift) = snapshot.drift {
print_drift_report(
drift,
snapshot.node_count,
snapshot.edge_count,
project_config,
);
} else {
println!(" No drift score calculated for this commit.");
println!(" Run 'morpharch scan <path>' to re-scan first.");
return Ok(());
}
println!();
println!(" Temporal Analysis (comparison with previous commits):");
println!();
if let Some(scan_order) = db.get_scan_order(repo_id, &commit_hash)? {
let prev_commits = db.list_previous_drift_entries(repo_id, scan_order, 3)?;
if prev_commits.is_empty() {
println!(" No earlier commits available.");
} else {
let header = format!(
" {:<9} {:>6} {:>6} {:>7} {:>8}",
"HASH", "NODES", "EDGES", "DRIFT", "DELTA"
);
println!("{header}");
let separator = format!(" {}", "-".repeat(45));
println!("{separator}");
let current_drift = snapshot.drift.as_ref().map(|d| d.total).unwrap_or(0);
for (prev_hash, _msg, prev_nodes, prev_edges, prev_drift, _ts) in &prev_commits {
let prev_short = if prev_hash.len() >= 7 {
&prev_hash[..7]
} else {
prev_hash
};
let drift_str = prev_drift
.map(|d| format!("{d}"))
.unwrap_or_else(|| "?".to_string());
let delta = prev_drift
.map(|d| current_drift as i32 - d as i32)
.map(|d| {
if d > 0 {
format!("+{d}")
} else {
format!("{d}")
}
})
.unwrap_or_else(|| "?".to_string());
println!(
" {:<9} {:>6} {:>6} {:>7} {:>8}",
prev_short, prev_nodes, prev_edges, drift_str, delta
);
}
}
} else {
println!(" This commit was not found in the trend data.");
}
println!();
print_boundary_details(&snapshot.edges, project_config);
println!();
print_cycle_info(&snapshot.nodes, &snapshot.edges);
println!();
print_blast_radius(&snapshot);
println!();
print_recommendations(&snapshot.drift);
Ok(())
}
fn resolve_commit(repo_path: &Path, commit_ish: Option<&str>) -> Result<String> {
let repo = gix::discover(repo_path)
.with_context(|| format!("Git repository not found: {}", repo_path.display()))?;
let reference = commit_ish.unwrap_or("HEAD");
let object = repo
.rev_parse_single(reference)
.with_context(|| format!("Failed to resolve commit reference: '{reference}'"))?;
Ok(object.detach().to_string())
}
fn print_drift_report(
drift: &DriftScore,
node_count: usize,
edge_count: usize,
config: &ProjectConfig,
) {
let (emoji, level) = match drift.total {
0..=15 => (" ", "Excellent"),
16..=30 => (" ", "Healthy"),
31..=55 => (" ", "Warning"),
56..=80 => (" ", "Degraded"),
_ => (" ", "Critical"),
};
let n = config.scoring.weights.normalized();
let pct = |v: f64| -> u32 { (v * 100.0).round() as u32 };
println!("{emoji} Drift Score: {}/100 ({level})", drift.total);
println!(" Health: {}%", 100u8.saturating_sub(drift.total));
println!();
println!(" Graph Statistics:");
println!(" Node (module) count: {node_count}");
println!(" Edge (dependency) count: {edge_count}");
println!();
println!(" Component Breakdown (6-factor analysis):");
println!(
" Cycles ({:>2}%): {:5.1}/100 {} SCCs",
pct(n.cycle),
drift.cycle_debt,
drift.new_cycles
);
println!(
" Layering ({:>2}%): {:5.1}/100 {} cross-links",
pct(n.layering),
drift.layering_debt,
drift.layering_violations
);
println!(
" Hub/God ({:>2}%): {:5.1}/100",
pct(n.hub),
drift.hub_debt
);
println!(
" Coupling ({:>2}%): {:5.1}/100",
pct(n.coupling),
drift.coupling_debt
);
println!(
" Cognitive ({:>2}%): {:5.1}/100",
pct(n.cognitive),
drift.cognitive_debt
);
println!(
" Instability ({:>2}%): {:5.1}/100",
pct(n.instability),
drift.instability_debt
);
println!();
println!(" Delta Metrics:");
println!(" Fan-in change (median): {:+}", drift.fan_in_delta);
println!(" Fan-out change (median): {:+}", drift.fan_out_delta);
}
fn print_boundary_details(edges: &[crate::models::DependencyEdge], config: &ProjectConfig) {
let pairs = scoring::edges_to_pairs(edges);
let violations: Vec<_> = if config.scoring.boundaries.is_empty() {
pairs
.iter()
.filter(|(from, to)| {
scoring::LEGACY_BOUNDARY_RULES
.iter()
.any(|(fp, tp)| from.starts_with(fp) && to.starts_with(tp))
})
.collect()
} else {
pairs
.iter()
.filter(|(from, to)| {
config
.scoring
.boundaries
.iter()
.any(|rule| rule.matches(from, to))
})
.collect()
};
if violations.is_empty() {
println!(" Boundary Violations: None — package boundaries are clean.");
} else {
println!(" Boundary Violations ({} found):", violations.len());
for (i, (from, to)) in violations.iter().enumerate().take(10) {
println!(" {}. {} -> {}", i + 1, from, to);
}
if violations.len() > 10 {
println!(" ... and {} more", violations.len() - 10);
}
}
}
fn print_cycle_info(nodes: &[String], edges: &[crate::models::DependencyEdge]) {
let node_set: HashSet<String> = nodes.iter().cloned().collect();
let graph = graph_builder::build_graph(&node_set, edges);
let cycle_count = scoring::count_cycles_public(&graph);
if cycle_count == 0 {
println!(" Cyclic Dependencies: None — DAG structure is maintained.");
} else {
println!(" Cyclic Dependencies: {cycle_count} cycle(s) detected.");
println!(" Cycles increase architectural complexity and make refactoring harder.");
}
}
fn print_recommendations(drift: &Option<DriftScore>) {
println!(" Recommendations:");
let Some(d) = drift else {
println!(" No drift score calculated — run 'morpharch scan' first.");
return;
};
let mut suggestions = Vec::new();
if d.cycle_debt > 20.0 {
suggestions.push(format!(
" {} circular dependency group(s) detected (score: {:.0}/100). \
Some modules depend on each other in loops — breaking these cycles \
with interfaces or traits will make the code easier to maintain.",
d.new_cycles, d.cycle_debt
));
}
if d.layering_debt > 20.0 {
suggestions.push(format!(
" {} extra edge(s) inside dependency cycles (score: {:.0}/100). \
The dependency flow isn't clean — organizing layers to depend \
only in one direction would improve clarity.",
d.layering_violations, d.layering_debt
));
}
if d.hub_debt > 20.0 {
suggestions.push(format!(
" Some modules are doing too much (score: {:.0}/100). They connect to \
many others in both directions. Splitting them into smaller, \
focused modules would reduce the blast radius of changes.",
d.hub_debt
));
}
if d.coupling_debt > 20.0 {
suggestions.push(format!(
" Modules are more tightly connected than expected (score: {:.0}/100). \
Adding abstractions between heavily coupled modules would \
improve flexibility and make changes safer.",
d.coupling_debt
));
}
if d.cognitive_debt > 20.0 {
suggestions.push(format!(
" The dependency structure is complex (score: {:.0}/100). \
There are more connections than needed. Simplifying the wiring \
would make the architecture easier to understand and navigate.",
d.cognitive_debt
));
}
if d.instability_debt > 20.0 {
suggestions.push(format!(
" Some core modules are fragile (score: {:.0}/100). They depend \
heavily on others, so upstream changes may cascade through them. \
Adding abstractions would help stabilize them.",
d.instability_debt
));
}
if d.total <= 15 {
suggestions.push(
" Architecture looks great — clean structure with minimal coupling.".to_string(),
);
} else if d.total <= 30 {
suggestions
.push(" Overall healthy architecture with minor areas for improvement.".to_string());
}
if suggestions.is_empty() {
suggestions.push(" Architecture is in an acceptable state.".to_string());
}
for suggestion in &suggestions {
println!("{suggestion}");
}
}
fn print_blast_radius(snapshot: &crate::models::GraphSnapshot) {
println!(" ── Blast Radius Analysis ──");
println!();
match &snapshot.blast_radius {
Some(br) => {
if br.articulation_points.is_empty() {
println!(" Structural Keystones: None — graph has good redundancy.");
} else {
println!(
" Structural Keystones ({} found):",
br.articulation_points.len()
);
for (i, ap) in br.articulation_points.iter().enumerate().take(5) {
println!(
" {}. {} (bridges {} components, {}in/{}out)",
i + 1,
ap.module_name,
ap.components_bridged,
ap.fan_in,
ap.fan_out
);
}
}
println!();
println!(" Highest Impact Modules:");
for (i, m) in br.impacts.iter().take(5).enumerate() {
let ap_marker = if m.is_articulation_point {
" [keystone]"
} else {
""
};
println!(
" {}. {} — {:.0}% blast radius ({} downstream){}",
i + 1,
m.module_name,
m.blast_score * 100.0,
m.downstream_count,
ap_marker,
);
}
println!();
if !br.critical_paths.is_empty() {
println!(" Critical Dependency Chains:");
for (i, path) in br.critical_paths.iter().take(3).enumerate() {
println!(
" {}. {} (depth {}, weight {})",
i + 1,
path.chain.join(" → "),
path.depth,
path.total_weight,
);
}
}
}
None => {
println!(" Not computed. Re-scan to generate blast radius data.");
}
}
}