Compare commits

..

No commits in common. "2b14234d6c051a90ae2d62e64b3293f8db626e25" and "33114847122813ccc7d63141495744b2a0013a40" have entirely different histories.

37 changed files with 9352 additions and 2460 deletions

2
.mono/config Normal file
View file

@ -0,0 +1,2 @@
[project]
name = "code-analysis"

1
.mono/refs/HEAD Normal file
View file

@ -0,0 +1 @@
c813292f1400c21462beea82e2f3bcb5a3b7cc8dc8ca26010cd530d6f9166658

View file

@ -1,392 +0,0 @@
# cstat Grounding Plan
## 1. Project Intent
`cstat` is a Rust codebase-shape analysis tool. It exists to make messy Rust codebases easier to inspect, reason about, and improve in bounded steps.
The project should prove three linked ideas:
1. **Codebase shape exploration** — existing probes expose different structural views of a Rust codebase.
2. **Verified complexity function** — those probes can feed a deterministic, explainable complexity/cleanup function.
3. **RLVF harness / environment** — the tool can support baseline, attempted cleanup, verification, re-measurement, and structured feedback for human or agent-driven codebase fixing.
This project is not trying to become a general-purpose static analyzer, automatic refactoring engine, full RL training system, architecture platform, or web product.
## 2. End-State Goals
### 2.1 Codebase Shape Exploration
`cstat` should let a user explore the shape of a Rust codebase through multiple static lenses. Existing probes are valuable because each one describes a different part of codebase shape.
Relevant shape views include:
- line/file size and distribution;
- symbol counts and concentration;
- module dependencies and coupling;
- per-function and per-file complexity;
- static dead-code candidates;
- static test/benchmark reachability;
- call-flow or call-trace views where useful;
- coverage or cluster views where they directly support exploration or verification.
The goal is not to hide these probes. The goal is to make clear why they exist, when to use them, and how they contribute to the larger project purpose.
### 2.2 Verified Complexity Function
`cstat` should define one deterministic complexity or cleanup function derived from existing metrics.
This function should be:
- deterministic for the same input codebase;
- explainable from its component metrics;
- stable enough to consume from JSON output;
- tested against fixtures and invariants;
- honest about what it measures.
“Verified” does not mean the function proves true code quality. It means the function has a stated contract, stable inputs and outputs, and tests showing that obvious structural changes move the score in expected directions.
Example invariants:
- adding branches should not reduce the complexity component;
- adding large functions should not improve the size/complexity result;
- adding unnecessary dependencies should not improve coupling;
- reducing test reachability should not improve the verification signal;
- preserving behavior while lowering measured complexity should improve the cleanup signal.
### 2.3 RLVF Harness / Environment
`cstat` should support an RLVF-style loop for codebase-fixing attempts.
The minimal loop is:
1. establish a baseline shape/complexity measurement;
2. allow a human or agent to make a bounded cleanup attempt;
3. run the project’s verification command, such as tests or build;
4. re-measure shape and complexity;
5. emit structured feedback describing whether the attempt improved the measured objective while preserving required behavior.
The harness does not need to train a model. It does not need to call an LLM. It does not need to apply patches automatically. For this project, the harness is the environment and feedback mechanism around a codebase-changing attempt.
## 3. Boundaries / Non-Goals
The MVP stays bounded by these rules:
- Rust-only for now.
- Static-first analysis.
- Existing probes should be organized before new probes are added.
- New analysis is only in scope if it directly supports shape exploration, the complexity function, or the RLVF harness.
- No automatic refactoring.
- No RL training loop.
- No LLM integration.
- No promise of perfect macro expansion, dynamic dispatch resolution, or runtime truth.
- No web UI.
- No plugin system.
- No multi-language support.
- No broad benchmark suite before the core loop works.
- No feature is justified merely because it is interesting.
A change is in scope only if it improves one of these:
- codebase shape exploration;
- the verified complexity function;
- the RLVF harness/environment;
- the demo proving those concepts;
- the documentation needed to make the project understandable.
## 4. Current Assets
The project already has substantial useful material:
- multiple CLI probes for structural codebase analysis;
- focused root commands for each maintained analysis path;
- JSON output modes;
- tests around several CLI behaviors;
- static analysis modules for size, symbols, dependencies, complexity, dead-code candidates, reachability, call flow, coverage, clustering, and related views;
- `cstat-agent-use.md`, which already points toward agent-oriented tool use;
- enough real code for dogfooding the tool on itself.
These assets should be treated as the foundation, not as clutter to discard by default.
## 5. Current Gaps
The missing pieces are mostly framing, contracts, and end-to-end proof.
### 5.1 Purpose and CLI Organization
The CLI exposes many capabilities, but the project purpose is not yet clearly organized around shape exploration, complexity scoring, and RLVF feedback.
The probes work, but they need to be presented as intentional views of codebase shape rather than as unrelated commands.
### 5.2 Verified Complexity Function
The project does not yet define a single named complexity/cleanup function with:
- explicit metric inputs;
- a deterministic formula;
- a stable JSON output shape;
- stated invariants;
- tests proving those invariants.
### 5.3 RLVF Harness Flow
The project does not yet demonstrate the full loop:
- baseline measurement;
- bounded cleanup attempt;
- project verification;
- after measurement;
- structured feedback/reward evidence.
Without this loop, the RLVF claim is only conceptual.
### 5.4 Demo
The project needs a concrete demo showing that the tool works end to end.
The demo should show:
- human exploration of a messy codebase;
- machine-readable output suitable for an agent;
- a before/after verification path;
- how the complexity function changes after a bounded cleanup.
### 5.5 Public Explanation
The README is too thin to communicate what the project is, why it exists, or how the existing pieces fit together.
The project needs enough surface-level polish that another developer can understand:
- what problem `cstat` solves;
- what commands or profiles to start with;
- what the metrics mean;
- what the limitations are;
- how the demo proves the core claims.
## 6. Gap-Fill Plan
### 6.1 Organize Existing Probes
Document the existing probes as codebase-shape views.
For each probe, clarify:
- what shape dimension it exposes;
- whether it is mainly useful for humans, agents, RLVF feedback, or all three;
- what command produces it;
- whether the output is stable enough for machine use.
This should make the existing breadth feel intentional without requiring every probe to become part of the core proof.
### 6.2 Define Tool-Use Profiles
Define limited profiles that combine existing probes for specific workflows.
Candidate profiles:
- **human exploration profile** — emphasizes readable summaries and cleanup starting points;
- **agent steering profile** — emphasizes compact JSON, hotspots, constraints, and suggested drilldowns;
- **RLVF profile** — emits stable fields needed for before/after feedback and reward calculation.
Profiles should be thin orchestration over existing analysis where possible.
### 6.3 Add the Complexity Function
Define a named complexity or cleanup cost function.
The implementation should reuse existing metrics first. The first version should prioritize determinism, explainability, and tests over sophistication.
The output should include:
- score or cost value;
- score version;
- component breakdown;
- top contributors/hotspots;
- enough metadata to compare before/after results.
### 6.4 Verify the Complexity Function
Add tests that prove the function’s contract.
The tests should focus on observable properties, not incidental implementation details.
Useful test cases include:
- a small/simple fixture has lower cost than a deliberately messy fixture;
- adding control-flow branches increases or preserves complexity cost;
- adding dead private functions worsens the relevant component;
- reducing test reachability does not improve the RLVF signal;
- JSON output contains the expected stable fields.
### 6.5 Build the RLVF Harness Flow
Create the minimal harness path for codebase-fixing attempts.
The harness should produce an artifact that records:
- task or attempt metadata;
- baseline profile output;
- verification command and result;
- after profile output;
- complexity delta;
- feedback/reward signal;
- reasons for acceptance or rejection.
The harness should remain separate from model training or patch generation.
### 6.6 Add an End-to-End Demo
The demo should prove the project’s core claims without becoming a second product.
A good demo path is dogfooding `cstat` on itself:
1. run shape exploration on the current repo;
2. identify an obvious cleanup target from the output;
3. make or describe a bounded cleanup attempt;
4. run project verification;
5. re-run the relevant profile;
6. show the feedback artifact.
If dogfooding is too noisy, add a small fixture crate that intentionally contains a few simple forms of messiness.
### 6.7 Polish Public Explanation
Update the public explanation after the core loop exists.
The README should explain:
- the three project goals;
- the basic commands or profiles;
- one short demo;
- what the metrics do and do not mean;
- how humans and agents use the tool differently;
- the non-goals that keep the project bounded.
## 7. Completion Criteria
The MVP is complete when all of the following are true:
- The project can be explained in one paragraph.
- Existing probes are framed as codebase-shape views.
- A deterministic complexity function exists.
- The complexity function has stated inputs, output, and invariants.
- Tests verify the complexity function’s basic contract.
- An RLVF-style harness flow exists.
- The harness produces structured feedback for a before/after cleanup attempt.
- A demo shows shape exploration, complexity scoring, verification, and feedback.
- The README explains the project clearly enough for a new developer to try it.
- Non-goals are documented and used to reject unrelated scope.
## 8. Scope Guardrail
The project should not expand just because another metric, command, or dashboard would be interesting.
The guardrail is:
> If a change does not support codebase shape exploration, the verified complexity function, the RLVF harness, the demo, or the public explanation of those pieces, it is outside the MVP.
## 9. Current CLI Polish Pass
We are currently going through the existing CLI commands and probes one by one. The goal is to polish, clarify, and prune the extant surface before designing RLVF targets or higher-level harness behavior.
This pass is not about adding new analyzers. It is about deciding, for each existing command:
- what codebase-shape concept it measures;
- whether the measure is useful for humans, agents, and later RLVF workflows;
- whether the output is granular enough;
- whether the behavior is tested well enough;
- whether the CLI/help/docs explain it clearly;
- whether any parts should be renamed, folded into another concept, demoted, or dropped.
Only after this CLI/probe pass is complete should we design the RLVF target or targets, because the RLVF harness needs stable, well-understood measurement primitives.
## 10. Current Branch / Worktree Status
- Main line now includes the accepted `loc` polish work.
- `loc` is a first-class line/size-shape probe.
- The `loc` branch/worktree was folded in and removed.
- Main line now includes the accepted `symbols` polish work.
- `symbols` is a first-class symbol-shape probe.
- It clarifies impl/trait-impl semantics, selected-file behavior, JSON output, and symbol granularity.
- The `symbols` branch/worktree was folded in and removed.
- `-complexity` branch:
- The old broad `complexity` command has been sharded.
- The sharding direction is accepted.
- Each new individual item still needs review before it is ready to fold in.
- Current stopping point: complexity concept split exists, but the resulting probes are not yet accepted as polished.
## 11. Stopping Point
- We resumed the CLI polish/pruning pass long enough to fold in the accepted `loc` and `symbols` work.
- `loc` and `symbols` are now the first accepted polished probes on the main line.
- We agreed that the old `complexity` command was too broad and vague.
- We narrowed the intended complexity split to specific probes from the existing complexity behavior:
- branching/control-flow path complexity;
- signature/type-boundary complexity;
- span/function-body size.
- We agreed not to treat all codebase complexity as one vague command.
- Current next review point is the sharded `-complexity` worktree/branch.
## 12. Next Steps
1. Review the sharded `-complexity` items individually.
2. Accept, revise, or reject each complexity shard based on whether it is granular, objective, tested, and useful for human/agent/RLVF workflows.
3. Fold in only the accepted complexity shards.
4. Continue the same polish/pruning review for the remaining existing CLI commands.
5. After the measurement primitives are stable, design the RLVF target or targets around those accepted probes.
## 13. Extreme Bare Minimum
Before the broader MVP work, the project needs a narrow, polished, resume-linkable slice that proves `cstat` is already usable.
This slice is not the full verified complexity function or RLVF harness. It is the smallest public path that shows a clean CLI and a credible human/agent demo.
### 13.1 Polished CLI MVP
The CLI MVP should expose a small accepted command surface rather than every existing probe.
For this slice:
- `loc` is folded in as an accepted line/size-shape probe;
- `symbols` is folded in as an accepted symbol-shape probe;
- present both as first-class codebase-shape probes;
- make command names, help text, examples, and output modes clear enough for a new user to run without project context;
- keep JSON output stable enough for agent consumption;
- hide, demote, or leave out unfinished rough commands from the public happy path.
The goal is a CLI that feels intentional and usable, not a complete analysis platform.
### 13.2 Human Demo
Add a short demo showing how a person uses the polished CLI on a Rust repo.
The demo should show:
- the exact commands to run;
- how to read file/size shape output;
- how to read symbol concentration output;
- how those outputs point to likely cleanup targets;
- one brief investigation or before/after walkthrough.
### 13.3 AI-Agent Demo
Add a short demo showing how an AI agent should consume the polished CLI.
The demo should show:
- the exact commands an agent should run;
- the JSON output path or mode it should consume;
- how to identify hotspots from structured output;
- how to choose a bounded next probe or cleanup target from that evidence.
This demo should stop at agent steering. It does not need the full RLVF harness, model training, automatic patching, or reward loop.
### 13.4 Completion Criteria
The extreme bare minimum is complete when:
- the polished CLI path is narrow, documented, and runnable;
- `loc` and `symbols` are folded into the main line as accepted probes;
- rough unfinished commands are not part of the primary public path;
- a human demo explains how to use the output;
- an AI-agent demo explains how to consume the JSON and pick a bounded next action;
- the README can support a resume link without implying the broader MVP is finished.

View file

@ -4,7 +4,6 @@ Terminal CLI tool to examine a codebase through a statistical lens
## helpful commands
```bash
cstat --help # cli options
cstat scorecard --path . --json # machine-readable code complexity cost
cstat loc --path . -v # size-shape analysis plus explanations
cstat test-reachability --path . --json # structured static test/bench reachability
cstat -v # basic dashboard plus explanation
cstat <any> -v # runs the command and gives an explanation of the metrics
```

View file

@ -9,19 +9,28 @@ and tests preserve behavior.
## Starting point
Start with the focused commands that answer the current cleanup question:
Run the focused report first:
```sh
cstat scorecard --json --path <project_root>
cstat loc --json --path <project_root>
cstat symbols --json --path <project_root>
cstat deps --json --path <project_root>
cstat dead-code --json --path <project_root>
cstat test-reachability --json --path <project_root>
cstat report --json --path <project_root>
```
The stale `summary`, `report`, and `advanced` entry points have been removed.
Use focused root commands directly.
Omitting the command is equivalent to `cstat summary`, which is the same focused
report. The JSON contains:
- `line_counts`: total code lines, physical lines, and per-file counts.
- `symbols`: total and per-file counts for functions, structs, enums, traits,
`trait_impls`, consts, statics, and parse-error state. `trait_impls` means
`impl Trait for Type` blocks only, not inherent `impl Type` blocks.
- `dependencies`: module list, module interdependency matrix, edge count, and
fan-in/fan-out degrees.
- `dead_code`: functions not statically reachable from main, tests, or
benchmarks.
- `test_reachability`: static test/benchmark reachability, step counts, max
depth, and per-function/per-edge reaching-entry counts.
The focused report intentionally excludes older exploratory dashboards. They
remain available under `cstat advanced ...`.
## Focused drilldowns
@ -39,16 +48,28 @@ Each accepts `--json` for structured output.
- `cstat dead-code --json --path .` — static cold-function candidates.
- `cstat test-reachability --json --path .` — static test/benchmark
reachability and reaching-entry counts.
- `cstat branching --json --path .` — per-function decision/path complexity.
- `cstat signature --json --path .` — per-function API boundary complexity.
- `cstat span --json --path .` — per-function implementation span metrics.
- `cstat scorecard --json --path .` — deterministic structural complexity cost
for agent optimization, with component costs, scope breakdown, and hotspots.
- `cstat complexity --json --path .` — per-function cyclomatic, cognitive,
nesting, parameter, and line metrics.
## Advanced / legacy commands
Use these only when the focused report points to a question they answer:
- `cstat advanced summary --json --path .` — previous broad dashboard.
- `cstat advanced flow --json --path .` — raw static call graph.
- `cstat advanced graph --json --path .` — graph centrality, SCCs, PageRank.
- `cstat advanced flow-heatmap --json --path .` — previous random-walk heatmap.
- `cstat advanced circuits --json --path .` — call-graph community detection.
- `cstat advanced redundancy --json --path .` — similar/duplicate function
candidates.
- `cstat advanced dist --json --path .` — metric histograms/correlation views.
- `cstat advanced map --json --path .` — terminal architecture map.
- `cstat advanced dump --path .` — previous diagnostic-score dump.
- `cstat advanced guide <topic>` — metric reference material.
## Cleanup workflow
1. Run the focused `cstat` commands that match the cleanup target.
1. Run `cstat report --json --path .`.
2. Read the focused sections in this order:
- line counts: find oversized files first;
- symbols: find files with too many definitions;
@ -56,8 +77,8 @@ Each accepts `--json` for structured output.
- dead code: review cold candidates before deleting;
- test reachability: compare code surface against what tests/benches can statically reach.
3. Plan one cleanup stage at a time.
4. After each stage, rerun the same focused `cstat` commands and the project's
own build/tests. cstat metrics do not prove correctness.
4. After each stage, rerun `cstat report --json --path .` and the project's own
build/tests. cstat metrics do not prove correctness.
## Safety rules

View file

@ -481,6 +481,11 @@ fn type_complexity(ty: &Type) -> usize {
}
}
/// Convert a Type to a readable string (public wrapper for cross-module use).
pub fn type_to_string_pub(ty: &Type) -> String {
type_to_string(ty)
}
/// Convert a Type to a readable string.
fn type_to_string(ty: &Type) -> String {
match ty {

View file

@ -762,16 +762,14 @@ fn render_entry_listing(listing: &EntryListing) {
fn render_human(output: &CallTraceJson, verbose: bool) {
println!("cstat call trace");
println!("static possible-call expansion; not a runtime execution trace");
if verbose {
render::verbose_block(&[
"Built from cstat's Rust source call graph.",
"Branches are not evaluated; every statically resolved callee is shown.",
"Static call graph rendered from one selected root.",
"Cycles stop expansion; shared functions expand in each stack.",
]);
}
println!("resolved entry:");
println!("resolved:");
match output.resolved.source {
EntrySource::CargoRun => {
println!(" cargo command: cargo run --");
@ -803,22 +801,19 @@ fn render_human(output: &CallTraceJson, verbose: bool) {
}
}
println!("static expansion summary:");
println!();
render_trace_node(&output.tree);
println!();
println!("summary:");
println!(" functions reached: {}", output.functions_reached);
println!(" max depth: {}", output.max_depth);
println!();
println!("tree:");
render_trace_node(&output.tree);
}
fn render_file_call_trace_report(report: &FileCallTraceReport, verbose: bool) {
println!("cstat file call trace ({})", report.file);
println!("static possible-call expansion per non-test function; not runtime execution traces");
if verbose {
render::verbose_block(&[
"Built from cstat's Rust source call graph.",
"Branches are not evaluated; every statically resolved callee is shown.",
"Static call graph rendered for every non-test function in the selected file.",
"Cycles stop expansion; shared functions expand in each stack.",
]);
}

687
src/circuits.rs Normal file
View file

@ -0,0 +1,687 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::flow;
use crate::render;
// ── Label Propagation Community Detection ────────────────────────
/// Detect communities in the call graph using label propagation.
/// Each function gets a label (initially its own index). Iteratively,
/// each node adopts the most frequent label among its neighbors.
/// Collect label frequency counts from a node's neighbors in both directions.
/// Returns a map of label -> occurrence count across all adjacent nodes.
fn collect_neighbor_labels(
node: &str,
adj: &HashMap<String, Vec<String>>,
rev_adj: &HashMap<String, Vec<String>>,
node_idx: &HashMap<&str, usize>,
labels: &[usize],
) -> HashMap<usize, usize> {
let mut label_counts: HashMap<usize, usize> = HashMap::new();
if let Some(succs) = adj.get(node) {
for s in succs {
if let Some(&idx) = node_idx.get(s.as_str()) {
*label_counts.entry(labels[idx]).or_insert(0) += 1;
}
}
}
if let Some(preds) = rev_adj.get(node) {
for p in preds {
if let Some(&idx) = node_idx.get(p.as_str()) {
*label_counts.entry(labels[idx]).or_insert(0) += 1;
}
}
}
label_counts
}
/// Detect communities in the call graph using label propagation.
/// Each function gets a label (initially its own index). Iteratively,
/// each node adopts the most frequent label among its neighbors.
/// Converges when labels stabilize.
fn label_propagation(
nodes: &[String],
adj: &HashMap<String, Vec<String>>,
rev_adj: &HashMap<String, Vec<String>>,
) -> Vec<Vec<String>> {
let n = nodes.len();
if n == 0 {
return vec![];
}
let node_idx: HashMap<&str, usize> = nodes
.iter()
.enumerate()
.map(|(i, n)| (n.as_str(), i))
.collect();
let mut labels: Vec<usize> = (0..n).collect();
// Create a deterministic node ordering for iteration (sorted by name)
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|a, b| nodes[*a].cmp(&nodes[*b]));
// Iterate until convergence or max iterations
let max_iter = 50;
for _ in 0..max_iter {
let mut changed = false;
for &i in &order {
let label_counts = collect_neighbor_labels(&nodes[i], adj, rev_adj, &node_idx, &labels);
if label_counts.is_empty() {
continue;
}
// Pick the most frequent label; break ties by smallest label for determinism
let max_count = *label_counts.values().max().unwrap();
let best_label = label_counts
.iter()
.filter(|(_, &c)| c == max_count)
.map(|(&l, _)| l)
.min()
.unwrap();
if best_label != labels[i] {
labels[i] = best_label;
changed = true;
}
}
if !changed {
break;
}
}
// Group nodes by label
let mut communities: HashMap<usize, Vec<String>> = HashMap::new();
for (i, label) in labels.iter().enumerate() {
communities
.entry(*label)
.or_default()
.push(nodes[i].clone());
}
let mut result: Vec<Vec<String>> = communities.into_values().collect();
// Sort communities by size (largest first), then by first member name for stability
result.sort_by(|a, b| b.len().cmp(&a.len()).then(a[0].cmp(&b[0])));
result
}
// ── Circuit Analysis ─────────────────────────────────────────────
struct Circuit {
members: Vec<String>,
boundary_fns: Vec<String>, // called from outside the circuit
internal_edges: usize,
cohesion: f64,
}
struct CircuitAnalysis {
circuits: Vec<Circuit>,
inter_circuit_edges: Vec<(usize, usize, usize)>, // (from_circuit, to_circuit, weight)
modularity: f64,
}
/// Compute modularity Q for a given partition.
/// Q = (1/m) * Σ_ij [A_ij - (k_out_i * k_in_j / m)] * δ(c_i, c_j)
/// where m = total edges, k_out/k_in = degrees, δ = same community
fn compute_modularity(
nodes: &[String],
adj: &HashMap<String, Vec<String>>,
partition: &HashMap<String, usize>, // node -> circuit index
) -> f64 {
let m: usize = adj.values().map(|v| v.len()).sum();
if m == 0 {
return 0.0;
}
let mut out_deg: HashMap<&str, usize> = HashMap::new();
let mut in_deg: HashMap<&str, usize> = HashMap::new();
for n in nodes {
out_deg.insert(n.as_str(), 0);
in_deg.insert(n.as_str(), 0);
}
for (from, tos) in adj {
*out_deg.entry(from.as_str()).or_insert(0) += tos.len();
for to in tos {
*in_deg.entry(to.as_str()).or_insert(0) += 1;
}
}
let m_f = m as f64;
let mut q = 0.0;
for (from, tos) in adj {
let c_from = match partition.get(from.as_str()) {
Some(&c) => c,
None => continue,
};
for to in tos {
let c_to = match partition.get(to.as_str()) {
Some(&c) => c,
None => continue,
};
if c_from == c_to {
let k_out = *out_deg.get(from.as_str()).unwrap_or(&0) as f64;
let k_in = *in_deg.get(to.as_str()).unwrap_or(&0) as f64;
q += 1.0 - (k_out * k_in / m_f);
}
}
}
q / m_f
}
/// Build Circuit structs from detected communities, computing boundary
/// functions, internal edges, and cohesion for each circuit.
fn build_circuits(
communities: &[Vec<String>],
cg: &flow::CallGraph,
rev_adj: &HashMap<String, Vec<String>>,
partition: &HashMap<String, usize>,
) -> Vec<Circuit> {
let mut circuits: Vec<Circuit> = Vec::new();
for (ci, community) in communities.iter().enumerate() {
let member_set: HashSet<&str> = community.iter().map(|s| s.as_str()).collect();
// Boundary functions: called from outside the circuit
let mut boundary_fns = Vec::new();
for member in community {
let is_boundary = rev_adj.get(member.as_str()).map_or(false, |callers| {
callers
.iter()
.any(|c| partition.get(c.as_str()).copied().unwrap_or(ci) != ci)
});
if is_boundary {
boundary_fns.push(member.clone());
}
}
boundary_fns.sort();
// Internal edges: edges where both endpoints are in this circuit
let mut internal_edges = 0usize;
for member in community {
if let Some(callees) = cg.adjacency.get(member.as_str()) {
for callee in callees {
if member_set.contains(callee.as_str()) {
internal_edges += 1;
}
}
}
}
// Cohesion: internal edges / possible internal edges
let n = community.len();
let possible = if n > 1 { n * (n - 1) } else { 1 };
let cohesion = internal_edges as f64 / possible as f64;
circuits.push(Circuit {
members: community.clone(),
boundary_fns,
internal_edges,
cohesion,
});
}
circuits
}
/// Compute inter-circuit edges: count directed call edges between each
/// pair of circuits, returned sorted by weight (heaviest first).
fn compute_inter_circuit_edges(
cg: &flow::CallGraph,
partition: &HashMap<String, usize>,
) -> Vec<(usize, usize, usize)> {
let mut inter_edges: HashMap<(usize, usize), usize> = HashMap::new();
for (caller, callees) in &cg.adjacency {
let c_from = match partition.get(caller.as_str()) {
Some(&c) => c,
None => continue,
};
for callee in callees {
let c_to = match partition.get(callee.as_str()) {
Some(&c) => c,
None => continue,
};
if c_from != c_to {
*inter_edges.entry((c_from, c_to)).or_insert(0) += 1;
}
}
}
let mut result: Vec<(usize, usize, usize)> = inter_edges
.into_iter()
.map(|((a, b), w)| (a, b, w))
.collect();
result.sort_by(|a, b| b.2.cmp(&a.2));
result
}
/// Run full circuit analysis on the call graph.
fn analyze_circuits(cg: &flow::CallGraph, min_size: usize) -> CircuitAnalysis {
// Build reverse adjacency
let mut rev_adj: HashMap<String, Vec<String>> = HashMap::new();
for f in &cg.functions {
rev_adj.entry(f.clone()).or_default();
}
for (caller, callees) in &cg.adjacency {
for callee in callees {
rev_adj
.entry(callee.clone())
.or_default()
.push(caller.clone());
}
}
let communities = label_propagation(&cg.functions, &cg.adjacency, &rev_adj);
// Build partition map: node -> circuit index
let mut partition: HashMap<String, usize> = HashMap::new();
for (ci, community) in communities.iter().enumerate() {
for node in community {
partition.insert(node.clone(), ci);
}
}
let circuits = build_circuits(&communities, cg, &rev_adj, &partition);
let inter_circuit_edges = compute_inter_circuit_edges(cg, &partition);
let modularity = compute_modularity(&cg.functions, &cg.adjacency, &partition);
// Filter by min_size
let filtered_circuits: Vec<Circuit> = circuits
.into_iter()
.filter(|c| c.members.len() >= min_size)
.collect();
CircuitAnalysis {
circuits: filtered_circuits,
inter_circuit_edges,
modularity,
}
}
fn render_overview(analysis: &CircuitAnalysis, total_functions: usize, verbose: bool) {
println!(
"\n{}",
"── Circuit Overview ──────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Community detection via label propagation on the call graph.",
"Each node starts with its own label; iteratively adopts the most frequent",
"neighbor label until stable. Produces functional clusters (circuits).",
"Modularity Q measures partition quality: Q > 0.3 = well-separated modules,",
" Q ≈ 0 = random partition, Q < 0 = worse than random.",
"Sparkline shows relative circuit sizes (█ = largest, ▁ = smallest).",
]);
render::guide_ref("modularity");
}
let sizes: Vec<usize> = analysis.circuits.iter().map(|c| c.members.len()).collect();
println!();
println!(" ┌──────────────────────────────────────┐");
println!(
" │ {:<18} {:>17} │",
"Circuits".cyan(),
format!("{}", analysis.circuits.len()).bold()
);
println!(
" │ {:<18} {:>17} │",
"Functions covered".cyan(),
format!("{}", total_functions).bold()
);
if !sizes.is_empty() {
let max_size = sizes.iter().max().unwrap_or(&0);
let min_size = sizes.iter().min().unwrap_or(&0);
let avg_size = sizes.iter().sum::<usize>() as f64 / sizes.len() as f64;
println!(
" │ {:<18} {:>17} │",
"Largest circuit".cyan(),
format!("{}", max_size).bold()
);
println!(
" │ {:<18} {:>17} │",
"Smallest circuit".cyan(),
format!("{}", min_size).bold()
);
println!(
" │ {:<18} {:>17} │",
"Average size".cyan(),
format!("{:.1}", avg_size).bold()
);
}
println!(
" │ {:<18} {:>17} │",
"Modularity (Q)".cyan(),
if analysis.modularity > 0.0 {
format!("{:.4}", analysis.modularity)
.green()
.bold()
.to_string()
} else {
format!("{:.4}", analysis.modularity)
.red()
.bold()
.to_string()
}
);
println!(" └──────────────────────────────────────┘");
// Size distribution sparkline
if sizes.len() > 1 {
let max_s = *sizes.iter().max().unwrap_or(&1) as f64;
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let sparkline: String = sizes
.iter()
.map(|&s| {
let idx = ((s as f64 / max_s) * 7.0).round() as usize;
blocks[idx.min(7)]
})
.collect();
println!(
"\n {} {}",
"Size distribution:".dimmed(),
sparkline.bright_yellow()
);
}
}
fn render_circuit_details(analysis: &CircuitAnalysis, verbose: bool) {
println!(
"\n{}",
"── Circuits (by size) ────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Each circuit listed with its members, sorted by size (largest first).",
"Cohesion = internal edges / possible internal edges. Higher = tighter cluster.",
" 1.0 = every pair of members calls each other; 0.0 = no internal calls.",
"◆ (blue) = boundary function: called from outside the circuit = the circuit's",
" API surface. These are the functions other circuits depend on.",
"· (dim) = internal function: only called from within the same circuit.",
"'Connects to' = other circuits this one exchanges calls with (weight = edge count).",
]);
}
for (i, circuit) in analysis.circuits.iter().enumerate() {
println!();
let header = format!(
" Circuit #{} — {} function{}",
i + 1,
circuit.members.len(),
if circuit.members.len() == 1 { "" } else { "s" },
);
println!("{}", header.yellow().bold());
// Stats line
println!(
" Cohesion: {:.2} Internal edges: {} Boundary fns: {}",
circuit.cohesion,
circuit.internal_edges,
circuit.boundary_fns.len(),
);
// Members, marking boundary functions
let boundary_set: HashSet<&str> = circuit.boundary_fns.iter().map(|s| s.as_str()).collect();
for member in &circuit.members {
if boundary_set.contains(member.as_str()) {
println!(" {} {}", "◆".bright_blue(), member.bright_white(),);
} else {
println!(" {} {}", "·".dimmed(), member.dimmed(),);
}
}
// External dependencies for this circuit
let circuit_deps: Vec<&(usize, usize, usize)> = analysis
.inter_circuit_edges
.iter()
.filter(|(from, to, _)| *from == i || *to == i)
.collect();
if !circuit_deps.is_empty() {
let mut dep_summary: Vec<(usize, usize)> = Vec::new(); // (other_circuit, total_weight)
let mut seen: HashSet<usize> = HashSet::new();
for &&(from, to, _w) in &circuit_deps {
let other = if from == i { to } else { from };
if seen.insert(other) {
// Sum both directions
let total: usize = analysis
.inter_circuit_edges
.iter()
.filter(|(a, b, _)| (*a == i && *b == other) || (*a == other && *b == i))
.map(|(_, _, w)| w)
.sum();
dep_summary.push((other, total));
}
}
dep_summary.sort_by(|a, b| b.1.cmp(&a.1));
print!(" {} ", "Connects to:".dimmed());
let dep_strs: Vec<String> = dep_summary
.iter()
.take(5)
.map(|(other, w)| format!("#{} ({})", other + 1, w))
.collect();
println!("{}", dep_strs.join(", ").dimmed());
}
}
}
fn render_inter_circuit_flow(analysis: &CircuitAnalysis, verbose: bool) {
println!(
"\n{}",
"── Inter-Circuit Flow ────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Cross-circuit call volume between each pair of circuits.",
"⇄ = bidirectional coupling (calls flow both ways between the two circuits).",
"→ / ← = unidirectional flow. Number = total call edges between the pair.",
"Bar length proportional to total edge count. Color: red = heaviest coupling,",
" yellow = moderate, green = light.",
"High bidirectional coupling between circuits suggests they may belong together",
"or need a cleaner interface boundary.",
]);
}
if analysis.inter_circuit_edges.is_empty() {
println!(" {}", "No cross-circuit calls detected.".dimmed());
return;
}
// Aggregate bidirectional: merge (a→b) and (b→a) into one row
let mut pairs: HashMap<(usize, usize), (usize, usize)> = HashMap::new(); // (min, max) -> (fwd, bwd)
for &(from, to, w) in &analysis.inter_circuit_edges {
let key = if from <= to { (from, to) } else { (to, from) };
let entry = pairs.entry(key).or_insert((0, 0));
if from <= to {
entry.0 += w;
} else {
entry.1 += w;
}
}
let mut pair_list: Vec<((usize, usize), usize, usize)> = pairs
.into_iter()
.map(|((a, b), (fwd, bwd))| ((a, b), fwd, bwd))
.collect();
pair_list.sort_by(|a, b| (b.1 + b.2).cmp(&(a.1 + a.2)));
let term_w = render::terminal_width();
let bar_budget = 20.min(term_w.saturating_sub(40));
let max_weight = pair_list.iter().map(|(_, f, b)| f + b).max().unwrap_or(1);
println!();
for ((a, b), fwd, bwd) in pair_list.iter().take(15) {
let total = fwd + bwd;
let bar_len = if max_weight > 0 {
(total as f64 / max_weight as f64 * bar_budget as f64).ceil() as usize
} else {
0
}
.max(1);
let bar = "█".repeat(bar_len);
let ratio = total as f64 / max_weight as f64;
let colored_bar = if ratio > 0.66 {
bar.red().bold().to_string()
} else if ratio > 0.33 {
bar.yellow().to_string()
} else {
bar.green().to_string()
};
let direction = if *fwd > 0 && *bwd > 0 {
format!("#{} ⇄ #{}", a + 1, b + 1)
} else if *fwd > 0 {
format!("#{} → #{}", a + 1, b + 1)
} else {
format!("#{} ← #{}", a + 1, b + 1)
};
println!(
" {:<16} {} {}",
direction,
colored_bar,
format!("{} calls", total).dimmed(),
);
}
if pair_list.len() > 15 {
println!(
" {}",
format!("... and {} more connections", pair_list.len() - 15).dimmed()
);
}
println!();
}
/// Compact summary data for embedding in the dashboard.
pub struct CircuitSummary {
/// Number of circuits.
pub circuit_count: usize,
/// Size of each circuit (largest first).
pub sizes: Vec<usize>,
/// Modularity score.
pub modularity: f64,
}
/// Compute compact circuit summary for dashboard use.
pub fn circuit_summary(rs_files: &[PathBuf], project_path: &Path) -> Option<CircuitSummary> {
let cg = flow::build_call_graph(rs_files, project_path);
if cg.functions.is_empty() {
return None;
}
let analysis = analyze_circuits(&cg, 1);
if analysis.circuits.is_empty() {
return None;
}
let sizes: Vec<usize> = analysis.circuits.iter().map(|c| c.members.len()).collect();
Some(CircuitSummary {
circuit_count: analysis.circuits.len(),
sizes,
modularity: analysis.modularity,
})
}
// ── Public entry point ───────────────────────────────────────────
pub fn render_circuits(rs_files: &[PathBuf], project_path: &Path, min_size: usize, verbose: bool) {
let cg = flow::build_call_graph(rs_files, project_path);
if cg.functions.is_empty() {
println!("{}", "No functions found for circuit analysis.".yellow());
return;
}
let total_functions = cg.functions.len();
let analysis = analyze_circuits(&cg, min_size);
if analysis.circuits.is_empty() {
println!(
"{}",
"No circuits found (try lowering --min-size).".yellow()
);
return;
}
render_overview(&analysis, total_functions, verbose);
render_circuit_details(&analysis, verbose);
render_inter_circuit_flow(&analysis, verbose);
}
#[derive(Serialize)]
struct CircuitJson {
members: Vec<String>,
boundary_functions: Vec<String>,
internal_edges: usize,
cohesion: f64,
}
#[derive(Serialize)]
struct InterCircuitEdgeJson {
from_circuit: usize,
to_circuit: usize,
weight: usize,
}
#[derive(Serialize)]
struct CircuitsOutputJson {
cstat_version: String,
circuits: Vec<CircuitJson>,
inter_circuit_edges: Vec<InterCircuitEdgeJson>,
modularity: f64,
}
/// Render circuits analysis as JSON.
pub fn render_circuits_json(rs_files: &[PathBuf], project_path: &Path, min_size: usize) {
let cg = flow::build_call_graph(rs_files, project_path);
let analysis = analyze_circuits(&cg, min_size);
let circuits: Vec<CircuitJson> = analysis
.circuits
.iter()
.map(|c| CircuitJson {
members: c.members.clone(),
boundary_functions: c.boundary_fns.clone(),
internal_edges: c.internal_edges,
cohesion: c.cohesion,
})
.collect();
let inter_circuit_edges: Vec<InterCircuitEdgeJson> = analysis
.inter_circuit_edges
.iter()
.map(|(from, to, w)| InterCircuitEdgeJson {
from_circuit: *from,
to_circuit: *to,
weight: *w,
})
.collect();
let output = CircuitsOutputJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
circuits,
inter_circuit_edges,
modularity: analysis.modularity,
};
println!("{}", serde_json::to_string(&output).unwrap());
}

108
src/commands.rs Normal file
View file

@ -0,0 +1,108 @@
use std::path::{Path, PathBuf};
#[path = "dist.rs"]
pub(crate) mod dist;
#[path = "dump.rs"]
pub(crate) mod dump;
#[path = "guide.rs"]
pub(crate) mod guide;
#[path = "map.rs"]
pub(crate) mod map;
#[path = "summary/mod.rs"]
pub(crate) mod summary;
use crate::{circuits, complexity, datapaths, deps, flow, graph, loc, redundancy};
pub enum Command {
Summary,
Loc { top: Option<usize> },
Branching,
Signature,
Span,
Deps { top: Option<usize> },
Map,
Flow,
Graph { call_only: bool, dep_only: bool, top: usize },
Datapaths { walks: usize, from: Option<String> },
Dist { metric: Option<String>, bins: usize },
Circuits { min_size: usize },
Redundancy,
Dump,
Guide { topic: Option<String> },
}
fn canon(project_path: &Path) -> PathBuf {
project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf())
}
/// Run one of two closures depending on the `json` flag.
fn run_json_or_human<F1, F2>(json: bool, json_fn: F1, human_fn: F2)
where
F1: FnOnce(),
F2: FnOnce(),
{
if json {
json_fn();
} else {
human_fn();
}
}
pub fn dispatch(command: Command, rs_files: &[PathBuf], project_path: &Path, json: bool, verbose: bool) {
let p = canon(project_path);
match command {
Command::Summary => run_json_or_human(json,
|| summary::render_summary_json(rs_files, &p),
|| summary::render_summary(rs_files, &p, verbose),
),
Command::Loc { top } => run_json_or_human(json,
|| loc::render_loc_json(rs_files, &p),
|| loc::render_loc(rs_files, &p, top, verbose),
),
Command::Branching => complexity::run_branching(rs_files, &p, json, verbose),
Command::Signature => complexity::run_signature(rs_files, &p, json, verbose),
Command::Span => complexity::run_span(rs_files, &p, json, verbose),
Command::Deps { top } => run_json_or_human(json,
|| deps::render_deps_json(rs_files, &p),
|| deps::render_deps(rs_files, &p, top, verbose),
),
Command::Map => run_json_or_human(json,
|| map::render_map_json(rs_files, &p),
|| map::render_map(rs_files, &p, verbose),
),
Command::Flow => run_json_or_human(json,
|| flow::render_flow_json(rs_files, &p),
|| flow::render_flow(rs_files, &p, verbose),
),
Command::Graph { call_only, dep_only, top } => run_json_or_human(json,
|| graph::render_graph_json(rs_files, &p, call_only, dep_only),
|| graph::render_graph(rs_files, &p, call_only, dep_only, top, verbose),
),
Command::Datapaths { walks, from } => run_json_or_human(json,
|| datapaths::render_datapaths_json(rs_files, &p, walks, from.as_deref()),
|| datapaths::render_datapaths(rs_files, &p, walks, from.as_deref(), verbose),
),
Command::Dist { metric, bins } => run_json_or_human(json,
|| dist::render_dist_json(rs_files, &p, metric.as_deref(), bins),
|| dist::render_dist(rs_files, &p, metric.as_deref(), bins, verbose),
),
Command::Circuits { min_size } => run_json_or_human(json,
|| circuits::render_circuits_json(rs_files, &p, min_size),
|| circuits::render_circuits(rs_files, &p, min_size, verbose),
),
Command::Redundancy => run_json_or_human(json,
|| redundancy::render_redundancy_json(rs_files, &p),
|| redundancy::render_redundancy(rs_files, &p, verbose),
),
Command::Dump => {
colored::control::set_override(false);
dump::render_dump(rs_files, &p);
}
Command::Guide { topic } => match topic {
Some(t) => guide::render_guide_topic(&t, json),
None => guide::render_guide_toc(json),
},
}
}

View file

@ -1,10 +1,34 @@
use colored::Colorize;
use serde::Serialize;
use std::path::{Path, PathBuf};
use syn::visit::Visit;
use syn::Expr;
use crate::ast_parser::{FunctionInfo, ProjectSymbols};
use crate::render;
/// Complexity metrics for a single function.
#[derive(Debug, Clone)]
pub struct FunctionComplexity {
pub name: String,
pub file: String,
pub cyclomatic: usize,
pub cognitive: usize,
pub nesting_depth: usize,
pub composite_score: f64,
pub line_count: usize,
pub param_count: usize,
}
/// Aggregate stats across all functions.
pub struct ComplexityStats {
pub total_functions: usize,
pub mean: f64,
pub std_dev: f64,
pub median: f64,
pub max: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct BranchingRow {
pub function: String,
@ -61,6 +85,320 @@ struct SpanJson {
functions: Vec<SpanRow>,
}
/// Compute complexity metrics for a single function.
fn compute_function_complexity(func: &FunctionInfo, source: &str) -> FunctionComplexity {
// Cyclomatic = branch_points + 1
let cyclomatic = func.branch_points + 1;
// Cognitive complexity: re-parse the function body for weighted nesting penalties
let cognitive = compute_cognitive_complexity(func, source);
// Generic complexity: type params + trait bounds + where predicates
let generic_complexity =
func.generic_param_count + func.trait_bound_count + func.where_predicate_count;
// Parameter complexity: params weighted by return type complexity
let param_complexity = func.param_count + func.return_type_complexity;
// Composite score: weighted combination
let composite_score = cyclomatic as f64 * 1.0
+ cognitive as f64 * 0.5
+ func.nesting_depth as f64 * 0.3
+ generic_complexity as f64 * 0.2
+ param_complexity as f64 * 0.1;
let line_count = function_line_count(func);
FunctionComplexity {
name: func.name.clone(),
file: func.file.display().to_string(),
cyclomatic,
cognitive,
nesting_depth: func.nesting_depth,
composite_score,
line_count,
param_count: func.param_count,
}
}
/// Compute cognitive complexity for a function.
/// Increments for each control flow break, with extra penalties for nesting.
/// Based on SonarSource's cognitive complexity model.
fn compute_cognitive_complexity(func: &FunctionInfo, source: &str) -> usize {
// Try to find and re-parse the function to walk its body with cognitive scoring
// We use the already-extracted data as a fallback
if let Some(cog) = try_cognitive_from_source(func, source) {
return cog;
}
// Fallback: estimate from extracted data
// branch_points + nesting_depth penalty
func.branch_points + func.nesting_depth
}
/// Try to compute cognitive complexity by re-parsing the source file and finding the function.
fn try_cognitive_from_source(func: &FunctionInfo, source: &str) -> Option<usize> {
let ast = syn::parse_file(source).ok()?;
let mut finder = CognitiveFinder {
target_name: func.name.clone(),
result: None,
};
finder.walk_items(&ast.items);
finder.result
}
struct CognitiveFinder {
target_name: String,
result: Option<usize>,
}
impl CognitiveFinder {
fn walk_items(&mut self, items: &[syn::Item]) {
for item in items {
match item {
syn::Item::Fn(f) => {
if f.sig.ident == self.target_name {
let mut visitor = CognitiveVisitor::new();
visitor.visit_block(&f.block);
self.result = Some(visitor.complexity);
}
}
syn::Item::Impl(imp) => {
for impl_item in &imp.items {
if let syn::ImplItem::Fn(method) = impl_item {
let target_type = crate::ast_parser::type_to_string_pub(&imp.self_ty);
let qualified = format!("{}::{}", target_type, method.sig.ident);
if qualified == self.target_name {
let mut visitor = CognitiveVisitor::new();
visitor.visit_block(&method.block);
self.result = Some(visitor.complexity);
}
}
}
}
_ => {}
}
}
}
}
/// Visitor that computes cognitive complexity using SonarSource-style rules:
/// - +1 for each control flow break (if, else if, else, match, for, while, loop, break, continue, &&, ||)
/// - +1 nesting increment for each level of nesting when encountering a break
struct CognitiveVisitor {
nesting: usize,
complexity: usize,
}
impl CognitiveVisitor {
fn new() -> Self {
Self {
nesting: 0,
complexity: 0,
}
}
fn increment(&mut self) {
// +1 for the construct, +nesting for nesting penalty
self.complexity += 1 + self.nesting;
}
fn increment_no_nesting(&mut self) {
self.complexity += 1;
}
fn count_boolean_chains(expr: &Expr) -> usize {
match expr {
Expr::Binary(bin) => {
use syn::BinOp;
let is_logical = matches!(bin.op, BinOp::And(_) | BinOp::Or(_));
let left = Self::count_boolean_chains(&bin.left);
let right = Self::count_boolean_chains(&bin.right);
left + right + if is_logical { 1 } else { 0 }
}
_ => 0,
}
}
/// Handle `if / else if / else` expressions for cognitive complexity.
fn handle_if_expr(&mut self, expr_if: &syn::ExprIf) {
self.increment();
// Count boolean operators in condition
self.complexity += Self::count_boolean_chains(&expr_if.cond);
self.nesting += 1;
// Visit then branch
for stmt in &expr_if.then_branch.stmts {
syn::visit::visit_stmt(self, stmt);
}
self.nesting -= 1;
// Visit else branch
if let Some((_, else_expr)) = &expr_if.else_branch {
self.handle_else_branch(else_expr);
}
}
/// Handle the else branch of an if expression.
/// Matches original behavior: first else-if is handled inline with no nesting
/// penalty, and deeper else branches delegate back to visit_expr.
fn handle_else_branch(&mut self, else_expr: &Expr) {
match else_expr {
Expr::If(inner_if) => {
// else if: +1 no nesting penalty (it's a continuation)
self.increment_no_nesting();
// But the inner if body still gets nesting
self.complexity += Self::count_boolean_chains(&inner_if.cond);
self.nesting += 1;
for stmt in &inner_if.then_branch.stmts {
syn::visit::visit_stmt(self, stmt);
}
self.nesting -= 1;
if let Some((_, inner_else)) = &inner_if.else_branch {
self.handle_inner_else(inner_else);
}
}
Expr::Block(block) => {
self.increment_no_nesting();
self.nesting += 1;
for stmt in &block.block.stmts {
syn::visit::visit_stmt(self, stmt);
}
self.nesting -= 1;
}
_ => {
syn::visit::visit_expr(self, else_expr);
}
}
}
/// Handle deeper else branches (third level and beyond).
/// Delegates Expr::If back to visit_expr for standard increment-with-nesting,
/// matching the original behavior.
fn handle_inner_else(&mut self, else_expr: &Expr) {
match else_expr {
Expr::If(_) => {
self.visit_expr(else_expr);
}
Expr::Block(block) => {
self.increment_no_nesting();
self.nesting += 1;
for stmt in &block.block.stmts {
syn::visit::visit_stmt(self, stmt);
}
self.nesting -= 1;
}
_ => {}
}
}
/// Handle `match` expressions for cognitive complexity.
fn handle_match_expr(&mut self, m: &syn::ExprMatch) {
self.increment();
self.nesting += 1;
for arm in &m.arms {
if arm.guard.is_some() {
self.increment_no_nesting();
}
syn::visit::visit_expr(self, &arm.body);
}
self.nesting -= 1;
}
/// Handle `for`, `while`, and `loop` expressions for cognitive complexity.
fn handle_loop_expr(&mut self, expr: &Expr) {
self.increment();
self.nesting += 1;
syn::visit::visit_expr(self, expr);
self.nesting -= 1;
}
}
impl<'ast> Visit<'ast> for CognitiveVisitor {
fn visit_expr(&mut self, expr: &'ast Expr) {
match expr {
Expr::If(expr_if) => {
self.handle_if_expr(expr_if);
return;
}
Expr::Match(m) => {
self.handle_match_expr(m);
return;
}
Expr::ForLoop(_) | Expr::While(_) | Expr::Loop(_) => {
self.handle_loop_expr(expr);
return;
}
Expr::Break(_) | Expr::Continue(_) => {
self.increment_no_nesting();
}
Expr::Closure(_) => {
self.nesting += 1;
syn::visit::visit_expr(self, expr);
self.nesting -= 1;
return;
}
_ => {}
}
syn::visit::visit_expr(self, expr);
}
}
/// Compute complexity for all functions in the project.
pub fn compute_all(symbols: &ProjectSymbols, project_path: &Path) -> Vec<FunctionComplexity> {
let mut results = Vec::new();
for file_syms in &symbols.files {
let source = std::fs::read_to_string(&file_syms.path).unwrap_or_default();
for func in &file_syms.functions {
let fc = compute_function_complexity(func, &source);
results.push(FunctionComplexity {
file: strip_project_path(&fc.file, project_path),
..fc
});
}
}
// Sort by composite score descending
results.sort_by(|a, b| {
b.composite_score
.partial_cmp(&a.composite_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
/// Compute per-file complexity aggregation.
/// Compute aggregate stats across all function complexities.
pub fn compute_stats(complexities: &[FunctionComplexity]) -> Option<ComplexityStats> {
if complexities.is_empty() {
return None;
}
let total = complexities.len();
let scores: Vec<f64> = complexities.iter().map(|c| c.composite_score).collect();
let sum: f64 = scores.iter().sum();
let mean = sum / total as f64;
let variance = scores.iter().map(|s| (s - mean) * (s - mean)).sum::<f64>() / total as f64;
let std_dev = variance.sqrt();
let mut sorted = scores.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if total % 2 == 0 {
(sorted[total / 2 - 1] + sorted[total / 2]) / 2.0
} else {
sorted[total / 2]
};
let max = sorted.last().copied().unwrap_or(0.0);
Some(ComplexityStats {
total_functions: total,
mean,
std_dev,
median,
max,
})
}
fn strip_project_path(path: &str, project_path: &Path) -> String {
let prefix = project_path.display().to_string();
let stripped = path.strip_prefix(&prefix).unwrap_or(path);

View file

@ -58,24 +58,14 @@ pub struct ToolStatus {
pub llvm_profdata: Option<String>,
pub profile_dir: Option<String>,
pub export_source: String,
pub branch_instrumentation_enabled: Option<bool>,
}
const COVERAGE_VERBOSE_INTRO: &[&str] = &[
"Normal mode runs cargo test with Rust source-based coverage flags before reading llvm-cov export data.",
"Use coverage --no-run with CSTAT_LLVM_COV_EXPORT_JSON to analyze an existing llvm-cov export.",
"Line coverage is dynamic llvm-cov segment data; branch coverage uses branch records when the Rust/LLVM toolchain emits them.",
"Production spans exclude tests, benches, test-support helpers, and selected src/bin wrapper main().",
"Selected-file static/projected sections are static reachability context, not runtime coverage.",
];
pub fn render_coverage(
rs_files: &[PathBuf],
project_path: &Path,
selected_file: Option<&Path>,
no_run: bool,
json: bool,
verbose: bool,
) -> Result<(), String> {
let symbols = ast_parser::parse_project(rs_files);
let graph = flow::build_call_graph(rs_files, project_path);
@ -87,37 +77,24 @@ pub fn render_coverage(
selected_file,
);
let export_result = if no_run {
let (export_json, mut status) = if no_run {
let export_path = std::env::var("CSTAT_LLVM_COV_EXPORT_JSON").map_err(|_| {
"CSTAT_LLVM_COV_EXPORT_JSON is required when coverage --no-run is used".to_string()
});
export_path.and_then(|export_path| {
let content = std::fs::read_to_string(&export_path).map_err(|e| {
format!("failed to read CSTAT_LLVM_COV_EXPORT_JSON={export_path}: {e}")
})?;
Ok((
content,
ToolStatus {
ran_tests: false,
llvm_cov: None,
llvm_profdata: None,
profile_dir: None,
branch_instrumentation_enabled: None,
export_source: export_path,
},
))
})
})?;
let content = std::fs::read_to_string(&export_path)
.map_err(|e| format!("failed to read CSTAT_LLVM_COV_EXPORT_JSON={export_path}: {e}"))?;
(
content,
ToolStatus {
ran_tests: false,
llvm_cov: None,
llvm_profdata: None,
profile_dir: None,
export_source: export_path,
},
)
} else {
collect_coverage_export(project_path, selected_file)
};
let (export_json, mut status) = match export_result {
Ok(result) => result,
Err(message) => {
if verbose && !json {
render_coverage_verbose_intro();
}
return Err(message);
}
collect_coverage_export(project_path, selected_file)?
};
let mut report = analyze_export_json(&export_json, project_path, selected_file, &filter)?;
@ -154,7 +131,7 @@ pub fn render_coverage(
serde_json::to_string(&report).map_err(|e| e.to_string())?
);
} else {
render_coverage_report(&report, selected_file.is_some(), &status, verbose);
render_coverage_report(&report, selected_file.is_some(), &status);
}
Ok(())
}
@ -322,21 +299,11 @@ pub fn analyze_export_json(
llvm_profdata: None,
profile_dir: None,
export_source: "provided_json".to_string(),
branch_instrumentation_enabled: None,
},
})
}
fn render_coverage_verbose_intro() {
render::verbose_block(COVERAGE_VERBOSE_INTRO);
}
fn render_coverage_report(
report: &CoverageReport,
selected_file: bool,
status: &ToolStatus,
verbose: bool,
) {
fn render_coverage_report(report: &CoverageReport, selected_file: bool, status: &ToolStatus) {
println!(
"\n{} {}",
"cstat coverage".bright_cyan().bold(),
@ -344,27 +311,19 @@ fn render_coverage_report(
);
println!(" {} {}", "source".cyan(), status.export_source);
if verbose {
render_coverage_verbose_intro();
}
if selected_file {
render::section_header("Static test/benchmark reachability");
if let Some(static_reachability) = &report.static_reachability {
datapaths::render_file_static_reachability_report(static_reachability, verbose);
datapaths::render_file_static_reachability_report(static_reachability, false);
}
render::section_header("Projected line reachability");
loc::render_file_projected_line_reachability_report(
&report.projected_line_reachability,
verbose,
false,
);
}
render::section_header("Dynamic line coverage");
if verbose {
render::verbose_block(&[
"Coverable/executed lines come from llvm-cov segment counts after cstat's production-span filter.",
"Missing lines are coverable production lines whose execution count was zero in the export.",
]);
}
println!(
" executed lines {} / {} ({:.1}%) missing {}",
report
@ -393,20 +352,11 @@ fn render_coverage_report(
}
render::section_header("Branch coverage");
if verbose {
render::verbose_block(&[
"Branch coverage counts true/false outcomes from llvm-cov branch records.",
"When cstat runs coverage itself, Rust branch records require nightly -Z coverage-options=branch.",
"When branch data is absent, cstat reports the section as unavailable instead of inventing branch counts.",
]);
}
if report.branch_coverage.total_branch_outcomes == 0 {
let message = match status.branch_instrumentation_enabled {
Some(false) => "branch data unavailable: this rustc does not expose nightly -Z coverage-options=branch, so cstat collected line/region coverage only",
Some(true) => "branch data unavailable: llvm-cov export contained no branch records for included production files",
None => "branch data unavailable: llvm-cov export contained no branch records for included production files",
};
println!(" {}", message.yellow());
println!(
" {}",
"branch data unavailable in llvm-cov export".yellow()
);
return;
}
println!(
@ -505,23 +455,6 @@ fn coverage_profile_dir(target_dir: &Path) -> PathBuf {
target_dir.join("cstat-coverage")
}
fn captured_command_output(stdout: &[u8], stderr: &[u8]) -> String {
let stdout = String::from_utf8_lossy(stdout);
let stderr = String::from_utf8_lossy(stderr);
let mut details = String::new();
if !stdout.trim().is_empty() {
details.push_str("\n\ncargo test stdout:\n");
details.push_str(stdout.trim_end());
}
if !stderr.trim().is_empty() {
details.push_str("\n\ncargo test stderr:\n");
details.push_str(stderr.trim_end());
}
details
}
fn collect_coverage_export(
project_path: &Path,
selected_file: Option<&Path>,
@ -543,16 +476,10 @@ fn collect_coverage_export(
}
let cargo_args = cargo_test_args(project_path, selected_file);
let branch_instrumentation_enabled = rustc_supports_branch_coverage();
let existing_rustflags = std::env::var("RUSTFLAGS").ok();
let rustflags = coverage_rustflags(
existing_rustflags.as_deref(),
branch_instrumentation_enabled,
);
let mut cargo = Command::new("cargo");
cargo.args(&cargo_args);
let output = cargo
.env("RUSTFLAGS", rustflags)
let status = cargo
.env("RUSTFLAGS", "-C instrument-coverage -C link-dead-code")
.env(
"LLVM_PROFILE_FILE",
profile_dir
@ -560,13 +487,11 @@ fn collect_coverage_export(
.to_string_lossy()
.to_string(),
)
.output()
.status()
.map_err(|e| format!("failed to run cargo test for coverage: {e}"))?;
if !output.status.success() {
if !status.success() {
return Err(format!(
"cargo test for coverage failed with status {}{}",
output.status,
captured_command_output(&output.stdout, &output.stderr),
"cargo test for coverage failed with status {status}"
));
}
@ -628,7 +553,6 @@ fn collect_coverage_export(
llvm_profdata: Some(llvm_profdata.to_string_lossy().to_string()),
profile_dir: Some(profile_dir.to_string_lossy().to_string()),
export_source: format_command("cargo", &cargo_args),
branch_instrumentation_enabled: Some(branch_instrumentation_enabled),
},
))
}
@ -771,30 +695,6 @@ fn format_command(program: &str, args: &[String]) -> String {
.join(" ")
}
fn coverage_rustflags(existing_rustflags: Option<&str>, enable_branch_coverage: bool) -> String {
let mut flags = existing_rustflags
.map(str::trim)
.filter(|flags| !flags.is_empty())
.map(ToString::to_string)
.unwrap_or_default();
if !flags.is_empty() {
flags.push(' ');
}
flags.push_str("-C instrument-coverage -C link-dead-code");
if enable_branch_coverage {
flags.push_str(" -Z coverage-options=branch");
}
flags
}
fn rustc_supports_branch_coverage() -> bool {
let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| std::ffi::OsString::from("rustc"));
let Ok(output) = Command::new(rustc).arg("-Z").arg("help").output() else {
return false;
};
output.status.success() && String::from_utf8_lossy(&output.stdout).contains("coverage-options")
}
fn find_tool(env_key: &str, binary: &str) -> Result<PathBuf, String> {
if let Some(value) = std::env::var_os(env_key) {
let path = PathBuf::from(value);
@ -820,7 +720,7 @@ fn find_tool(env_key: &str, binary: &str) -> Result<PathBuf, String> {
return Ok(candidate);
}
}
Err("LLVM coverage tools unavailable; install rustup component llvm-tools-preview or set LLVM_COV and LLVM_PROFDATA, or use coverage --no-run with CSTAT_LLVM_COV_EXPORT_JSON".to_string())
Err("LLVM coverage tools unavailable; install rustup component llvm-tools-preview or set LLVM_COV and LLVM_PROFDATA".to_string())
}
fn rustc_sysroot() -> Result<String, String> {
@ -1324,22 +1224,6 @@ path = "src/bin/mvp_chat.rs"
fs::remove_dir_all(root).unwrap();
}
#[test]
fn coverage_rustflags_preserve_existing_flags_and_enable_branch_option() {
assert_eq!(
coverage_rustflags(None, false),
"-C instrument-coverage -C link-dead-code"
);
assert_eq!(
coverage_rustflags(Some("-C debuginfo=2"), false),
"-C debuginfo=2 -C instrument-coverage -C link-dead-code"
);
assert_eq!(
coverage_rustflags(Some("-C debuginfo=2"), true),
"-C debuginfo=2 -C instrument-coverage -C link-dead-code -Z coverage-options=branch"
);
}
#[test]
fn parses_llvm_export_line_and_branch_sites() {
let root = temp_project("parser");

View file

@ -598,6 +598,589 @@ fn truncate(value: &str, max: usize) -> String {
}
}
/// Run many random walks through the call graph and accumulate visit counts.
fn simulate_walks(
graph: &CallGraph,
entry_points: &[String],
num_walks: usize,
) -> HashMap<String, usize> {
let mut visit_counts: HashMap<String, usize> = HashMap::new();
let mut rng_state: u64 = 0xdeadbeef_u64;
for (ep_idx, entry) in entry_points.iter().enumerate() {
let walks_per_entry = num_walks / entry_points.len().max(1);
for walk_i in 0..walks_per_entry {
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(
(ep_idx as u64)
.wrapping_mul(1442695040888963407)
.wrapping_add(walk_i as u64),
);
let mut current = entry.clone();
let mut steps = 0;
let max_steps = 200;
loop {
*visit_counts.entry(current.clone()).or_insert(0) += 1;
steps += 1;
if steps >= max_steps {
break;
}
let callees = match graph.adjacency.get(&current) {
Some(c) if !c.is_empty() => c,
_ => break,
};
// Simple LCG random choice
rng_state = rng_state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let idx = (rng_state >> 33) as usize % callees.len();
current = callees[idx].clone();
}
}
}
visit_counts
}
/// Run walks from a single entry point.
fn simulate_walks_from(graph: &CallGraph, entry: &str, num_walks: usize) -> HashMap<String, usize> {
let mut visit_counts: HashMap<String, usize> = HashMap::new();
let mut rng_state: u64 = 0xcafebabe_u64;
for walk_i in 0..num_walks {
rng_state = rng_state
.wrapping_mul(6364136223846793005)
.wrapping_add(walk_i as u64);
let mut current = entry.to_string();
let mut steps = 0;
let max_steps = 200;
loop {
*visit_counts.entry(current.clone()).or_insert(0) += 1;
steps += 1;
if steps >= max_steps {
break;
}
let callees = match graph.adjacency.get(&current) {
Some(c) if !c.is_empty() => c,
_ => break,
};
rng_state = rng_state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let idx = (rng_state >> 33) as usize % callees.len();
current = callees[idx].clone();
}
}
visit_counts
}
/// Find the hot path: greedily follow highest-visit-count callees from entry.
fn find_hot_path(
graph: &CallGraph,
entry: &str,
visit_counts: &HashMap<String, usize>,
) -> Vec<String> {
let mut path = vec![entry.to_string()];
let mut visited = HashSet::new();
visited.insert(entry.to_string());
let mut current = entry.to_string();
loop {
let callees = match graph.adjacency.get(&current) {
Some(c) if !c.is_empty() => c,
_ => break,
};
// Pick callee with highest visit count
let best = callees
.iter()
.filter(|c| !visited.contains(*c))
.max_by_key(|c| visit_counts.get(*c).copied().unwrap_or(0));
match best {
Some(next) => {
visited.insert(next.clone());
path.push(next.clone());
current = next.clone();
}
None => break,
}
}
path
}
/// Find all functions reachable from any entry point via BFS.
fn reachable_from_entries(graph: &CallGraph) -> HashSet<String> {
let mut reachable = HashSet::new();
let mut queue: Vec<String> = graph.entry_points.clone();
while let Some(node) = queue.pop() {
if !reachable.insert(node.clone()) {
continue;
}
if let Some(callees) = graph.adjacency.get(&node) {
for callee in callees {
if !reachable.contains(callee) {
queue.push(callee.clone());
}
}
}
}
reachable
}
/// Render the heatmap ranked list.
fn render_heatmap(visit_counts: &HashMap<String, usize>, total_functions: usize, verbose: bool) {
let tw = render::terminal_width();
println!(
"\n{}",
"── Activation Heatmap ────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Random walk activation heatmap: N random walks (default 10,000) start from",
"entry points and follow random callees at each step.",
"Visit count ≈ runtime activation likelihood — how often each function would",
"be hit if entry points were called uniformly at random.",
"Color: red/bold = hot (>70% of max), yellow = warm (>40%), blue = cool (>15%),",
" dim = cold (<15%). Percentage = share of all visits across all walks.",
"Coverage = fraction of total functions reached by at least one walk.",
]);
render::guide_ref("flow");
}
let total_visits: usize = visit_counts.values().sum();
if total_visits == 0 {
println!(" {}", "No visits recorded.".dimmed());
return;
}
let mut ranked: Vec<(&String, &usize)> = visit_counts.iter().collect();
ranked.sort_by(|a, b| b.1.cmp(a.1));
let max_count = *ranked[0].1;
let name_width = 40.min(tw.saturating_sub(30));
let bar_max = 20.min(tw.saturating_sub(name_width + 20));
println!();
for (name, count) in ranked.iter().take(30) {
let pct = **count as f64 / total_visits as f64 * 100.0;
let bar_len = (**count as f64 / max_count as f64 * bar_max as f64) as usize;
let heat = **count as f64 / max_count as f64;
let bar = "█".repeat(bar_len);
let colored_bar = if heat > 0.7 {
bar.red().bold().to_string()
} else if heat > 0.4 {
bar.yellow().to_string()
} else if heat > 0.15 {
bar.bright_blue().to_string()
} else {
bar.dimmed().to_string()
};
let display_name = if name.len() > name_width {
format!("…{}", &name[name.len() - name_width + 1..])
} else {
format!("{:<width$}", name, width = name_width)
};
println!(
" {} {} {:>5.1}% {}",
display_name.dimmed(),
colored_bar,
pct,
format!("({})", count).dimmed(),
);
}
if ranked.len() > 30 {
println!(
" {} more functions with lower activation.",
format!("… +{}", ranked.len() - 30).dimmed()
);
}
println!(
"\n {} {} visits across {} functions",
"Total:".bold(),
total_visits,
visit_counts.len(),
);
println!(
" {} {:.1}%",
"Coverage:".bold(),
visit_counts.len() as f64 / total_functions.max(1) as f64 * 100.0,
);
}
/// Render per-entry-point breakdown.
fn render_per_entry(graph: &CallGraph, num_walks: usize, verbose: bool) {
if graph.entry_points.is_empty() {
return;
}
println!(
"\n{}",
"── Per-Entry-Point Breakdown ─────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Same random walks as above, but partitioned by starting entry point.",
"Shows how many unique functions each entry point can reach and their",
"top-5 most visited callees. Useful for understanding which entry point",
"exercises which parts of the codebase.",
]);
}
for entry in &graph.entry_points {
let counts = simulate_walks_from(graph, entry, num_walks / graph.entry_points.len().max(1));
let total: usize = counts.values().sum();
let mut ranked: Vec<(&String, &usize)> = counts.iter().collect();
ranked.sort_by(|a, b| b.1.cmp(a.1));
println!(
"\n {} (reached {} functions, {} total visits)",
entry.bold().bright_white(),
counts.len(),
total,
);
let top5: Vec<_> = ranked.iter().take(5).collect();
for (name, count) in &top5 {
let pct = **count as f64 / total.max(1) as f64 * 100.0;
println!(" {:<40} {:>5.1}%", name.dimmed(), pct);
}
if ranked.len() > 5 {
println!(" {}", format!("… +{} more", ranked.len() - 5).dimmed());
}
}
}
/// Render hot path highlighting.
fn render_hot_paths(graph: &CallGraph, visit_counts: &HashMap<String, usize>, verbose: bool) {
println!(
"\n{}",
"── Hot Paths ─────────────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Hot paths: greedily follow the highest-visit-count callee from each entry point.",
"This traces the most likely execution path through the call graph.",
"Arrow chain: entry → most visited callee → its most visited callee → ...",
"Color: white = entry, red/bold = hot (>50% of max visits), yellow = warm (>20%),",
" plain = moderate, dim = cold. (depth N) = total chain length.",
]);
}
if graph.entry_points.is_empty() {
println!(" {}", "No entry points found.".dimmed());
return;
}
for entry in &graph.entry_points {
let path = find_hot_path(graph, entry, visit_counts);
if path.len() <= 1 {
println!(
"\n {} → {}",
entry.bold().bright_white(),
"(no callees)".dimmed()
);
continue;
}
println!();
print!(" ");
for (i, node) in path.iter().enumerate() {
let count = visit_counts.get(node).copied().unwrap_or(0);
let colored_name = if i == 0 {
node.bold().bright_white().to_string()
} else if count > 0 {
let max_count = visit_counts.values().max().copied().unwrap_or(1);
let heat = count as f64 / max_count as f64;
if heat > 0.5 {
node.red().bold().to_string()
} else if heat > 0.2 {
node.yellow().to_string()
} else {
node.to_string()
}
} else {
node.dimmed().to_string()
};
if i > 0 {
print!(" → ");
}
print!("{}", colored_name);
}
println!(" (depth {})", path.len());
}
}
/// Render cold function detection.
fn render_cold_functions(graph: &CallGraph, visit_counts: &HashMap<String, usize>, verbose: bool) {
let reachable = reachable_from_entries(graph);
let cold: Vec<&String> = graph
.functions
.iter()
.filter(|f| !visit_counts.contains_key(*f) && !reachable.contains(*f))
.collect();
println!(
"\n{}",
"── Cold Functions (Unreachable) ──────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Functions never reached from any entry point via call graph traversal.",
"These are dead code candidates — they exist in the source but no entry point",
"(main, #[test]) can reach them through any chain of calls.",
"Note: static analysis may miss dynamic dispatch or macro-generated calls.",
]);
}
if cold.is_empty() {
println!(
" {}",
"All functions are reachable from entry points.".green()
);
} else {
println!(
"\n {} functions never reached from any entry point (dead code candidates):\n",
format!("{}", cold.len()).red().bold(),
);
for (i, name) in cold.iter().enumerate() {
if i >= 20 {
println!(" {}", format!("… +{} more", cold.len() - 20).dimmed());
break;
}
println!(" {} {}", "•".red(), name.dimmed());
}
}
println!();
}
/// Compact static entrypoint-reachability data for embedding in the dashboard.
pub struct EntrypointReachabilitySummary {
/// Percentage of functions statically reachable from entry points.
pub reachable_pct: f64,
/// Total function count.
pub total_functions: usize,
/// Number of reachable functions.
pub reachable_count: usize,
/// Number of cold (unreachable) functions.
pub cold_count: usize,
}
/// Compute compact static entrypoint reachability for dashboard use.
pub fn entrypoint_reachability_summary(
rs_files: &[PathBuf],
project_path: &Path,
) -> Option<EntrypointReachabilitySummary> {
let graph = build_call_graph(rs_files, project_path);
if graph.functions.is_empty() {
return None;
}
let total_functions = graph.functions.len();
let reachable = reachable_from_entries(&graph);
let reachable_count = reachable.len();
let cold_count = total_functions.saturating_sub(reachable_count);
let reachable_pct = reachable_count as f64 / total_functions.max(1) as f64 * 100.0;
Some(EntrypointReachabilitySummary {
reachable_pct,
total_functions,
reachable_count,
cold_count,
})
}
/// Public entry point: render the datapaths subcommand.
pub fn render_datapaths(
rs_files: &[PathBuf],
project_path: &Path,
num_walks: usize,
from_fn: Option<&str>,
verbose: bool,
) {
let graph = build_call_graph(rs_files, project_path);
if graph.functions.is_empty() {
println!("{}", "No functions found to analyze.".yellow());
return;
}
let total_functions = graph.functions.len();
// Determine entry points
let entries: Vec<String> = if let Some(f) = from_fn {
// Check if the function exists (exact or suffix match)
let matched = graph
.functions
.iter()
.find(|name| *name == f || name.ends_with(&format!("::{}", f)));
match matched {
Some(m) => vec![m.clone()],
None => {
eprintln!(
"{} Function '{}' not found in call graph.",
"Error:".red().bold(),
f
);
eprintln!("Available functions:");
for func in graph.functions.iter().take(20) {
eprintln!(" {}", func);
}
if graph.functions.len() > 20 {
eprintln!(" … +{} more", graph.functions.len() - 20);
}
return;
}
}
} else {
if graph.entry_points.is_empty() {
println!(
"{} No entry points (main or #[test]) found. Use --from to specify a start function.",
"Warning:".yellow().bold()
);
return;
}
graph.entry_points.clone()
};
println!(
"\n{}",
"══ Execution Flow Heatmap ═══════════════════════════════"
.bright_cyan()
.bold()
);
println!(
" {} random walks from {} entry point(s)",
format!("{}", num_walks).bold(),
entries.len(),
);
// Run global simulation
let visit_counts = simulate_walks(&graph, &entries, num_walks);
// Render sections
render_heatmap(&visit_counts, total_functions, verbose);
render_per_entry(&graph, num_walks, verbose);
render_hot_paths(&graph, &visit_counts, verbose);
render_cold_functions(&graph, &visit_counts, verbose);
}
#[derive(Serialize)]
struct VisitCountJson {
function: String,
visits: usize,
percentage: f64,
}
#[derive(Serialize)]
struct DatapathsJson {
cstat_version: String,
visit_counts: Vec<VisitCountJson>,
coverage_percentage: f64,
hot_paths: Vec<Vec<String>>,
cold_functions: Vec<String>,
total_functions: usize,
total_visits: usize,
}
/// Render datapaths analysis as JSON.
pub fn render_datapaths_json(
rs_files: &[PathBuf],
project_path: &Path,
num_walks: usize,
from_fn: Option<&str>,
) {
let graph = build_call_graph(rs_files, project_path);
let total_functions = graph.functions.len();
let entries: Vec<String> = if let Some(f) = from_fn {
let matched = graph
.functions
.iter()
.find(|name| *name == f || name.ends_with(&format!("::{}", f)));
match matched {
Some(m) => vec![m.clone()],
None => vec![],
}
} else {
graph.entry_points.clone()
};
let visit_counts = simulate_walks(&graph, &entries, num_walks);
let total_visits: usize = visit_counts.values().sum();
let mut ranked: Vec<(&String, &usize)> = visit_counts.iter().collect();
ranked.sort_by(|a, b| b.1.cmp(a.1));
let visit_counts_json: Vec<VisitCountJson> = ranked
.iter()
.map(|(name, count)| VisitCountJson {
function: (*name).clone(),
visits: **count,
percentage: if total_visits > 0 {
**count as f64 / total_visits as f64 * 100.0
} else {
0.0
},
})
.collect();
let coverage = visit_counts.len() as f64 / total_functions.max(1) as f64 * 100.0;
let hot_paths: Vec<Vec<String>> = entries
.iter()
.map(|entry| find_hot_path(&graph, entry, &visit_counts))
.collect();
let reachable = reachable_from_entries(&graph);
let cold_functions: Vec<String> = graph
.functions
.iter()
.filter(|f| !visit_counts.contains_key(*f) && !reachable.contains(*f))
.cloned()
.collect();
let output = DatapathsJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
visit_counts: visit_counts_json,
coverage_percentage: coverage,
hot_paths,
cold_functions,
total_functions,
total_visits,
};
println!("{}", serde_json::to_string(&output).unwrap());
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -113,57 +113,19 @@ fn collect_use_paths(tree: &syn::UseTree, prefix: &mut Vec<String>, results: &mu
}
}
/// Match a use/mod path to a known project module name from a source-module context.
fn resolve_to_module(
use_path: &str,
known_modules: &HashSet<String>,
current_module: &str,
) -> Option<String> {
if use_path == "crate" {
return None;
}
if use_path == "self" {
return known_modules
.contains(current_module)
.then(|| current_module.to_string());
}
if use_path == "super" {
return parent_module(current_module)
.filter(|parent| known_modules.contains(*parent))
.map(ToString::to_string);
}
/// Match a use path to a known project module name.
fn resolve_to_module(use_path: &str, known_modules: &HashSet<String>) -> Option<String> {
let path = if let Some(rest) = use_path.strip_prefix("crate::") {
rest
} else if let Some(rest) = use_path.strip_prefix("self::") {
rest
} else if let Some(rest) = use_path.strip_prefix("super::") {
rest
} else {
use_path
};
if let Some(rest) = use_path.strip_prefix("crate::") {
return resolve_absolute_module_path(rest, known_modules);
}
if let Some(rest) = use_path.strip_prefix("self::") {
return resolve_relative_module_path(current_module, rest, known_modules).or_else(|| {
known_modules
.contains(current_module)
.then(|| current_module.to_string())
});
}
let mut base = current_module;
let mut rest = use_path;
let mut saw_super = false;
while let Some(stripped) = rest.strip_prefix("super::") {
saw_super = true;
base = parent_module(base).unwrap_or("");
rest = stripped;
}
if saw_super {
return resolve_relative_module_path(base, rest, known_modules)
.or_else(|| known_modules.contains(base).then(|| base.to_string()));
}
resolve_relative_module_path(current_module, use_path, known_modules)
.or_else(|| resolve_absolute_module_path(use_path, known_modules))
}
fn resolve_absolute_module_path(path: &str, known_modules: &HashSet<String>) -> Option<String> {
let parts: Vec<&str> = path.split("::").filter(|part| !part.is_empty()).collect();
let parts: Vec<&str> = path.split("::").collect();
for len in (1..=parts.len()).rev() {
let candidate = parts[..len].join("::");
if known_modules.contains(&candidate) {
@ -173,26 +135,6 @@ fn resolve_absolute_module_path(path: &str, known_modules: &HashSet<String>) ->
None
}
fn resolve_relative_module_path(
base: &str,
path: &str,
known_modules: &HashSet<String>,
) -> Option<String> {
if base.is_empty() {
return resolve_absolute_module_path(path, known_modules);
}
let absolute = if path.is_empty() {
base.to_string()
} else {
format!("{base}::{path}")
};
resolve_absolute_module_path(&absolute, known_modules)
}
fn parent_module(module: &str) -> Option<&str> {
module.rsplit_once("::").map(|(parent, _)| parent)
}
/// Compute coupling pairs from the edge set, sorted by strength descending.
fn build_coupling_pairs(edges: &HashSet<DepEdge>) -> Vec<(String, String, usize)> {
let mut coupling_pairs: Vec<(String, String, usize)> = Vec::new();
@ -267,7 +209,7 @@ pub fn analyze_deps(rs_files: &[PathBuf], project_path: &Path) -> DepAnalysis {
let mut seen_targets: HashSet<String> = HashSet::new();
for target in &use_targets {
if let Some(to_module) = resolve_to_module(target, &known_modules, &from_module) {
if let Some(to_module) = resolve_to_module(target, &known_modules) {
if to_module != from_module && seen_targets.insert(to_module.clone()) {
let edge = DepEdge {
from: from_module.clone(),
@ -318,7 +260,7 @@ pub fn analyze_file_deps(
let mut internal = Vec::new();
let mut external = Vec::new();
for raw in use_targets {
if let Some(resolved_module) = resolve_to_module(&raw, &known_modules, &module) {
if let Some(resolved_module) = resolve_to_module(&raw, &known_modules) {
internal.push(FileDependency {
raw,
resolved_module: Some(resolved_module),
@ -408,15 +350,7 @@ fn relative_path_string(project_path: &Path, path: &Path) -> String {
.replace('\\', "/")
}
fn function_graph_name(file_module: &str, function_name: &str) -> String {
if file_module == "main" || file_module == "lib" {
function_name.to_string()
} else {
format!("{file_module}::{function_name}")
}
}
/// Compute call cohesion: fraction of source functions calling another function in the same module.
/// Compute call cohesion: fraction of functions calling another function in the same module.
fn compute_call_cohesion(
module_functions: &HashMap<String, Vec<String>>,
call_graph: &flow::CallGraph,
@ -447,7 +381,7 @@ fn compute_call_cohesion(
call_cohesion_map
}
/// Compute type cohesion: fraction of functions sharing signature type tokens within a module.
/// Compute type cohesion: fraction of functions sharing parameter/return types within a module.
fn compute_type_cohesion(
module_func_infos: &HashMap<String, Vec<&ast_parser::FunctionInfo>>,
) -> HashMap<String, f64> {
@ -460,7 +394,7 @@ fn compute_type_cohesion(
let func_types: Vec<HashSet<String>> = funcs
.iter()
.map(|f| extract_signature_types(&f.file, f.line_start, f.line_end))
.map(|f| extract_param_types(&f.file, f.line_start, f.line_end))
.collect();
let mut sharing_count = 0usize;
@ -481,20 +415,30 @@ fn compute_type_cohesion(
type_cohesion_map
}
/// Compute per-source-module cohesion metrics.
/// Compute per-module cohesion metrics.
pub fn compute_cohesion(rs_files: &[PathBuf], project_path: &Path) -> Vec<ModuleCohesion> {
let call_graph = flow::build_call_graph(rs_files, project_path);
let symbols = ast_parser::parse_project(rs_files);
let mut module_functions: HashMap<String, Vec<String>> = HashMap::new();
for func_name in &call_graph.functions {
let module = if let Some(pos) = func_name.rfind("::") {
func_name[..pos].to_string()
} else {
"main".to_string()
};
module_functions
.entry(module)
.or_default()
.push(func_name.clone());
}
let call_cohesion_map = compute_call_cohesion(&module_functions, &call_graph);
let mut module_func_infos: HashMap<String, Vec<&ast_parser::FunctionInfo>> = HashMap::new();
for file_syms in &symbols.files {
let module_name = crate_render::module_name_from_path(&file_syms.path, project_path);
for func in &file_syms.functions {
module_functions
.entry(module_name.clone())
.or_default()
.push(function_graph_name(&module_name, &func.name));
module_func_infos
.entry(module_name.clone())
.or_default()
@ -502,21 +446,31 @@ pub fn compute_cohesion(rs_files: &[PathBuf], project_path: &Path) -> Vec<Module
}
}
let call_cohesion_map = compute_call_cohesion(&module_functions, &call_graph);
let type_cohesion_map = compute_type_cohesion(&module_func_infos);
let mut results: Vec<ModuleCohesion> = module_func_infos
let all_modules: HashSet<&str> = module_functions
.keys()
.map(|s| s.as_str())
.chain(module_func_infos.keys().map(|s| s.as_str()))
.collect();
let mut results: Vec<ModuleCohesion> = all_modules
.iter()
.map(|(module, funcs)| {
.map(|&module| {
let call_c = call_cohesion_map.get(module).copied().unwrap_or(0.0);
let type_c = type_cohesion_map.get(module).copied().unwrap_or(0.0);
let func_count = module_functions
.get(module)
.map(|v| v.len())
.or_else(|| module_func_infos.get(module).map(|v| v.len()))
.unwrap_or(0);
let combined = (0.6 * call_c + 0.4 * type_c).clamp(0.0, 1.0);
ModuleCohesion {
module: module.clone(),
module: module.to_string(),
call_cohesion: call_c,
type_cohesion: type_c,
combined,
function_count: funcs.len(),
function_count: func_count,
}
})
.collect();
@ -525,13 +479,12 @@ pub fn compute_cohesion(rs_files: &[PathBuf], project_path: &Path) -> Vec<Module
a.combined
.partial_cmp(&b.combined)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.module.cmp(&b.module))
});
results
}
/// Extract parameter/return type-like tokens from a function signature in a source file.
fn extract_signature_types(file: &Path, line_start: usize, line_end: usize) -> HashSet<String> {
/// Extract parameter/return type names from a function in a source file.
fn extract_param_types(file: &Path, line_start: usize, line_end: usize) -> HashSet<String> {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(_) => return HashSet::new(),
@ -539,14 +492,10 @@ fn extract_signature_types(file: &Path, line_start: usize, line_end: usize) -> H
let lines: Vec<&str> = content.lines().collect();
let start = line_start.saturating_sub(1);
let end = line_end.min(lines.len());
let span_text: String = lines[start..end].join("\n");
let signature_text = span_text
.split_once('{')
.map(|(signature, _)| signature)
.unwrap_or(&span_text);
let sig_text: String = lines[start..end].join("\n");
let mut types = HashSet::new();
for token in signature_text.split(|c: char| !c.is_alphanumeric() && c != '_') {
for token in sig_text.split(|c: char| !c.is_alphanumeric() && c != '_') {
let t = token.trim();
if t.is_empty() || t.len() < 2 {
continue;

View file

@ -26,7 +26,6 @@ struct CouplingPairJson {
module_b: String,
strength: usize,
direction: String,
edges: Vec<DepEdgeJson>,
}
#[derive(Serialize)]
@ -117,7 +116,7 @@ fn render_matrix_rows(
fn render_matrix_legend(display_modules: &[&String]) {
println!();
println!(
" {} no dependency {} one-way pair {} bidirectional pair",
" {} no dependency {} unidirectional {} bidirectional",
"0".dimmed(),
"1".yellow(),
"2".red().bold(),
@ -139,14 +138,13 @@ fn render_dependency_matrix(analysis: &DepAnalysis, top_n: usize, term_w: usize,
if verbose {
render::verbose_block(&[
"Matrix showing coupling strength between module pairs.",
"Cells are pair strength, not edge direction: 0 = no dependency,",
" 1 = exactly one direction exists, 2 = both modules import each other.",
"Matrix showing coupling intensity between module pairs.",
"Cell [row, col] = 0 (dim, no dependency), 1 (yellow, unidirectional),",
" 2 (red/bold, bidirectional — both modules import each other).",
"Diagonal is marked '·' (a module cannot depend on itself).",
"Modules are sorted by total connectivity (in + out degree).",
"Row/column indices map to module names shown in the legend below the matrix.",
"Use Coupling Ranking or JSON edges for source -> target direction.",
"Built from top-level source 'use' and external 'mod' statements parsed from each file.",
"Built from 'use' and 'mod' statements found by parsing each file's AST.",
]);
render::guide_ref("modularity");
}
@ -206,9 +204,9 @@ fn render_coupling_ranking(analysis: &DepAnalysis, top_n: usize, verbose: bool)
if verbose {
render::verbose_block(&[
"Module pairs ranked by coupling strength.",
"One-way rows show actual source module -> target module direction.",
"◆ bidirectional (red) = both modules import each other. This is a coupling risk —",
" changes in either module may ripple to the other.",
"→ unidirectional (yellow) = one-way dependency, lower risk.",
"Pairs are sorted by coupling strength descending, then alphabetically.",
]);
}
@ -219,52 +217,37 @@ fn render_coupling_ranking(analysis: &DepAnalysis, top_n: usize, verbose: bool)
}
let display_count = analysis.coupling_pairs.len().min(top_n);
let edge_set: HashSet<(&str, &str)> = analysis
.edges
.iter()
.map(|edge| (edge.from.as_str(), edge.to.as_str()))
.collect();
println!(
"\n {:<30} {:<30} {}",
"From".bold().underline(),
"To".bold().underline(),
"Module A".bold().underline(),
"Module B".bold().underline(),
"Coupling".bold().underline(),
);
for (a, b, strength) in analysis.coupling_pairs.iter().take(display_count) {
let a_to_b = edge_set.contains(&(a.as_str(), b.as_str()));
let b_to_a = edge_set.contains(&(b.as_str(), a.as_str()));
let (from, to, coupling_label) = if a_to_b && b_to_a {
(
a.as_str(),
b.as_str(),
"◆ bidirectional".red().bold().to_string(),
)
} else if a_to_b {
(a.as_str(), b.as_str(), "→ one-way".yellow().to_string())
let coupling_label = if *strength >= 2 {
"◆ bidirectional".red().bold().to_string()
} else {
(b.as_str(), a.as_str(), "→ one-way".yellow().to_string())
"→ unidirectional".yellow().to_string()
};
let from_display = if from.len() > 28 {
format!("..{}", &from[from.len() - 26..])
let a_display = if a.len() > 28 {
format!("..{}", &a[a.len() - 26..])
} else {
from.to_string()
a.clone()
};
let to_display = if to.len() > 28 {
format!("..{}", &to[to.len() - 26..])
let b_display = if b.len() > 28 {
format!("..{}", &b[b.len() - 26..])
} else {
to.to_string()
b.clone()
};
println!(
" {:<30} {:<30} {}",
from_display.dimmed(),
to_display.dimmed(),
a_display.dimmed(),
b_display.dimmed(),
coupling_label
);
let _ = strength;
}
if analysis.coupling_pairs.len() > display_count {
@ -420,11 +403,11 @@ fn render_cohesion_section(cohesion: &[ModuleCohesion], top_n: usize, verbose: b
if verbose {
render::verbose_block(&[
"Per-source-module cohesion scores measuring internal focus/coherence.",
"Call cohesion: fraction of parsed source functions calling another function in the same source module.",
"Type cohesion: fraction of parsed source functions sharing signature type tokens within the module.",
"Per-module cohesion scores measuring internal focus/coherence.",
"Call cohesion: fraction of functions calling another function in the same module.",
"Type cohesion: fraction of functions sharing parameter/return types within the module.",
"Combined: weighted average (60% call + 40% type), 0.0–1.0 where higher = more cohesive.",
"Low cohesion suggests the source module bundles unrelated functionality.",
"Low cohesion suggests the module bundles unrelated functionality.",
"Modules sorted by combined cohesion ascending (least cohesive first).",
]);
}
@ -522,39 +505,18 @@ pub fn render_deps_json(rs_files: &[PathBuf], project_path: &Path) {
})
.collect();
let edge_set: HashSet<(&str, &str)> = analysis
.edges
.iter()
.map(|edge| (edge.from.as_str(), edge.to.as_str()))
.collect();
let coupling_pairs: Vec<CouplingPairJson> = analysis
.coupling_pairs
.iter()
.map(|(a, b, s)| {
let mut pair_edges = Vec::new();
if edge_set.contains(&(a.as_str(), b.as_str())) {
pair_edges.push(DepEdgeJson {
from: a.clone(),
to: b.clone(),
});
}
if edge_set.contains(&(b.as_str(), a.as_str())) {
pair_edges.push(DepEdgeJson {
from: b.clone(),
to: a.clone(),
});
}
CouplingPairJson {
module_a: a.clone(),
module_b: b.clone(),
strength: *s,
direction: if *s >= 2 {
"bidirectional".to_string()
} else {
"unidirectional".to_string()
},
edges: pair_edges,
}
.map(|(a, b, s)| CouplingPairJson {
module_a: a.clone(),
module_b: b.clone(),
strength: *s,
direction: if *s >= 2 {
"bidirectional".to_string()
} else {
"unidirectional".to_string()
},
})
.collect();

699
src/diagnostics/mod.rs Normal file
View file

@ -0,0 +1,699 @@
mod scoring;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::ast_parser;
use crate::complexity;
use crate::deps;
use crate::flow;
use crate::loc;
use crate::redundancy;
// ── Diagnostic types ─────────────────────────────────────────────
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
pub severity: Severity,
pub category: Category,
pub location: Location,
pub metric: f64,
pub threshold: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<serde_json::Value>,
pub message: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Warn,
Alert,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Category {
BloatedFile,
BloatedFunction,
HighComplexity,
HighCognitive,
DeepNesting,
HighCoupling,
GodModule,
DeadCode,
LowCohesion,
RedundantCode,
TooManyParams,
CyclicDeps,
}
#[derive(Debug, Clone, Serialize)]
pub struct Location {
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub module: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_start: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_end: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthScores {
pub modularity: f64,
pub complexity: f64,
pub maintainability: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiagnosticReport {
pub diagnostics: Vec<Diagnostic>,
pub scores: HealthScores,
}
// ── Thresholds ───────────────────────────────────────────────────
pub(crate) struct Threshold {
pub(crate) warn: f64,
pub(crate) alert: f64,
}
pub(crate) const FILE_LOC: Threshold = Threshold {
warn: 500.0,
alert: 800.0,
};
const FUNC_LOC: Threshold = Threshold {
warn: 50.0,
alert: 100.0,
};
pub(crate) const CYCLOMATIC: Threshold = Threshold {
warn: 10.0,
alert: 20.0,
};
pub(crate) const COGNITIVE: Threshold = Threshold {
warn: 15.0,
alert: 30.0,
};
const NESTING: Threshold = Threshold {
warn: 4.0,
alert: 6.0,
};
const PARAMS: Threshold = Threshold {
warn: 5.0,
alert: 8.0,
};
pub(crate) const REDUNDANCY: Threshold = Threshold {
warn: 0.7,
alert: 0.9,
};
pub(crate) const COHESION: Threshold = Threshold {
warn: 0.3,
alert: 0.1,
};
// ── Main analysis entry point (orchestrator) ─────────────────────
pub fn run_diagnostics(rs_files: &[PathBuf], project_path: &Path) -> DiagnosticReport {
let mut diagnostics = Vec::new();
// --- LoC analysis ---
let (file_stats, _agg_loc) = loc::analyze_files(rs_files);
// --- Complexity analysis ---
let symbols = ast_parser::parse_project(rs_files);
let func_complexities = complexity::compute_all(&symbols, project_path);
// Build per-file function index for cross-referencing
let mut funcs_by_file: HashMap<String, Vec<&complexity::FunctionComplexity>> = HashMap::new();
for fc in &func_complexities {
funcs_by_file.entry(fc.file.clone()).or_default().push(fc);
}
diagnose_bloated_files(&file_stats, &funcs_by_file, project_path, &mut diagnostics);
diagnose_function_metrics(&func_complexities, &mut diagnostics);
// --- Dependency analysis ---
let dep_analysis = deps::analyze_deps(rs_files, project_path);
diagnose_coupling(&dep_analysis, &mut diagnostics);
diagnose_god_modules(&dep_analysis, &mut diagnostics);
// --- Dead code (cold functions) ---
let call_graph = flow::build_call_graph(rs_files, project_path);
let reachable = compute_reachable(&call_graph);
diagnose_dead_code(&call_graph, &reachable, &mut diagnostics);
diagnose_cyclic_deps(&dep_analysis, &mut diagnostics);
// --- Redundancy ---
let redundant_pairs = redundancy::detect_redundancy(rs_files, project_path);
diagnose_redundancy(&redundant_pairs, &mut diagnostics);
// --- Low cohesion ---
let cohesion_data = deps::compute_cohesion(rs_files, project_path);
diagnose_low_cohesion(&cohesion_data, &mut diagnostics);
// --- Sort diagnostics: alerts first, then by metric magnitude descending ---
diagnostics.sort_by(|a, b| {
a.severity.cmp(&b.severity).then_with(|| {
b.metric
.partial_cmp(&a.metric)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
// --- Compute health scores ---
let scores = scoring::compute_health_scores(
&file_stats,
&func_complexities,
&dep_analysis,
&redundant_pairs,
&call_graph,
&reachable,
rs_files,
project_path,
);
DiagnosticReport {
diagnostics,
scores,
}
}
// ── Extracted diagnostic helpers ────────────────────────────────
fn diagnose_bloated_files(
file_stats: &[loc::FileLocStats],
funcs_by_file: &HashMap<String, Vec<&complexity::FunctionComplexity>>,
project_path: &Path,
diagnostics: &mut Vec<Diagnostic>,
) {
for stat in file_stats {
let loc = stat.code_lines as f64;
let rel_path = stat
.path
.strip_prefix(project_path)
.unwrap_or(&stat.path)
.display()
.to_string();
let (sev, thresh) = if loc > FILE_LOC.alert {
(Some(Severity::Alert), FILE_LOC.alert)
} else if loc > FILE_LOC.warn {
(Some(Severity::Warn), FILE_LOC.warn)
} else {
(None, 0.0)
};
if let Some(severity) = sev {
let file_funcs = funcs_by_file.get(rel_path.as_str());
let func_count = file_funcs.map(|v| v.len()).unwrap_or(0);
let top_complex: Vec<serde_json::Value> = file_funcs
.map(|fns| {
let mut sorted: Vec<_> = fns.iter().collect();
sorted.sort_by(|a, b| {
b.composite_score
.partial_cmp(&a.composite_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
sorted
.into_iter()
.take(3)
.map(|f| {
serde_json::json!({
"name": f.name,
"composite_score": f.composite_score,
"lines": f.line_count
})
})
.collect()
})
.unwrap_or_default();
let ctx = serde_json::json!({
"function_count": func_count,
"top_complex_functions": top_complex,
});
let msg = format!(
"{} is {} lines with {} functions",
rel_path, stat.code_lines, func_count
);
diagnostics.push(Diagnostic {
severity,
category: Category::BloatedFile,
location: Location {
file: Some(rel_path.clone()),
function: None,
module: None,
line_start: None,
line_end: None,
},
metric: loc,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
}
}
fn diagnose_function_metrics(
func_complexities: &[complexity::FunctionComplexity],
diagnostics: &mut Vec<Diagnostic>,
) {
for fc in func_complexities {
diagnose_fn_complexity(fc, diagnostics);
diagnose_fn_structure(fc, diagnostics);
}
}
fn diagnose_fn_complexity(fc: &complexity::FunctionComplexity, diagnostics: &mut Vec<Diagnostic>) {
let line_count = fc.line_count as f64;
let cyc = fc.cyclomatic as f64;
let cog = fc.cognitive as f64;
// Bloated function
if let Some((sev, thresh)) = check_threshold(line_count, &FUNC_LOC) {
let ctx = serde_json::json!({
"cyclomatic": fc.cyclomatic,
"cognitive": fc.cognitive,
"nesting_depth": fc.nesting_depth,
"composite_score": fc.composite_score,
});
let msg = format!(
"{} is {} lines (cyclomatic {}, cognitive {})",
fc.name, fc.line_count, fc.cyclomatic, fc.cognitive
);
diagnostics.push(Diagnostic {
severity: sev,
category: Category::BloatedFunction,
location: fn_location(&fc.name, &fc.file),
metric: line_count,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
// Cyclomatic complexity
if let Some((sev, thresh)) = check_threshold(cyc, &CYCLOMATIC) {
let ctx = serde_json::json!({
"cognitive": fc.cognitive,
"nesting_depth": fc.nesting_depth,
"line_count": fc.line_count,
});
let msg = format!(
"{} has cyclomatic complexity {} ({} lines, nesting depth {})",
fc.name, fc.cyclomatic, fc.line_count, fc.nesting_depth
);
diagnostics.push(Diagnostic {
severity: sev,
category: Category::HighComplexity,
location: fn_location(&fc.name, &fc.file),
metric: cyc,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
// Cognitive complexity
if let Some((sev, thresh)) = check_threshold(cog, &COGNITIVE) {
let ctx = serde_json::json!({
"cyclomatic": fc.cyclomatic,
"nesting_depth": fc.nesting_depth,
"line_count": fc.line_count,
});
let msg = format!(
"{} has cognitive complexity {} (cyclomatic {}, nesting depth {})",
fc.name, fc.cognitive, fc.cyclomatic, fc.nesting_depth
);
diagnostics.push(Diagnostic {
severity: sev,
category: Category::HighCognitive,
location: fn_location(&fc.name, &fc.file),
metric: cog,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
}
fn diagnose_fn_structure(fc: &complexity::FunctionComplexity, diagnostics: &mut Vec<Diagnostic>) {
let nest = fc.nesting_depth as f64;
let params = fc.param_count as f64;
// Deep nesting
if let Some((sev, thresh)) = check_threshold(nest, &NESTING) {
let ctx = serde_json::json!({
"cyclomatic": fc.cyclomatic,
"cognitive": fc.cognitive,
"line_count": fc.line_count,
});
let msg = format!(
"{} has nesting depth {} (cyclomatic {}, {} lines)",
fc.name, fc.nesting_depth, fc.cyclomatic, fc.line_count
);
diagnostics.push(Diagnostic {
severity: sev,
category: Category::DeepNesting,
location: fn_location(&fc.name, &fc.file),
metric: nest,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
// Too many params
if let Some((sev, thresh)) = check_threshold(params, &PARAMS) {
let ctx = serde_json::json!({
"line_count": fc.line_count,
"cyclomatic": fc.cyclomatic,
});
let msg = format!(
"{} takes {} parameters ({} lines, cyclomatic {})",
fc.name, fc.param_count, fc.line_count, fc.cyclomatic
);
diagnostics.push(Diagnostic {
severity: sev,
category: Category::TooManyParams,
location: fn_location(&fc.name, &fc.file),
metric: params,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
}
fn diagnose_coupling(dep_analysis: &deps::DepAnalysis, diagnostics: &mut Vec<Diagnostic>) {
for (a, b, strength) in &dep_analysis.coupling_pairs {
if *strength >= 2 {
let fan_in_a = dep_analysis.in_degree.get(a).copied().unwrap_or(0);
let fan_out_a = dep_analysis.out_degree.get(a).copied().unwrap_or(0);
let fan_in_b = dep_analysis.in_degree.get(b).copied().unwrap_or(0);
let fan_out_b = dep_analysis.out_degree.get(b).copied().unwrap_or(0);
let ctx = serde_json::json!({
"module_a": { "fan_in": fan_in_a, "fan_out": fan_out_a },
"module_b": { "fan_in": fan_in_b, "fan_out": fan_out_b },
});
diagnostics.push(Diagnostic {
severity: Severity::Warn,
category: Category::HighCoupling,
location: Location {
file: None,
function: None,
module: Some(format!("{} <-> {}", a, b)),
line_start: None,
line_end: None,
},
metric: *strength as f64,
threshold: 2.0,
context: Some(ctx),
message: format!(
"{} and {} have bidirectional dependency (strength {})",
a, b, strength
),
});
}
}
}
fn diagnose_god_modules(dep_analysis: &deps::DepAnalysis, diagnostics: &mut Vec<Diagnostic>) {
let fan_totals: Vec<f64> = dep_analysis
.modules
.iter()
.map(|m| {
let in_d = dep_analysis.in_degree.get(m).copied().unwrap_or(0);
let out_d = dep_analysis.out_degree.get(m).copied().unwrap_or(0);
(in_d + out_d) as f64
})
.collect();
if fan_totals.is_empty() {
return;
}
let mean_fan = fan_totals.iter().sum::<f64>() / fan_totals.len() as f64;
let var_fan = fan_totals
.iter()
.map(|v| (v - mean_fan).powi(2))
.sum::<f64>()
/ fan_totals.len() as f64;
let std_fan = var_fan.sqrt();
let warn_thresh = mean_fan + std_fan;
let alert_thresh = mean_fan + 2.0 * std_fan;
for (i, m) in dep_analysis.modules.iter().enumerate() {
let total = fan_totals[i];
let in_d = dep_analysis.in_degree.get(m).copied().unwrap_or(0);
let out_d = dep_analysis.out_degree.get(m).copied().unwrap_or(0);
if total > alert_thresh && alert_thresh > 0.0 {
let ctx = serde_json::json!({
"fan_in": in_d,
"fan_out": out_d,
"mean": mean_fan,
"stddev": std_fan,
});
diagnostics.push(Diagnostic {
severity: Severity::Alert,
category: Category::GodModule,
location: mod_location(m),
metric: total,
threshold: alert_thresh,
context: Some(ctx),
message: format!(
"{} has fan-in+fan-out of {} (mean+2σ={:.1}, fan-in {}, fan-out {})",
m, total as usize, alert_thresh, in_d, out_d
),
});
} else if total > warn_thresh && warn_thresh > 0.0 {
let ctx = serde_json::json!({
"fan_in": in_d,
"fan_out": out_d,
"mean": mean_fan,
"stddev": std_fan,
});
diagnostics.push(Diagnostic {
severity: Severity::Warn,
category: Category::GodModule,
location: mod_location(m),
metric: total,
threshold: warn_thresh,
context: Some(ctx),
message: format!(
"{} has fan-in+fan-out of {} (mean+1σ={:.1}, fan-in {}, fan-out {})",
m, total as usize, warn_thresh, in_d, out_d
),
});
}
}
}
fn compute_reachable(call_graph: &flow::CallGraph) -> HashSet<String> {
let mut reachable = HashSet::new();
let mut queue: Vec<String> = call_graph.entry_points.clone();
while let Some(node) = queue.pop() {
if !reachable.insert(node.clone()) {
continue;
}
if let Some(callees) = call_graph.adjacency.get(&node) {
for callee in callees {
if !reachable.contains(callee) {
queue.push(callee.clone());
}
}
}
}
reachable
}
fn diagnose_dead_code(
call_graph: &flow::CallGraph,
reachable: &HashSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for func in &call_graph.functions {
if !reachable.contains(func) {
diagnostics.push(Diagnostic {
severity: Severity::Warn,
category: Category::DeadCode,
location: Location {
file: None,
function: Some(func.clone()),
module: None,
line_start: None,
line_end: None,
},
metric: 0.0,
threshold: 0.0,
context: None,
message: format!("{} is not reachable from any entry point", func),
});
}
}
}
fn diagnose_cyclic_deps(dep_analysis: &deps::DepAnalysis, diagnostics: &mut Vec<Diagnostic>) {
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
for m in &dep_analysis.modules {
adj.entry(m.clone()).or_default();
}
for edge in &dep_analysis.edges {
adj.entry(edge.from.clone())
.or_default()
.push(edge.to.clone());
}
let sccs = scoring::tarjan_scc_simple(&dep_analysis.modules, &adj);
for scc in &sccs {
if scc.len() > 1 {
let members = scc.join(", ");
let ctx = serde_json::json!({
"members": scc,
"member_count": scc.len(),
});
diagnostics.push(Diagnostic {
severity: Severity::Warn,
category: Category::CyclicDeps,
location: Location {
file: None,
function: None,
module: Some(members.clone()),
line_start: None,
line_end: None,
},
metric: scc.len() as f64,
threshold: 1.0,
context: Some(ctx),
message: format!("Cyclic dependency among {} modules: {}", scc.len(), members),
});
}
}
}
fn diagnose_redundancy(
redundant_pairs: &[redundancy::RedundantPair],
diagnostics: &mut Vec<Diagnostic>,
) {
for pair in redundant_pairs {
let (sev, thresh) = if pair.similarity > REDUNDANCY.alert {
(Some(Severity::Alert), REDUNDANCY.alert)
} else if pair.similarity > REDUNDANCY.warn {
(Some(Severity::Warn), REDUNDANCY.warn)
} else {
(None, 0.0)
};
if let Some(severity) = sev {
let ctx = serde_json::json!({
"func_a": pair.func_a,
"func_b": pair.func_b,
"file_a": pair.file_a,
"file_b": pair.file_b,
"kind": pair.kind.to_string(),
});
let msg = format!(
"{} and {} have {:.0}% similarity ({})",
pair.func_a,
pair.func_b,
pair.similarity * 100.0,
pair.kind
);
diagnostics.push(Diagnostic {
severity,
category: Category::RedundantCode,
location: Location {
file: Some(pair.file_a.clone()),
function: Some(format!("{} / {}", pair.func_a, pair.func_b)),
module: None,
line_start: Some(pair.line_a),
line_end: None,
},
metric: pair.similarity,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
}
}
fn diagnose_low_cohesion(
cohesion_data: &[deps::ModuleCohesion],
diagnostics: &mut Vec<Diagnostic>,
) {
for mc in cohesion_data {
if mc.function_count <= 1 {
continue;
}
let (sev, thresh) = if mc.combined < COHESION.alert {
(Some(Severity::Alert), COHESION.alert)
} else if mc.combined < COHESION.warn {
(Some(Severity::Warn), COHESION.warn)
} else {
(None, 0.0)
};
if let Some(severity) = sev {
let ctx = serde_json::json!({
"call_cohesion": mc.call_cohesion,
"type_cohesion": mc.type_cohesion,
"function_count": mc.function_count,
});
let msg = format!(
"{} has cohesion {:.2} (call {:.2}, type {:.2}, {} functions)",
mc.module, mc.combined, mc.call_cohesion, mc.type_cohesion, mc.function_count
);
diagnostics.push(Diagnostic {
severity,
category: Category::LowCohesion,
location: mod_location(&mc.module),
metric: mc.combined,
threshold: thresh,
context: Some(ctx),
message: msg,
});
}
}
}
// ── Helpers ──────────────────────────────────────────────────────
fn check_threshold(value: f64, thresh: &Threshold) -> Option<(Severity, f64)> {
if value > thresh.alert {
Some((Severity::Alert, thresh.alert))
} else if value > thresh.warn {
Some((Severity::Warn, thresh.warn))
} else {
None
}
}
fn make_location(file: Option<&str>, function: Option<&str>, module: Option<&str>) -> Location {
Location {
file: file.map(str::to_string),
function: function.map(str::to_string),
module: module.map(str::to_string),
line_start: None,
line_end: None,
}
}
fn fn_location(name: &str, file: &str) -> Location {
make_location(Some(file), Some(name), None)
}
fn mod_location(module: &str) -> Location {
make_location(None, None, Some(module))
}

220
src/diagnostics/scoring.rs Normal file
View file

@ -0,0 +1,220 @@
use std::path::{Path, PathBuf};
use crate::circuits;
use crate::complexity;
use crate::deps;
use crate::flow;
use crate::loc;
use crate::redundancy;
use super::{HealthScores, COGNITIVE, CYCLOMATIC, FILE_LOC, REDUNDANCY};
pub(super) fn compute_health_scores(
file_stats: &[loc::FileLocStats],
func_complexities: &[complexity::FunctionComplexity],
dep_analysis: &deps::DepAnalysis,
redundant_pairs: &[redundancy::RedundantPair],
call_graph: &flow::CallGraph,
reachable: &std::collections::HashSet<String>,
rs_files: &[PathBuf],
project_path: &Path,
) -> HealthScores {
let modularity = compute_modularity_score(dep_analysis, rs_files, project_path);
let complexity = compute_complexity_score(func_complexities);
let maintainability = compute_maintainability_score(
file_stats,
func_complexities,
redundant_pairs,
call_graph,
reachable,
modularity,
complexity,
);
HealthScores {
modularity,
complexity,
maintainability,
}
}
fn compute_modularity_score(
dep_analysis: &deps::DepAnalysis,
rs_files: &[PathBuf],
project_path: &Path,
) -> f64 {
if dep_analysis.modules.is_empty() {
return 1.0;
}
let bidi_count = dep_analysis
.coupling_pairs
.iter()
.filter(|(_, _, s)| *s >= 2)
.count();
let total_pairs = dep_analysis.coupling_pairs.len().max(1);
let coupling_ratio = bidi_count as f64 / total_pairs as f64;
let coupling_score = 1.0 - coupling_ratio;
let modularity_q = circuits::circuit_summary(rs_files, project_path)
.map(|cs| cs.modularity)
.unwrap_or(0.0);
let q_score = ((modularity_q + 0.5) / 1.5).clamp(0.0, 1.0);
(0.5 * coupling_score + 0.5 * q_score).clamp(0.0, 1.0)
}
fn compute_complexity_score(func_complexities: &[complexity::FunctionComplexity]) -> f64 {
if func_complexities.is_empty() {
return 1.0;
}
let clean_cycl = func_complexities
.iter()
.filter(|fc| (fc.cyclomatic as f64) <= CYCLOMATIC.warn)
.count();
let cycl_ratio = clean_cycl as f64 / func_complexities.len() as f64;
let clean_cogn = func_complexities
.iter()
.filter(|fc| (fc.cognitive as f64) <= COGNITIVE.warn)
.count();
let cogn_ratio = clean_cogn as f64 / func_complexities.len() as f64;
let mut scores: Vec<f64> = func_complexities
.iter()
.map(|fc| fc.composite_score)
.collect();
scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if scores.len() % 2 == 0 {
(scores[scores.len() / 2 - 1] + scores[scores.len() / 2]) / 2.0
} else {
scores[scores.len() / 2]
};
let median_score = (1.0 - (median / 30.0)).clamp(0.0, 1.0);
(0.4 * cycl_ratio + 0.3 * cogn_ratio + 0.3 * median_score).clamp(0.0, 1.0)
}
fn compute_maintainability_score(
file_stats: &[loc::FileLocStats],
func_complexities: &[complexity::FunctionComplexity],
redundant_pairs: &[redundancy::RedundantPair],
call_graph: &flow::CallGraph,
reachable: &std::collections::HashSet<String>,
modularity: f64,
complexity: f64,
) -> f64 {
let clean_files = file_stats
.iter()
.filter(|f| (f.code_lines as f64) <= FILE_LOC.warn)
.count();
let file_ratio = if file_stats.is_empty() {
1.0
} else {
clean_files as f64 / file_stats.len() as f64
};
let high_redundancy = redundant_pairs
.iter()
.filter(|p| p.similarity > REDUNDANCY.alert)
.count();
let total_funcs = func_complexities.len().max(1);
let redundancy_ratio =
(1.0 - (high_redundancy as f64 * 2.0 / total_funcs as f64)).clamp(0.0, 1.0);
let dead_ratio = if call_graph.functions.is_empty() {
1.0
} else {
let dead = call_graph
.functions
.iter()
.filter(|f| !reachable.contains(*f))
.count();
(1.0 - dead as f64 / call_graph.functions.len() as f64).clamp(0.0, 1.0)
};
(0.25 * modularity
+ 0.25 * complexity
+ 0.2 * file_ratio
+ 0.15 * redundancy_ratio
+ 0.15 * dead_ratio)
.clamp(0.0, 1.0)
}
// ── Simple Tarjan SCC (to avoid depending on graph module internals) ─
pub(super) fn tarjan_scc_simple(
nodes: &[String],
adj: &std::collections::HashMap<String, Vec<String>>,
) -> Vec<Vec<String>> {
struct State {
index_counter: usize,
stack: Vec<String>,
on_stack: std::collections::HashSet<String>,
index: std::collections::HashMap<String, usize>,
lowlink: std::collections::HashMap<String, usize>,
sccs: Vec<Vec<String>>,
}
fn strongconnect(
v: &str,
adj: &std::collections::HashMap<String, Vec<String>>,
state: &mut State,
) {
state.index.insert(v.to_string(), state.index_counter);
state.lowlink.insert(v.to_string(), state.index_counter);
state.index_counter += 1;
state.stack.push(v.to_string());
state.on_stack.insert(v.to_string());
if let Some(neighbors) = adj.get(v) {
for w in neighbors {
if !state.index.contains_key(w.as_str()) {
strongconnect(w, adj, state);
let wll = state.lowlink[w.as_str()];
let vll = state.lowlink.get_mut(v).unwrap();
if wll < *vll {
*vll = wll;
}
} else if state.on_stack.contains(w.as_str()) {
let wi = state.index[w.as_str()];
let vll = state.lowlink.get_mut(v).unwrap();
if wi < *vll {
*vll = wi;
}
}
}
}
if state.lowlink[v] == state.index[v] {
let mut component = Vec::new();
loop {
let w = state.stack.pop().unwrap();
state.on_stack.remove(&w);
component.push(w.clone());
if w == v {
break;
}
}
state.sccs.push(component);
}
}
let mut state = State {
index_counter: 0,
stack: Vec::new(),
on_stack: std::collections::HashSet::new(),
index: std::collections::HashMap::new(),
lowlink: std::collections::HashMap::new(),
sccs: Vec::new(),
};
for node in nodes {
if !state.index.contains_key(node.as_str()) {
strongconnect(node, adj, &mut state);
}
}
state.sccs
}

706
src/dist.rs Normal file
View file

@ -0,0 +1,706 @@
use colored::Colorize;
use serde::Serialize;
use std::path::Path;
use crate::complexity::{self, FunctionComplexity};
use crate::render;
// ── Metric extraction ──────────────────────────────────────────────
const METRIC_NAMES: &[&str] = &["loc", "cyclomatic", "cognitive", "nesting", "params"];
struct MetricSet {
/// One vector of values per metric, in METRIC_NAMES order.
columns: Vec<Vec<f64>>,
}
fn extract_metrics(fcs: &[FunctionComplexity]) -> MetricSet {
let mut columns: Vec<Vec<f64>> = vec![Vec::new(); METRIC_NAMES.len()];
for fc in fcs {
columns[0].push(fc.line_count as f64);
columns[1].push(fc.cyclomatic as f64);
columns[2].push(fc.cognitive as f64);
columns[3].push(fc.nesting_depth as f64);
columns[4].push(fc.param_count as f64);
}
MetricSet { columns }
}
// ── Statistics helpers ─────────────────────────────────────────────
fn mean(v: &[f64]) -> f64 {
if v.is_empty() {
return 0.0;
}
v.iter().sum::<f64>() / v.len() as f64
}
fn std_dev(v: &[f64]) -> f64 {
if v.len() < 2 {
return 0.0;
}
let m = mean(v);
let var = v.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / v.len() as f64;
var.sqrt()
}
fn pearson(x: &[f64], y: &[f64]) -> f64 {
let n = x.len();
if n < 2 {
return 0.0;
}
let mx = mean(x);
let my = mean(y);
let mut num = 0.0;
let mut dx2 = 0.0;
let mut dy2 = 0.0;
for i in 0..n {
let dx = x[i] - mx;
let dy = y[i] - my;
num += dx * dy;
dx2 += dx * dx;
dy2 += dy * dy;
}
let denom = (dx2 * dy2).sqrt();
if denom < 1e-12 {
0.0
} else {
num / denom
}
}
fn skewness(v: &[f64]) -> f64 {
let n = v.len();
if n < 3 {
return 0.0;
}
let m = mean(v);
let s = std_dev(v);
if s < 1e-12 {
return 0.0;
}
let m3 = v.iter().map(|x| ((x - m) / s).powi(3)).sum::<f64>();
m3 / n as f64
}
fn kurtosis(v: &[f64]) -> f64 {
let n = v.len();
if n < 4 {
return 0.0;
}
let m = mean(v);
let s = std_dev(v);
if s < 1e-12 {
return 0.0;
}
let m4 = v.iter().map(|x| ((x - m) / s).powi(4)).sum::<f64>();
m4 / n as f64 - 3.0 // excess kurtosis
}
// ── Histogram ──────────────────────────────────────────────────────
fn render_histogram(values: &[f64], label: &str, num_bins: usize, verbose: bool) {
if values.is_empty() {
return;
}
let min_v = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max_v = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
// Cap bins to the actual range for integer-valued data
let range = max_v - min_v;
let num_bins = if range.abs() < 1e-12 {
1
} else {
let int_range = range.ceil() as usize;
num_bins.min(int_range.max(1))
};
let bin_width = if num_bins == 1 {
1.0
} else {
range / num_bins as f64
};
let mut bins = vec![0usize; num_bins];
for &v in values {
let idx = if num_bins == 1 {
0
} else {
((v - min_v) / bin_width).floor() as usize
};
let idx = idx.min(num_bins - 1);
bins[idx] += 1;
}
let max_count = *bins.iter().max().unwrap_or(&1).max(&1);
let term_w = render::terminal_width();
let label_w = 12; // " [xxx, yyy)"
let count_w = format!("{}", max_count).len() + 1;
let bar_budget = term_w.saturating_sub(label_w + count_w + 4).max(10);
println!(
"\n {} (n={})",
label.bright_cyan().bold(),
format!("{}", values.len()).bold()
);
if verbose {
render::verbose_block(&[
"Histogram: frequency distribution of values. Each row is a bin range.",
"Bar length proportional to count. Color: red = most frequent, green = least.",
"Stats below: skew > 0 = right-tailed (few very high values),",
" excess kurtosis > 0 = heavy tails (more outliers than a normal distribution).",
]);
render::guide_ref("complexity");
}
for i in 0..num_bins {
let lo = min_v + i as f64 * bin_width;
let hi = lo + bin_width;
let range_label = if num_bins == 1 {
format!("[{:.0}]", lo)
} else if i == num_bins - 1 {
format!("[{:.0},{:.0}]", lo, hi)
} else {
format!("[{:.0},{:.0})", lo, hi)
};
let bar_len = if max_count > 0 {
(bins[i] as f64 / max_count as f64 * bar_budget as f64).ceil() as usize
} else {
0
}
.max(if bins[i] > 0 { 1 } else { 0 });
let ratio = if max_count > 0 {
1.0 - (bins[i] as f64 / max_count as f64)
} else {
1.0
};
let bar = render::bar_color(&"█".repeat(bar_len), ratio);
println!(
" {:>10} │ {}{} {}",
range_label.dimmed(),
bar,
" ".repeat(bar_budget.saturating_sub(bar_len)),
format!("{}", bins[i]).bold(),
);
}
// Distribution shape stats
let m = mean(values);
let sd = std_dev(values);
let sk = skewness(values);
let ku = kurtosis(values);
println!(
" {} mean={:.1} σ={:.1} skew={:.2} kurt={:.2}",
"↳".dimmed(),
m,
sd,
sk,
ku
);
}
// ── Scatter plot ───────────────────────────────────────────────────
fn render_scatter(x: &[f64], y: &[f64], x_label: &str, y_label: &str, verbose: bool) {
if x.is_empty() {
return;
}
let r = pearson(x, y);
println!(
"\n{}",
format!("── {} vs {} (r={:.3}) ", x_label, y_label, r)
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Scatter plot: each ● represents one or more functions at that (x, y) position.",
" Green ● = 1 function, Yellow ● = 2-3, Red ● = 4+ (overlapping).",
" · = empty cell. X-axis = LoC, Y-axis = composite complexity.",
"Pearson r measures linear correlation: r > 0.7 = strong positive,",
" r ≈ 0 = no linear relationship, r < -0.7 = strong negative.",
]);
}
let plot_w: usize = render::terminal_width().min(72).saturating_sub(8);
let plot_h: usize = 20;
let x_min = x.iter().cloned().fold(f64::INFINITY, f64::min);
let x_max = x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let y_min = y.iter().cloned().fold(f64::INFINITY, f64::min);
let y_max = y.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let x_range = if (x_max - x_min).abs() < 1e-12 {
1.0
} else {
x_max - x_min
};
let y_range = if (y_max - y_min).abs() < 1e-12 {
1.0
} else {
y_max - y_min
};
// Build grid with counts
let mut grid = vec![vec![0u32; plot_w]; plot_h];
for i in 0..x.len() {
let col = ((x[i] - x_min) / x_range * (plot_w - 1) as f64).round() as usize;
let row = ((y[i] - y_min) / y_range * (plot_h - 1) as f64).round() as usize;
let col = col.min(plot_w - 1);
let row = row.min(plot_h - 1);
grid[row][col] += 1;
}
// Render top to bottom (high y first)
let y_label_w = 6;
for row in (0..plot_h).rev() {
let y_val = y_min + (row as f64 / (plot_h - 1).max(1) as f64) * y_range;
let label = if row == plot_h - 1 || row == 0 || row == plot_h / 2 {
format!("{:>5.0}", y_val)
} else {
" ".to_string()
};
let mut line = String::new();
for col in 0..plot_w {
let count = grid[row][col];
if count == 0 {
line.push('·');
} else if count == 1 {
line.push_str(&"●".green().to_string());
} else if count < 4 {
line.push_str(&"●".yellow().to_string());
} else {
line.push_str(&"●".red().bold().to_string());
}
}
let border = if row == 0 { "└" } else { "│" };
println!("{} {}{}", label.dimmed(), border, line);
}
// X axis
let x_min_s = format!("{:.0}", x_min);
let x_max_s = format!("{:.0}", x_max);
let mid_x = (x_min + x_max) / 2.0;
let x_mid_s = format!("{:.0}", mid_x);
let axis_padding = plot_w.saturating_sub(x_min_s.len() + x_max_s.len() + x_mid_s.len()) / 2;
println!(
"{} {}{}{}{}{}",
" ".repeat(y_label_w),
x_min_s.dimmed(),
" ".repeat(axis_padding),
x_mid_s.dimmed(),
" ".repeat(axis_padding),
x_max_s.dimmed(),
);
println!(
"{} {} → {} {} ↑",
" ".repeat(y_label_w),
x_label.dimmed(),
y_label.dimmed(),
format!("r={:.3}", r).bold(),
);
}
// ── Outlier detection ──────────────────────────────────────────────
fn render_outliers(fcs: &[FunctionComplexity], metrics: &MetricSet, verbose: bool) {
println!(
"\n{}",
"── Outliers (z-score > 2.0) ───────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Outlier detection: functions with a z-score > 2.0 in any metric.",
"z-score = (value - mean) / standard deviation. z > 2.0 means the value is",
"more than 2 standard deviations above the mean — statistically unusual.",
"These are candidates for refactoring or closer inspection.",
"Grouped by metric, showing up to 5 outliers per metric.",
]);
}
let mut any_outlier = false;
for (mi, metric_name) in METRIC_NAMES.iter().enumerate() {
let vals = &metrics.columns[mi];
let m = mean(vals);
let s = std_dev(vals);
if s < 1e-12 {
continue;
}
let mut outliers: Vec<(usize, f64)> = Vec::new();
for (i, &v) in vals.iter().enumerate() {
let z = (v - m) / s;
if z > 2.0 {
outliers.push((i, z));
}
}
outliers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
if !outliers.is_empty() {
any_outlier = true;
println!(
"\n {} ({} outlier{})",
metric_name.yellow().bold(),
outliers.len(),
if outliers.len() == 1 { "" } else { "s" }
);
for (i, z) in outliers.iter().take(5) {
let name = if fcs[*i].name.len() > 45 {
format!("{}...", &fcs[*i].name[..42])
} else {
fcs[*i].name.clone()
};
println!(
" {} {} (z={:.2}, val={:.0})",
"▸".red(),
name.dimmed(),
z,
vals[*i],
);
}
}
}
if !any_outlier {
println!(" {}", "No outliers detected.".dimmed());
}
}
// ── Correlation matrix ─────────────────────────────────────────────
fn render_correlation_matrix(metrics: &MetricSet, verbose: bool) {
let n = METRIC_NAMES.len();
println!(
"\n{}",
"── Correlation Matrix ─────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Pairwise Pearson correlation coefficients (r) between all metrics.",
"r ranges from -1.0 (perfect negative) to +1.0 (perfect positive).",
"Color: red/bold = strong positive (r > 0.7), blue/bold = strong negative (r < -0.7),",
" yellow = moderate positive (r > 0.4), cyan = moderate negative (r < -0.4),",
" dim = weak correlation (|r| ≤ 0.4).",
"Diagonal is always 1.000 (a metric perfectly correlates with itself).",
"High correlation between two metrics suggests redundancy or a shared underlying factor.",
]);
}
// Header row
print!(" {:>12}", "");
for name in METRIC_NAMES {
print!(" {:>10}", name.bold());
}
println!();
for i in 0..n {
print!(" {:>12}", METRIC_NAMES[i].bold());
for j in 0..n {
let r = pearson(&metrics.columns[i], &metrics.columns[j]);
let cell = format!("{:>7.3}", r);
let colored = color_correlation(&cell, r);
print!(" {}", colored);
}
println!();
}
}
fn color_correlation(text: &str, r: f64) -> String {
let abs_r = r.abs();
if abs_r > 0.7 {
if r > 0.0 {
text.red().bold().to_string()
} else {
text.blue().bold().to_string()
}
} else if abs_r > 0.4 {
if r > 0.0 {
text.yellow().to_string()
} else {
text.cyan().to_string()
}
} else {
text.dimmed().to_string()
}
}
// ── Public entry point ─────────────────────────────────────────────
pub fn render_dist(
rs_files: &[std::path::PathBuf],
project_path: &Path,
metric_filter: Option<&str>,
num_bins: usize,
verbose: bool,
) {
let symbols = crate::ast_parser::parse_project(rs_files);
let fcs = complexity::compute_all(&symbols, project_path);
if fcs.is_empty() {
println!("{}", "No functions found to analyze.".yellow());
return;
}
let metrics = extract_metrics(&fcs);
println!(
"\n{}",
"── Distribution Analysis ──────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Statistical distribution analysis of complexity metrics across all functions.",
"Histograms show frequency distributions. Scatter plot shows LoC vs complexity.",
"Outliers are functions with z-score > 2.0 (more than 2σ above the mean).",
"Correlation matrix shows pairwise Pearson r between all metrics.",
]);
}
println!(
"{} functions across {} files\n",
format!("{}", fcs.len()).bold(),
format!("{}", rs_files.len()).bold(),
);
// Histograms
let labels = &[
"Function LoC",
"Cyclomatic",
"Cognitive",
"Nesting Depth",
"Param Count",
];
if let Some(filter) = metric_filter {
// Find the matching metric
let filter_lower = filter.to_lowercase();
if let Some(idx) = METRIC_NAMES.iter().position(|&n| n == filter_lower) {
render_histogram(&metrics.columns[idx], labels[idx], num_bins, verbose);
} else {
println!(
"{} Unknown metric '{}'. Available: {}",
"Error:".red().bold(),
filter,
METRIC_NAMES.join(", "),
);
return;
}
} else {
for (i, label) in labels.iter().enumerate() {
render_histogram(&metrics.columns[i], label, num_bins, verbose);
}
}
// Scatter plot: complexity (composite score) vs LoC
let composite_scores: Vec<f64> = fcs.iter().map(|f| f.composite_score).collect();
render_scatter(
&metrics.columns[0], // LoC
&composite_scores,
"Function LoC",
"Complexity",
verbose,
);
// Outlier detection
render_outliers(&fcs, &metrics, verbose);
// Correlation matrix
render_correlation_matrix(&metrics, verbose);
println!();
}
#[derive(Serialize)]
struct HistogramBinJson {
low: f64,
high: f64,
count: usize,
}
#[derive(Serialize)]
struct HistogramJson {
metric: String,
bins: Vec<HistogramBinJson>,
mean: f64,
std_dev: f64,
skewness: f64,
kurtosis: f64,
}
#[derive(Serialize)]
struct OutlierJson {
metric: String,
function: String,
z_score: f64,
value: f64,
}
#[derive(Serialize)]
struct CorrelationEntryJson {
metric_a: String,
metric_b: String,
pearson_r: f64,
}
#[derive(Serialize)]
struct DistJson {
cstat_version: String,
histograms: Vec<HistogramJson>,
outliers: Vec<OutlierJson>,
correlations: Vec<CorrelationEntryJson>,
}
fn compute_histogram_bins(values: &[f64], num_bins: usize) -> Vec<HistogramBinJson> {
if values.is_empty() {
return vec![];
}
let min_v = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max_v = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = max_v - min_v;
let num_bins = if range.abs() < 1e-12 {
1
} else {
let int_range = range.ceil() as usize;
num_bins.min(int_range.max(1))
};
let bin_width = if num_bins == 1 {
1.0
} else {
range / num_bins as f64
};
let mut counts = vec![0usize; num_bins];
for &v in values {
let idx = if num_bins == 1 {
0
} else {
((v - min_v) / bin_width).floor() as usize
};
let idx = idx.min(num_bins - 1);
counts[idx] += 1;
}
(0..num_bins)
.map(|i| {
let lo = min_v + i as f64 * bin_width;
let hi = lo + bin_width;
HistogramBinJson {
low: lo,
high: hi,
count: counts[i],
}
})
.collect()
}
/// Render distribution analysis as JSON.
pub fn render_dist_json(
rs_files: &[std::path::PathBuf],
project_path: &std::path::Path,
metric_filter: Option<&str>,
num_bins: usize,
) {
let symbols = crate::ast_parser::parse_project(rs_files);
let fcs = complexity::compute_all(&symbols, project_path);
let metrics = extract_metrics(&fcs);
let labels = &["loc", "cyclomatic", "cognitive", "nesting", "params"];
let indices: Vec<usize> = if let Some(filter) = metric_filter {
let filter_lower = filter.to_lowercase();
if let Some(idx) = METRIC_NAMES.iter().position(|&n| n == filter_lower) {
vec![idx]
} else {
vec![]
}
} else {
(0..METRIC_NAMES.len()).collect()
};
let histograms: Vec<HistogramJson> = indices
.iter()
.map(|&i| {
let vals = &metrics.columns[i];
let bins_json = compute_histogram_bins(vals, num_bins);
HistogramJson {
metric: labels[i].to_string(),
bins: bins_json,
mean: mean(vals),
std_dev: std_dev(vals),
skewness: skewness(vals),
kurtosis: kurtosis(vals),
}
})
.collect();
let mut outliers_json: Vec<OutlierJson> = Vec::new();
for (mi, metric_name) in METRIC_NAMES.iter().enumerate() {
let vals = &metrics.columns[mi];
let m = mean(vals);
let s = std_dev(vals);
if s < 1e-12 {
continue;
}
for (i, &v) in vals.iter().enumerate() {
let z = (v - m) / s;
if z > 2.0 {
outliers_json.push(OutlierJson {
metric: metric_name.to_string(),
function: fcs[i].name.clone(),
z_score: z,
value: v,
});
}
}
}
outliers_json.sort_by(|a, b| {
b.z_score
.partial_cmp(&a.z_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let n = METRIC_NAMES.len();
let mut correlations: Vec<CorrelationEntryJson> = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
let r = pearson(&metrics.columns[i], &metrics.columns[j]);
correlations.push(CorrelationEntryJson {
metric_a: METRIC_NAMES[i].to_string(),
metric_b: METRIC_NAMES[j].to_string(),
pearson_r: r,
});
}
}
let output = DistJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
histograms,
outliers: outliers_json,
correlations,
};
println!("{}", serde_json::to_string(&output).unwrap());
}

248
src/dump.rs Normal file
View file

@ -0,0 +1,248 @@
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::ast_parser;
use crate::complexity;
use crate::deps;
use crate::diagnostics;
use crate::loc;
// ── Top-level dump output ────────────────────────────────────────
#[derive(Serialize)]
struct DumpOutput {
cstat_version: String,
project: ProjectInfo,
scores: diagnostics::HealthScores,
diagnostic_counts: DiagnosticCounts,
worst_items: Vec<diagnostics::Diagnostic>,
suggested_drilldowns: Vec<Drilldown>,
}
#[derive(Serialize)]
struct ProjectInfo {
files: usize,
total_loc: usize,
functions: usize,
modules: usize,
}
// ── Diagnostic counts ───────────────────────────────────────────
#[derive(Serialize)]
struct DiagnosticCounts {
total: usize,
alerts: usize,
warns: usize,
by_category: BTreeMap<String, CategoryCounts>,
}
#[derive(Serialize)]
struct CategoryCounts {
alerts: usize,
warns: usize,
}
// ── Drilldowns ──────────────────────────────────────────────────
#[derive(Serialize)]
struct Drilldown {
command: String,
reason: String,
}
// ── Public entry point ───────────────────────────────────────────
pub fn render_dump(rs_files: &[PathBuf], project_path: &Path) {
let version = env!("CARGO_PKG_VERSION").to_string();
let (file_stats, agg_loc) = loc::analyze_files(rs_files);
let symbols = ast_parser::parse_project(rs_files);
let func_complexities = complexity::compute_all(&symbols, project_path);
let dep_analysis = deps::analyze_deps(rs_files, project_path);
let report = diagnostics::run_diagnostics(rs_files, project_path);
let project = ProjectInfo {
files: file_stats.len(),
total_loc: agg_loc.as_ref().map(|a| a.total_loc).unwrap_or(0),
functions: func_complexities.len(),
modules: dep_analysis.modules.len(),
};
let diagnostic_counts = build_diagnostic_counts(&report.diagnostics);
let worst_items = build_worst_items(&report.diagnostics);
let suggested_drilldowns = build_drilldowns(&report.diagnostics);
let output = DumpOutput {
cstat_version: version,
project,
scores: report.scores,
diagnostic_counts,
worst_items,
suggested_drilldowns,
};
println!("{}", serde_json::to_string(&output).unwrap());
}
// ── Diagnostic count builder ────────────────────────────────────
fn build_diagnostic_counts(diagnostics: &[diagnostics::Diagnostic]) -> DiagnosticCounts {
let mut total_alerts = 0usize;
let mut total_warns = 0usize;
let mut by_category: BTreeMap<String, CategoryCounts> = BTreeMap::new();
// Initialize all categories so they always appear
let all_categories = [
"bloated_function",
"redundant_code",
"high_cognitive",
"high_complexity",
"deep_nesting",
"dead_code",
"bloated_file",
"low_cohesion",
"too_many_params",
"god_module",
"high_coupling",
"cyclic_deps",
];
for cat in &all_categories {
by_category.insert(
cat.to_string(),
CategoryCounts {
alerts: 0,
warns: 0,
},
);
}
for d in diagnostics {
let cat_name = category_name(&d.category);
let entry = by_category.entry(cat_name).or_insert(CategoryCounts {
alerts: 0,
warns: 0,
});
match d.severity {
diagnostics::Severity::Alert => {
total_alerts += 1;
entry.alerts += 1;
}
diagnostics::Severity::Warn => {
total_warns += 1;
entry.warns += 1;
}
}
}
DiagnosticCounts {
total: diagnostics.len(),
alerts: total_alerts,
warns: total_warns,
by_category,
}
}
// ── Worst items (top 5) ─────────────────────────────────────────
fn build_worst_items(diagnostics: &[diagnostics::Diagnostic]) -> Vec<diagnostics::Diagnostic> {
// Sort: alerts first, then by metric magnitude descending.
let mut sorted: Vec<_> = diagnostics.to_vec();
sorted.sort_by(|a, b| {
let sev_order = |s: &diagnostics::Severity| match s {
diagnostics::Severity::Alert => 0,
diagnostics::Severity::Warn => 1,
};
sev_order(&a.severity)
.cmp(&sev_order(&b.severity))
.then_with(|| {
b.metric
.partial_cmp(&a.metric)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
sorted.into_iter().take(5).collect()
}
// ── Drilldown suggestions ───────────────────────────────────────
fn build_drilldowns(diagnostics: &[diagnostics::Diagnostic]) -> Vec<Drilldown> {
// Count alerts per category
let mut alert_counts: BTreeMap<String, usize> = BTreeMap::new();
for d in diagnostics {
if d.severity == diagnostics::Severity::Alert {
*alert_counts.entry(category_name(&d.category)).or_insert(0) += 1;
}
}
// Map categories to subcommands
let category_to_command: &[(&[&str], &str)] = &[
(
&["high_complexity", "high_cognitive", "deep_nesting"],
"cstat branching --json",
),
(&["too_many_params"], "cstat signature --json"),
(&["bloated_function"], "cstat span --json"),
(&["redundant_code"], "cstat redundancy --json"),
(&["bloated_file"], "cstat loc --json"),
(
&["low_cohesion", "high_coupling", "god_module"],
"cstat deps --json",
),
(&["dead_code"], "cstat test-reachability --json"),
(&["cyclic_deps"], "cstat graph --json"),
];
let mut drilldowns: Vec<(usize, String, Vec<String>)> = Vec::new();
for (categories, command) in category_to_command {
let mut total_alerts = 0usize;
let mut reasons: Vec<String> = Vec::new();
for cat in *categories {
if let Some(&count) = alert_counts.get(*cat) {
total_alerts += count;
let display = cat.replace('_', " ");
reasons.push(format!("{} alert-level {}", count, display));
}
}
if total_alerts > 0 {
drilldowns.push((total_alerts, command.to_string(), reasons));
}
}
// Sort by total alert count descending
drilldowns.sort_by(|a, b| b.0.cmp(&a.0));
drilldowns
.into_iter()
.map(|(_, command, reasons)| Drilldown {
command,
reason: reasons.join(", "),
})
.collect()
}
// ── Helpers ─────────────────────────────────────────────────────
fn category_name(cat: &diagnostics::Category) -> String {
// Match the serde snake_case serialization
match cat {
diagnostics::Category::BloatedFile => "bloated_file",
diagnostics::Category::BloatedFunction => "bloated_function",
diagnostics::Category::HighComplexity => "high_complexity",
diagnostics::Category::HighCognitive => "high_cognitive",
diagnostics::Category::DeepNesting => "deep_nesting",
diagnostics::Category::HighCoupling => "high_coupling",
diagnostics::Category::GodModule => "god_module",
diagnostics::Category::DeadCode => "dead_code",
diagnostics::Category::LowCohesion => "low_cohesion",
diagnostics::Category::RedundantCode => "redundant_code",
diagnostics::Category::TooManyParams => "too_many_params",
diagnostics::Category::CyclicDeps => "cyclic_deps",
}
.to_string()
}

507
src/file_summary.rs Normal file
View file

@ -0,0 +1,507 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::{
ast_parser, call_trace, datapaths, dead_code, deps, file_metrics, flow, loc, render, symbols,
};
#[derive(Debug, Clone, Serialize)]
pub struct FileSummaryReport {
pub cstat_version: String,
pub file: String,
pub project: String,
pub line_counts: loc::FileProjectedLineReachabilityReport,
pub static_reachability: datapaths::FileStaticReachabilityReport,
pub symbols: symbols::FileSymbolReport,
pub dependencies: deps::FileDependencyReport,
pub dead_code: dead_code::FileDeadCodeReport,
pub call_trace: call_trace::FileCallTraceReport,
}
pub fn render_file_summary(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) {
let report = build_file_summary(rs_files, project_path, file);
render_file_summary_report(&report, verbose);
}
pub fn render_file_summary_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
let report = build_file_summary(rs_files, project_path, file);
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn build_file_summary(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
) -> FileSummaryReport {
let file_display_path = call_trace::relative_path_string(project_path, file);
let project = project_name(project_path);
let symbols_raw = ast_parser::parse_project(rs_files);
let graph = flow::build_call_graph(rs_files, project_path);
let file_functions =
file_metrics::selected_file_functions(&symbols_raw, &graph, project_path, file);
let static_reachability =
datapaths::analyze_file_static_reachability(&graph, &file_functions, &file_display_path);
let reachable_functions: HashSet<String> = static_reachability
.reachable_functions
.iter()
.cloned()
.collect();
let line_counts = loc::analyze_file_projected_line_reachability(
file,
&file_functions,
&reachable_functions,
&file_display_path,
)
.unwrap_or_else(|| empty_line_report(file, &file_display_path));
let symbols = symbols::analyze_symbols_file(&symbols_raw, project_path, file);
let dependencies = deps::analyze_file_deps(rs_files, project_path, file);
let dead_code = dead_code::analyze_file_dead_code(&graph, &file_functions, &file_display_path);
let call_trace =
call_trace::analyze_file_call_traces(&graph, &file_functions, &file_display_path);
FileSummaryReport {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
file: file_display_path,
project,
line_counts,
static_reachability,
symbols,
dependencies,
dead_code,
call_trace,
}
}
fn project_name(project_path: &Path) -> String {
let fallback = || {
project_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string())
};
let Ok(manifest) = std::fs::read_to_string(project_path.join("Cargo.toml")) else {
return fallback();
};
let mut in_package = false;
for line in manifest.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_package = trimmed == "[package]";
continue;
}
if in_package {
if let Some(value) = trimmed.strip_prefix("name") {
if let Some((_, raw_name)) = value.split_once('=') {
let name = raw_name.trim().trim_matches('"');
if !name.is_empty() {
return name.to_string();
}
}
}
}
}
fallback()
}
fn empty_line_report(
file: &Path,
file_display_path: &str,
) -> loc::FileProjectedLineReachabilityReport {
let stats = loc::count_file_lines(file);
loc::FileProjectedLineReachabilityReport {
file: file_display_path.to_string(),
total_lines: stats.as_ref().map_or(0, |stats| stats.total_lines),
code_lines: 0,
projected_reachable_lines: 0,
projected_unreachable_lines: 0,
reachable_spans: Vec::new(),
unreachable_spans: Vec::new(),
}
}
fn render_file_summary_report(report: &FileSummaryReport, verbose: bool) {
println!(
"\n{} {}",
"cstat file summary".bright_cyan().bold(),
format!("({})", report.file).dimmed(),
);
println!(
"{}",
"Single-file static metrics projected through the containing crate.".dimmed()
);
render_line_counts(&report.line_counts, &report.file, verbose);
render_static_reachability(&report.static_reachability, &report.file, verbose);
render_symbols(&report.symbols, &report.file, verbose);
render_dependencies(&report.dependencies, &report.file, verbose);
render_dead_code(&report.dead_code, &report.file, verbose);
render_call_trace(&report.call_trace, &report.file, verbose);
}
fn render_line_counts(
report: &loc::FileProjectedLineReachabilityReport,
file: &str,
verbose: bool,
) {
render::section_header("Projected line reachability");
if verbose {
let full_section = format!("Full section: cstat loc --path {file} -v");
render::verbose_block(&[
"Production code lines exclude blank/comment-only lines and test/support/wrapper spans.",
"Projected reachable lines are static function spans reached from project tests/benches.",
full_section.as_str(),
]);
}
let pct = percentage(report.projected_reachable_lines, report.code_lines);
println!(
" {} {} {} {} {} {} / {} ({:.1}%) {} {}",
"production code lines".cyan(),
report.code_lines.to_string().bold(),
"physical".cyan(),
report.total_lines.to_string().bold(),
"statically reachable".cyan(),
report.projected_reachable_lines.to_string().bold(),
report.code_lines,
pct,
"not statically reachable".cyan(),
report.projected_unreachable_lines.to_string().bold(),
);
if !report.reachable_spans.is_empty() {
println!();
println!(" {}", "reachable spans:".bold());
for span in report.reachable_spans.iter().take(6) {
println!(
" {} lines {}-{}",
span.function, span.line_start, span.line_end
);
}
print_more(report.reachable_spans.len(), 6);
}
if !report.unreachable_spans.is_empty() {
println!();
println!(" {}", "unreachable spans:".bold().yellow());
for span in report.unreachable_spans.iter().take(6) {
println!(
" {} lines {}-{}",
span.function, span.line_start, span.line_end
);
}
print_more(report.unreachable_spans.len(), 6);
}
}
fn render_static_reachability(
report: &datapaths::FileStaticReachabilityReport,
file: &str,
verbose: bool,
) {
render::section_header("Static test/benchmark reachability");
if verbose {
let full_section = format!("Full section: cstat test-reachability --path {file} -v");
render::verbose_block(&[
"Roots are tests and benchmarks discovered anywhere in the project.",
"Reachability follows the full crate call graph, then projects production results onto this file.",
full_section.as_str(),
]);
}
let pct = percentage(
report.reachable_function_count,
report.production_function_count,
);
println!(
" {} {} {} {} / {} ({:.1}%) {} {} / {}",
"test/bench roots".cyan(),
report.test_benchmark_entry_count.to_string().bold(),
"functions reachable".cyan(),
report.reachable_function_count.to_string().bold(),
report.production_function_count,
pct,
"local edges reached".cyan(),
report.reachable_local_edge_count.to_string().bold(),
report.local_edge_count,
);
if !report.wrapper_entrypoints.is_empty() {
println!(
" {} {}",
"wrapper entrypoints:".cyan(),
report.wrapper_entrypoints.join(", ")
);
}
if !report.entries.is_empty() {
println!();
println!(
" {:<48} {:<7} {:>9} {:>7} {:>8}",
"entry root".bold(),
"kind".bold(),
"functions".bold(),
"steps".bold(),
"depth".bold(),
);
for entry in report.entries.iter().take(8) {
println!(
" {:<48} {:<7} {:>9} {:>7} {:>8}",
truncate(&entry.entry, 48),
entry.kind,
entry.reachable_functions,
entry.unique_call_steps,
entry.max_call_depth,
);
}
print_more(report.entries.len(), 8);
}
println!();
println!(" {}", "not statically reachable:".bold().yellow());
if report.unreachable_functions.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for function in report.unreachable_functions.iter().take(8) {
println!(" {} {}", "•".yellow(), function);
}
print_more(report.unreachable_functions.len(), 8);
}
}
fn render_symbols(report: &symbols::FileSymbolReport, file: &str, verbose: bool) {
render::section_header("Symbols");
if verbose {
let full_section = format!("Full section: cstat symbols --path {file} -v");
render::verbose_block(&[
"Selected-file symbols use syn Rust AST item discovery.",
"Functions include free functions and impl methods once.",
full_section.as_str(),
]);
}
println!(
" {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
"total".cyan(),
report.total.to_string().bold(),
"fn".cyan(),
report.functions.to_string().bold(),
"struct".cyan(),
report.structs.to_string().bold(),
"enum".cyan(),
report.enums.to_string().bold(),
"trait".cyan(),
report.traits.to_string().bold(),
"trait_impl".cyan(),
report.trait_impls.to_string().bold(),
"const".cyan(),
report.consts.to_string().bold(),
"static".cyan(),
report.statics.to_string().bold(),
);
if report.parse_error {
println!(
" {}",
"parse error: selected file could not be parsed"
.yellow()
.bold()
);
}
if !report.symbols.is_empty() {
println!();
println!(" {:<10} {}", "kind".bold(), "symbol".bold());
for row in report.symbols.iter().take(12) {
println!(
" {:<10} {}",
file_symbol_kind_label(&row.kind),
truncate(&row.symbol, 60),
);
}
print_more(report.symbols.len(), 12);
}
}
fn render_dependencies(report: &deps::FileDependencyReport, file: &str, verbose: bool) {
render::section_header("Dependencies");
if verbose {
let full_section = format!("Full section: cstat deps --path {file} -v");
render::verbose_block(&[
"Imports are raw use/mod targets parsed from this file.",
"Internal imports resolve to known modules in the containing crate.",
full_section.as_str(),
]);
}
println!(
" {} {} {} {} {} {} {} {}",
"module".cyan(),
report.module.bold(),
"imports".cyan(),
report.imports.to_string().bold(),
"internal".cyan(),
report.internal.len().to_string().bold(),
"external".cyan(),
report.external.len().to_string().bold(),
);
print_dependency_list("internal", &report.internal, 6, true);
print_dependency_list("external", &report.external, 6, false);
}
fn render_dead_code(report: &dead_code::FileDeadCodeReport, file: &str, verbose: bool) {
render::section_header("Dead code candidates");
if verbose {
let full_section = format!("Full section: cstat dead-code --path {file} -v");
render::verbose_block(&[
"Candidates are file functions not reachable from crate roots.",
"Fan-in distinguishes crate callers from callers inside this file.",
full_section.as_str(),
]);
}
println!(
" {} {} / {} {} {} candidates",
"reached from crate roots".cyan(),
report.reached_from_crate_count.to_string().bold(),
report.file_function_count,
"cold".cyan(),
report.candidate_count.to_string().bold(),
);
let candidates: Vec<_> = report
.functions
.iter()
.filter(|function| function.candidate)
.collect();
println!();
println!(
" {:<44} {:>11} {:>13} {}",
"function".bold(),
"crate fan-in".bold(),
"in-file fan-in".bold(),
"reason".bold(),
);
if candidates.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for function in candidates.iter().take(8) {
println!(
" {:<44} {:>11} {:>13} {}",
truncate(&function.function, 44),
function.crate_fan_in,
function.in_file_fan_in,
function.reason,
);
}
print_more(candidates.len(), 8);
}
}
fn render_call_trace(report: &call_trace::FileCallTraceReport, file: &str, verbose: bool) {
render::section_header("Call trace size");
if verbose {
let full_section = format!("Full section: cstat call-trace --path {file} -v");
render::verbose_block(&[
"Rows rank non-test functions by the size of their static call tree.",
"Direct callees are truncated for the summary; full trees are in the section command.",
full_section.as_str(),
]);
}
println!(
" {:<44} {:>7} {:>5} {}",
"function".bold(),
"reached".bold(),
"depth".bold(),
"direct callees".bold(),
);
let width = render::terminal_width();
let callee_width = width.saturating_sub(64).max(24);
for function in report.functions.iter().take(12) {
let callees = if function.direct_callees.is_empty() {
"-".to_string()
} else {
function.direct_callees.join(", ")
};
println!(
" {:<44} {:>7} {:>5} {}",
truncate(&function.function, 44),
function.functions_reached,
function.max_depth,
truncate(&callees, callee_width),
);
}
print_more(report.functions.len(), 12);
}
fn print_dependency_list(
label: &str,
dependencies: &[deps::FileDependency],
limit: usize,
show_resolved: bool,
) {
println!();
println!(" {}", format!("{label} imports:").bold());
if dependencies.is_empty() {
println!(" {}", "<none>".dimmed());
return;
}
for dependency in dependencies.iter().take(limit) {
if show_resolved {
println!(
" {} {} -> {}",
"•".cyan(),
dependency.raw,
dependency.resolved_module.as_deref().unwrap_or("-"),
);
} else {
println!(" {} {}", "•".cyan(), dependency.raw);
}
}
print_more(dependencies.len(), limit);
}
fn file_symbol_kind_label(kind: &symbols::FileSymbolKind) -> &'static str {
match kind {
symbols::FileSymbolKind::Fn => "fn",
symbols::FileSymbolKind::Struct => "struct",
symbols::FileSymbolKind::Enum => "enum",
symbols::FileSymbolKind::Trait => "trait",
symbols::FileSymbolKind::TraitImpl => "trait_impl",
symbols::FileSymbolKind::Const => "const",
symbols::FileSymbolKind::Static => "static",
}
}
fn percentage(numerator: usize, denominator: usize) -> f64 {
if denominator == 0 {
0.0
} else {
numerator as f64 / denominator as f64 * 100.0
}
}
fn print_more(total: usize, shown: usize) {
if total > shown {
println!(" … +{} more", total - shown);
}
}
fn truncate(value: &str, max: usize) -> String {
if value.chars().count() <= max {
value.to_string()
} else {
let mut out: String = value.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}

View file

@ -1,3 +1,4 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
@ -58,6 +59,8 @@ pub struct CallGraph {
pub entry_points: Vec<String>,
/// Entry point classification keyed by function name.
pub entry_point_kinds: HashMap<String, EntryPointKind>,
/// Edges that form cycles.
pub cycle_edges: HashSet<(String, String)>,
}
#[derive(Debug, Clone)]
@ -1446,6 +1449,49 @@ fn concrete_generic_method_edges(
out
}
/// Detect cycles in the call graph using DFS. Returns the set of back-edges.
fn detect_cycles(adjacency: &HashMap<String, Vec<String>>) -> HashSet<(String, String)> {
let mut cycle_edges = HashSet::new();
let mut visited = HashSet::new();
let mut on_stack = HashSet::new();
for node in adjacency.keys() {
if !visited.contains(node) {
dfs_cycle(
node,
adjacency,
&mut visited,
&mut on_stack,
&mut cycle_edges,
);
}
}
cycle_edges
}
fn dfs_cycle(
node: &str,
adjacency: &HashMap<String, Vec<String>>,
visited: &mut HashSet<String>,
on_stack: &mut HashSet<String>,
cycle_edges: &mut HashSet<(String, String)>,
) {
visited.insert(node.to_string());
on_stack.insert(node.to_string());
if let Some(callees) = adjacency.get(node) {
for callee in callees {
if on_stack.contains(callee.as_str()) {
cycle_edges.insert((node.to_string(), callee.clone()));
} else if !visited.contains(callee.as_str()) {
dfs_cycle(callee, adjacency, visited, on_stack, cycle_edges);
}
}
}
on_stack.remove(node);
}
/// Build the call graph from all project files.
pub fn build_call_graph(rs_files: &[PathBuf], project_path: &Path) -> CallGraph {
let mut records = Vec::new();
@ -1589,12 +1635,15 @@ pub fn build_call_graph(rs_files: &[PathBuf], project_path: &Path) -> CallGraph
callees.retain(|c| seen.insert(c.clone()));
}
let cycle_edges = detect_cycles(&adjacency);
CallGraph {
functions,
function_nodes,
adjacency,
entry_points,
entry_point_kinds,
cycle_edges,
}
}
@ -1627,6 +1676,352 @@ pub fn entry_points_by_kind(call_graph: &CallGraph, kind: EntryPointKind) -> Vec
.collect()
}
/// Render the call graph as a tree rooted at entry points.
fn render_call_tree(graph: &CallGraph, verbose: bool) {
println!(
"\n{}",
"── Call Flow ─────────────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Static call graph rendered as a tree rooted at entry points.",
"Entry points are main() and #[test] functions.",
"Each indented line is a callee of the function above it.",
"├── / └── = tree branch connectors. ─┬ = node has children below.",
"⟲ (red) = cycle detected — this call creates a recursive loop.",
"… (dimmed) = already visited in this tree — prevents infinite expansion.",
"⋯ (depth limit) = tree truncated at maximum display depth (12 levels).",
"Yellow-highlighted names = functions that themselves have callees.",
]);
render::guide_ref("flow");
}
if graph.entry_points.is_empty() {
println!(" {}", "No entry points found.".dimmed());
return;
}
// Render from each entry point
for entry in &graph.entry_points {
println!();
println!(" {}", entry.bold().bright_white());
let mut visited = HashSet::new();
visited.insert(entry.clone());
render_subtree(graph, entry, " ", &mut visited, 0);
}
}
/// Recursively render a subtree of the call graph.
fn render_subtree(
graph: &CallGraph,
node: &str,
prefix: &str,
visited: &mut HashSet<String>,
depth: usize,
) {
const MAX_DEPTH: usize = 12;
let callees = match graph.adjacency.get(node) {
Some(c) if !c.is_empty() => c,
_ => return,
};
let count = callees.len();
for (i, callee) in callees.iter().enumerate() {
let is_last = i == count - 1;
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = if is_last { " " } else { "│ " };
let is_cycle = graph
.cycle_edges
.contains(&(node.to_string(), callee.clone()));
let is_revisit = visited.contains(callee);
if is_cycle {
println!(
"{}{}{}",
prefix,
connector,
format!("{} ⟲ cycle", callee).red().bold(),
);
} else if is_revisit {
println!(
"{}{}{}",
prefix,
connector,
format!("{} …", callee).dimmed(),
);
} else if depth >= MAX_DEPTH {
println!(
"{}{}{}",
prefix,
connector,
format!("{} ⋯ (depth limit)", callee).dimmed(),
);
} else {
let has_children = graph
.adjacency
.get(callee.as_str())
.map_or(false, |c| !c.is_empty());
if has_children {
println!(
"{}{}{}",
prefix,
connector.replace("── ", "─┬ "),
callee.bright_yellow(),
);
} else {
println!("{}{}{}", prefix, connector, callee);
}
visited.insert(callee.clone());
let new_prefix = format!("{}{}", prefix, child_prefix);
render_subtree(graph, callee, &new_prefix, visited, depth + 1);
}
}
}
/// Render call graph statistics.
fn render_stats(graph: &CallGraph, verbose: bool) {
println!(
"\n{}",
"── Call Graph Stats ──────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Summary statistics for the call graph.",
"Functions = total discovered. Call edges = total caller→callee links.",
"Entry points = main() and #[test] functions. Cycles (red) = back-edges detected.",
" Cycles = none (green) means the call graph is a DAG.",
"Fan-out (top callers): functions that call the most other functions.",
" High fan-out = orchestrator or god function that coordinates many pieces.",
" Bar (blue) length proportional to call count.",
"Hot paths (longest call chains): deepest call chain from each entry point.",
" Depth = number of function hops from entry to deepest leaf.",
" Color: red = depth > 6, yellow = depth > 3, white = shallow.",
]);
}
let total_fns = graph.functions.len();
let total_edges: usize = graph.adjacency.values().map(|v| v.len()).sum();
let cycle_count = graph.cycle_edges.len();
println!();
println!(" Functions: {}", format!("{}", total_fns).bold());
println!(" Call edges: {}", format!("{}", total_edges).bold());
println!(
" Entry points: {}",
format!("{}", graph.entry_points.len()).bold()
);
if cycle_count > 0 {
println!(
" Cycles: {}",
format!("{}", cycle_count).red().bold()
);
} else {
println!(" Cycles: {}", "none".green());
}
// Fan-out: functions that call the most others
let mut fan_out: Vec<(&String, usize)> =
graph.adjacency.iter().map(|(k, v)| (k, v.len())).collect();
fan_out.sort_by(|a, b| b.1.cmp(&a.1));
if !fan_out.is_empty() {
println!();
println!(" {} (top callers):", "Fan-out".bold().underline());
for (name, count) in fan_out.iter().take(8) {
let bar_len = (*count).min(30);
let bar = "█".repeat(bar_len);
println!(" {:<40} {} {}", name.dimmed(), bar.bright_blue(), count,);
}
}
// Deepest call chains
println!();
println!(
" {} (longest call chains):",
"Hot paths".bold().underline()
);
for entry in &graph.entry_points {
let depth = measure_max_depth(graph, entry, &mut HashSet::new());
let indicator = if depth > 6 {
format!("depth {}", depth).red().bold().to_string()
} else if depth > 3 {
format!("depth {}", depth).yellow().to_string()
} else {
format!("depth {}", depth).to_string()
};
println!(" {} → {}", entry.bright_white(), indicator);
}
println!();
}
/// Measure the maximum call depth from a given node.
fn measure_max_depth(graph: &CallGraph, node: &str, visited: &mut HashSet<String>) -> usize {
if visited.contains(node) {
return 0;
}
visited.insert(node.to_string());
let max_child = graph
.adjacency
.get(node)
.map(|callees| {
callees
.iter()
.map(|c| measure_max_depth(graph, c, visited))
.max()
.unwrap_or(0)
})
.unwrap_or(0);
visited.remove(node);
1 + max_child
}
/// Public entry point: render the flow subcommand.
pub fn render_flow(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
let graph = build_call_graph(rs_files, project_path);
if graph.functions.is_empty() {
println!("{}", "No functions found to analyze.".yellow());
return;
}
render_call_tree(&graph, verbose);
render_stats(&graph, verbose);
}
#[derive(Serialize)]
struct CallEdgeJson {
caller: String,
callee: String,
}
#[derive(Serialize)]
struct FanOutJson {
function: String,
call_count: usize,
}
#[derive(Serialize)]
struct EntryDepthJson {
entry_point: String,
max_depth: usize,
}
#[derive(Serialize)]
struct FlowJson {
cstat_version: String,
functions: Vec<String>,
call_edges: Vec<CallEdgeJson>,
entry_points: Vec<String>,
cycles: Vec<CallEdgeJson>,
max_depth: usize,
top_callers: Vec<FanOutJson>,
entry_depths: Vec<EntryDepthJson>,
cold_functions: Vec<String>,
}
/// Render flow analysis as JSON.
pub fn render_flow_json(rs_files: &[PathBuf], project_path: &Path) {
let graph = build_call_graph(rs_files, project_path);
let mut call_edges = Vec::new();
for (caller, callees) in &graph.adjacency {
for callee in callees {
call_edges.push(CallEdgeJson {
caller: caller.clone(),
callee: callee.clone(),
});
}
}
let cycles: Vec<CallEdgeJson> = graph
.cycle_edges
.iter()
.map(|(a, b)| CallEdgeJson {
caller: a.clone(),
callee: b.clone(),
})
.collect();
let mut fan_out: Vec<(&String, usize)> =
graph.adjacency.iter().map(|(k, v)| (k, v.len())).collect();
fan_out.sort_by(|a, b| b.1.cmp(&a.1));
let top_callers: Vec<FanOutJson> = fan_out
.iter()
.take(20)
.map(|(f, c)| FanOutJson {
function: (*f).clone(),
call_count: *c,
})
.collect();
let entry_depths: Vec<EntryDepthJson> = graph
.entry_points
.iter()
.map(|entry| {
let depth = measure_max_depth(&graph, entry, &mut HashSet::new());
EntryDepthJson {
entry_point: entry.clone(),
max_depth: depth,
}
})
.collect();
let max_depth = entry_depths.iter().map(|e| e.max_depth).max().unwrap_or(0);
// Cold functions
let reachable = {
let mut reachable = HashSet::new();
let mut queue: Vec<String> = graph.entry_points.clone();
while let Some(node) = queue.pop() {
if !reachable.insert(node.clone()) {
continue;
}
if let Some(callees) = graph.adjacency.get(&node) {
for callee in callees {
if !reachable.contains(callee) {
queue.push(callee.clone());
}
}
}
}
reachable
};
let cold_functions: Vec<String> = graph
.functions
.iter()
.filter(|f| !reachable.contains(*f))
.cloned()
.collect();
let output = FlowJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
functions: graph.functions,
call_edges,
entry_points: graph.entry_points,
cycles,
max_depth,
top_callers,
entry_depths,
cold_functions,
};
println!("{}", serde_json::to_string(&output).unwrap());
}
#[cfg(test)]
mod tests {
use super::*;

411
src/graph/algorithms.rs Normal file
View file

@ -0,0 +1,411 @@
use std::collections::{HashMap, HashSet, VecDeque};
// ── Generic directed graph ─────────────────────────────────────────
/// A simple directed graph used for metric computation.
pub(super) struct DiGraph {
pub(super) nodes: Vec<String>,
pub(super) adj: HashMap<String, Vec<String>>, // forward edges
pub(super) rev_adj: HashMap<String, Vec<String>>, // reverse edges
}
impl DiGraph {
pub(super) fn from_edges(nodes: Vec<String>, edges: &[(String, String)]) -> Self {
let mut adj: HashMap<String, Vec<String>> = HashMap::new();
let mut rev_adj: HashMap<String, Vec<String>> = HashMap::new();
for n in &nodes {
adj.entry(n.clone()).or_default();
rev_adj.entry(n.clone()).or_default();
}
for (from, to) in edges {
adj.entry(from.clone()).or_default().push(to.clone());
rev_adj.entry(to.clone()).or_default().push(from.clone());
}
DiGraph {
nodes,
adj,
rev_adj,
}
}
pub(super) fn node_count(&self) -> usize {
self.nodes.len()
}
pub(super) fn edge_count(&self) -> usize {
self.adj.values().map(|v| v.len()).sum()
}
pub(super) fn density(&self) -> f64 {
let n = self.node_count();
if n <= 1 {
return 0.0;
}
self.edge_count() as f64 / (n * (n - 1)) as f64
}
}
// ── Tarjan's SCC ───────────────────────────────────────────────────
struct TarjanState {
index_counter: usize,
stack: Vec<String>,
on_stack: HashSet<String>,
index: HashMap<String, usize>,
lowlink: HashMap<String, usize>,
sccs: Vec<Vec<String>>,
}
pub(super) fn tarjan_scc(graph: &DiGraph) -> Vec<Vec<String>> {
let mut state = TarjanState {
index_counter: 0,
stack: Vec::new(),
on_stack: HashSet::new(),
index: HashMap::new(),
lowlink: HashMap::new(),
sccs: Vec::new(),
};
for node in &graph.nodes {
if !state.index.contains_key(node) {
tarjan_strongconnect(node, graph, &mut state);
}
}
state.sccs
}
fn tarjan_strongconnect(v: &str, graph: &DiGraph, state: &mut TarjanState) {
state.index.insert(v.to_string(), state.index_counter);
state.lowlink.insert(v.to_string(), state.index_counter);
state.index_counter += 1;
state.stack.push(v.to_string());
state.on_stack.insert(v.to_string());
if let Some(neighbors) = graph.adj.get(v) {
for w in neighbors {
if !state.index.contains_key(w.as_str()) {
tarjan_strongconnect(w, graph, state);
let wll = state.lowlink[w.as_str()];
let vll = state.lowlink.get_mut(v).unwrap();
if wll < *vll {
*vll = wll;
}
} else if state.on_stack.contains(w.as_str()) {
let wi = state.index[w.as_str()];
let vll = state.lowlink.get_mut(v).unwrap();
if wi < *vll {
*vll = wi;
}
}
}
}
if state.lowlink[v] == state.index[v] {
let mut component = Vec::new();
loop {
let w = state.stack.pop().unwrap();
state.on_stack.remove(&w);
component.push(w.clone());
if w == v {
break;
}
}
state.sccs.push(component);
}
}
// ── Brandes' betweenness centrality ────────────────────────────────
/// Result of BFS forward pass from a single source.
struct BfsResult {
stack: Vec<String>,
predecessors: HashMap<String, Vec<String>>,
sigma: HashMap<String, f64>,
}
/// BFS forward pass: compute shortest-path counts and predecessor lists from source `s`.
fn bfs_shortest_paths(s: &str, graph: &DiGraph) -> BfsResult {
let mut stack: Vec<String> = Vec::new();
let mut predecessors: HashMap<String, Vec<String>> = HashMap::new();
let mut sigma: HashMap<String, f64> = HashMap::new();
let mut dist: HashMap<String, i64> = HashMap::new();
for n in &graph.nodes {
predecessors.insert(n.clone(), Vec::new());
sigma.insert(n.clone(), 0.0);
dist.insert(n.clone(), -1);
}
*sigma.get_mut(s).unwrap() = 1.0;
*dist.get_mut(s).unwrap() = 0;
let mut queue: VecDeque<String> = VecDeque::new();
queue.push_back(s.to_string());
while let Some(v) = queue.pop_front() {
stack.push(v.clone());
let d_v = dist[&v];
if let Some(neighbors) = graph.adj.get(&v) {
for w in neighbors {
if dist[w] < 0 {
*dist.get_mut(w).unwrap() = d_v + 1;
queue.push_back(w.clone());
}
if dist[w] == d_v + 1 {
*sigma.get_mut(w).unwrap() += sigma[&v];
predecessors.get_mut(w).unwrap().push(v.clone());
}
}
}
}
BfsResult {
stack,
predecessors,
sigma,
}
}
/// Back-propagation: accumulate betweenness from dependency scores.
fn accumulate_betweenness(
mut stack: Vec<String>,
predecessors: &HashMap<String, Vec<String>>,
sigma: &HashMap<String, f64>,
s: &str,
cb: &mut HashMap<String, f64>,
) {
let mut delta: HashMap<String, f64> = HashMap::new();
for n in sigma.keys() {
delta.insert(n.clone(), 0.0);
}
while let Some(w) = stack.pop() {
for v in &predecessors[&w] {
let d = sigma[v] / sigma[&w] * (1.0 + delta[&w]);
*delta.get_mut(v).unwrap() += d;
}
if w != s {
*cb.get_mut(&w).unwrap() += delta[&w];
}
}
}
pub(super) fn betweenness_centrality(graph: &DiGraph) -> HashMap<String, f64> {
let mut cb: HashMap<String, f64> = HashMap::new();
for n in &graph.nodes {
cb.insert(n.clone(), 0.0);
}
for s in &graph.nodes {
let bfs = bfs_shortest_paths(s, graph);
accumulate_betweenness(bfs.stack, &bfs.predecessors, &bfs.sigma, s, &mut cb);
}
// Normalize
let n = graph.node_count() as f64;
if n > 2.0 {
let norm = (n - 1.0) * (n - 2.0);
for v in cb.values_mut() {
*v /= norm;
}
}
cb
}
// ── PageRank ───────────────────────────────────────────────────────
pub(super) fn pagerank(graph: &DiGraph, damping: f64, iterations: usize) -> HashMap<String, f64> {
let n = graph.node_count();
if n == 0 {
return HashMap::new();
}
let initial = 1.0 / n as f64;
let mut rank: HashMap<String, f64> = graph.nodes.iter().map(|n| (n.clone(), initial)).collect();
for _ in 0..iterations {
let mut new_rank: HashMap<String, f64> = HashMap::new();
let mut dangling_sum = 0.0;
for node in &graph.nodes {
let out_deg = graph.adj.get(node).map_or(0, |v| v.len());
if out_deg == 0 {
dangling_sum += rank[node];
}
}
for node in &graph.nodes {
let mut incoming_sum = 0.0;
if let Some(preds) = graph.rev_adj.get(node) {
for pred in preds {
let out_deg = graph.adj.get(pred).map_or(1, |v| v.len().max(1));
incoming_sum += rank[pred] / out_deg as f64;
}
}
let pr =
(1.0 - damping) / n as f64 + damping * (incoming_sum + dangling_sum / n as f64);
new_rank.insert(node.clone(), pr);
}
rank = new_rank;
}
rank
}
// ── Clustering coefficient ─────────────────────────────────────────
/// Count directed links that exist between members of `neighbors` in the adjacency map.
fn count_neighbor_links(neighbors: &[&str], adj: &HashMap<String, Vec<String>>) -> usize {
let mut links = 0usize;
for i in 0..neighbors.len() {
for j in 0..neighbors.len() {
if i == j {
continue;
}
if let Some(succs) = adj.get(neighbors[i]) {
if succs.iter().any(|x| x == neighbors[j]) {
links += 1;
}
}
}
}
links
}
pub(super) fn clustering_coefficient(graph: &DiGraph) -> HashMap<String, f64> {
let mut cc: HashMap<String, f64> = HashMap::new();
for node in &graph.nodes {
// Neighbors = union of successors and predecessors
let mut neighbors: HashSet<&str> = HashSet::new();
if let Some(succs) = graph.adj.get(node) {
for s in succs {
neighbors.insert(s.as_str());
}
}
if let Some(preds) = graph.rev_adj.get(node) {
for p in preds {
neighbors.insert(p.as_str());
}
}
neighbors.remove(node.as_str());
let k = neighbors.len();
if k < 2 {
cc.insert(node.clone(), 0.0);
continue;
}
let neighbor_vec: Vec<&str> = neighbors.iter().copied().collect();
let links = count_neighbor_links(&neighbor_vec, &graph.adj);
let possible = k * (k - 1); // directed pairs
cc.insert(node.clone(), links as f64 / possible as f64);
}
cc
}
// ── Graph-level stats ──────────────────────────────────────────────
pub(super) struct GraphStats {
pub(super) density: f64,
pub(super) diameter: Option<usize>,
pub(super) avg_path_length: f64,
pub(super) component_count: usize,
}
pub(super) fn graph_level_stats(graph: &DiGraph) -> GraphStats {
let density = graph.density();
let (diameter, avg_path_length) = if graph.node_count() <= 2000 {
compute_distances(graph)
} else {
(None, 0.0)
};
let component_count = count_weak_components(graph);
GraphStats {
density,
diameter,
avg_path_length,
component_count,
}
}
fn compute_distances(graph: &DiGraph) -> (Option<usize>, f64) {
let mut max_dist = 0usize;
let mut total_dist = 0u64;
let mut pair_count = 0u64;
for s in &graph.nodes {
let mut dist: HashMap<&str, usize> = HashMap::new();
dist.insert(s.as_str(), 0);
let mut queue: VecDeque<&str> = VecDeque::new();
queue.push_back(s.as_str());
while let Some(v) = queue.pop_front() {
let d = dist[v];
if let Some(neighbors) = graph.adj.get(v) {
for w in neighbors {
if !dist.contains_key(w.as_str()) {
let nd = d + 1;
dist.insert(w.as_str(), nd);
queue.push_back(w.as_str());
if nd > max_dist {
max_dist = nd;
}
total_dist += nd as u64;
pair_count += 1;
}
}
}
}
}
let diameter = if pair_count > 0 { Some(max_dist) } else { None };
let avg = if pair_count > 0 {
total_dist as f64 / pair_count as f64
} else {
0.0
};
(diameter, avg)
}
/// BFS traversal for one weak component starting at `start`.
/// Visits all nodes reachable via forward or reverse edges, marking them in `visited`.
fn bfs_weak_component<'a>(start: &'a str, graph: &'a DiGraph, visited: &mut HashSet<&'a str>) {
let mut queue: VecDeque<&str> = VecDeque::new();
queue.push_back(start);
visited.insert(start);
while let Some(v) = queue.pop_front() {
if let Some(neighbors) = graph.adj.get(v) {
for w in neighbors {
if !visited.contains(w.as_str()) {
visited.insert(w.as_str());
queue.push_back(w.as_str());
}
}
}
if let Some(neighbors) = graph.rev_adj.get(v) {
for w in neighbors {
if !visited.contains(w.as_str()) {
visited.insert(w.as_str());
queue.push_back(w.as_str());
}
}
}
}
}
fn count_weak_components(graph: &DiGraph) -> usize {
let mut visited: HashSet<&str> = HashSet::new();
let mut count = 0;
for node in &graph.nodes {
if visited.contains(node.as_str()) {
continue;
}
count += 1;
bfs_weak_component(node.as_str(), graph, &mut visited);
}
count
}

511
src/graph/mod.rs Normal file
View file

@ -0,0 +1,511 @@
mod algorithms;
use colored::Colorize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::deps;
use crate::flow;
use crate::render;
use algorithms::{
betweenness_centrality, clustering_coefficient, graph_level_stats, pagerank, tarjan_scc,
DiGraph, GraphStats,
};
// ── Full analysis result ───────────────────────────────────────────
struct NodeMetrics {
betweenness: f64,
pagerank: f64,
clustering: f64,
}
struct GraphAnalysis {
label: String,
node_count: usize,
edge_count: usize,
sccs: Vec<Vec<String>>,
node_metrics: HashMap<String, NodeMetrics>,
stats: GraphStats,
}
fn analyze_graph(graph: &DiGraph, label: &str) -> GraphAnalysis {
let sccs = tarjan_scc(graph);
let bc = betweenness_centrality(graph);
let pr = pagerank(graph, 0.85, 100);
let cc = clustering_coefficient(graph);
let stats = graph_level_stats(graph);
let mut node_metrics: HashMap<String, NodeMetrics> = HashMap::new();
for node in &graph.nodes {
node_metrics.insert(
node.clone(),
NodeMetrics {
betweenness: bc.get(node).copied().unwrap_or(0.0),
pagerank: pr.get(node).copied().unwrap_or(0.0),
clustering: cc.get(node).copied().unwrap_or(0.0),
},
);
}
GraphAnalysis {
label: label.to_string(),
node_count: graph.node_count(),
edge_count: graph.edge_count(),
sccs,
node_metrics,
stats,
}
}
// ── Build DiGraph from call graph / dep graph ──────────────────────
fn digraph_from_call_graph(cg: &flow::CallGraph) -> DiGraph {
let nodes = cg.functions.clone();
let mut edges = Vec::new();
for (caller, callees) in &cg.adjacency {
for callee in callees {
edges.push((caller.clone(), callee.clone()));
}
}
DiGraph::from_edges(nodes, &edges)
}
fn digraph_from_dep_analysis(da: &deps::DepAnalysis) -> DiGraph {
let nodes = da.modules.clone();
let edges: Vec<(String, String)> = da
.edges
.iter()
.map(|e| (e.from.clone(), e.to.clone()))
.collect();
DiGraph::from_edges(nodes, &edges)
}
// ── Rendering ──────────────────────────────────────────────────────
fn render_analysis(analysis: &GraphAnalysis, top_n: usize, verbose: bool) {
let term_w = render::terminal_width();
let header = format!("── {} ─", analysis.label);
let padded = format!("{:─<width$}", header, width = 56);
println!("\n{}", padded.bright_cyan().bold());
render_stats_box(analysis, verbose);
render_sccs(analysis, verbose);
render_node_table(analysis, top_n, term_w, verbose);
}
fn render_stats_box(analysis: &GraphAnalysis, verbose: bool) {
if verbose {
render::verbose_block(&[
"Graph-level statistics for the overall structure:",
" Nodes = number of functions (or modules for dependency graph).",
" Edges = total directed connections between nodes.",
" Density = edges / (nodes × (nodes - 1)). Ranges 0 to 1.",
" Low density = sparse graph; high density = many connections.",
" Components (weak) = weakly connected subgraphs (ignoring edge direction).",
" >1 component = some nodes are completely isolated from others.",
" Diameter = longest shortest path between any two reachable nodes.",
" Avg path length = mean shortest path distance across all reachable pairs.",
]);
render::guide_ref("architecture");
}
println!();
println!(" ┌──────────────────────────────────────┐");
println!(
" │ {:<18} {:>17} │",
"Nodes".cyan(),
format!("{}", analysis.node_count).bold()
);
println!(
" │ {:<18} {:>17} │",
"Edges".cyan(),
format!("{}", analysis.edge_count).bold()
);
println!(
" │ {:<18} {:>17} │",
"Density".cyan(),
format!("{:.4}", analysis.stats.density).bold()
);
println!(
" │ {:<18} {:>17} │",
"Components (weak)".cyan(),
format!("{}", analysis.stats.component_count).bold()
);
if let Some(d) = analysis.stats.diameter {
println!(
" │ {:<18} {:>17} │",
"Diameter".cyan(),
format!("{}", d).bold()
);
}
if analysis.stats.avg_path_length > 0.0 {
println!(
" │ {:<18} {:>17} │",
"Avg path length".cyan(),
format!("{:.2}", analysis.stats.avg_path_length).bold()
);
}
println!(" └──────────────────────────────────────┘");
}
fn render_sccs(analysis: &GraphAnalysis, verbose: bool) {
let cyclic_sccs: Vec<&Vec<String>> = analysis.sccs.iter().filter(|c| c.len() > 1).collect();
println!("\n {}", "Strongly Connected Components".bold().underline());
if verbose {
render::verbose_block(&[
"SCCs found by Tarjan's algorithm: groups of mutually reachable nodes (cycles).",
"Every node in an SCC can reach every other node in the same SCC.",
"A DAG (directed acyclic graph) has no cyclic SCCs — all components are singletons.",
"Cyclic clusters indicate circular dependencies that may complicate refactoring.",
]);
}
if cyclic_sccs.is_empty() {
println!(" {}", "No cyclic clusters found (DAG).".green());
} else {
println!(
" {} cyclic cluster{}:",
format!("{}", cyclic_sccs.len()).red().bold(),
if cyclic_sccs.len() == 1 { "" } else { "s" }
);
for (i, scc) in cyclic_sccs.iter().enumerate() {
println!();
println!(
" {} (size {})",
format!("Cluster #{}", i + 1).yellow().bold(),
scc.len()
);
for member in *scc {
println!(" {}", member.dimmed());
}
}
}
}
/// Render one metric section: heading, optional verbose lines, sorted bar chart.
fn render_metric_section(
label: &str,
sorted_items: &[(&String, f64)],
bar_budget: usize,
top_n: usize,
verbose: bool,
verbose_lines: &[&str],
) {
println!("\n {}", label.bold().underline());
if verbose {
render::verbose_block(verbose_lines);
}
println!();
if sorted_items.is_empty() {
println!(" {}", "No clustering detected.".dimmed());
return;
}
let max_val = sorted_items
.first()
.map(|(_, v)| *v)
.unwrap_or(1.0)
.max(f64::MIN_POSITIVE);
for (name, val) in sorted_items.iter().take(top_n) {
if *val < f64::EPSILON {
continue;
}
let bar_len = ((*val / max_val) * bar_budget as f64).ceil() as usize;
let bar = "█".repeat(bar_len.max(1));
let ratio = (*val / max_val).min(1.0);
let colored_bar = render::bar_color(&bar, 1.0 - ratio);
println!(" {:<40} {} {:.4}", name.dimmed(), colored_bar, val,);
}
}
fn render_node_table(analysis: &GraphAnalysis, top_n: usize, term_w: usize, verbose: bool) {
if analysis.node_metrics.is_empty() {
return;
}
let bar_budget = 20.min(term_w.saturating_sub(70));
// Betweenness centrality
let mut bc_sorted: Vec<(&String, f64)> = analysis
.node_metrics
.iter()
.map(|(n, m)| (n, m.betweenness))
.collect();
bc_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
render_metric_section(
"Betweenness Centrality (bridges/bottlenecks)",
&bc_sorted,
bar_budget,
top_n,
verbose,
&[
"Betweenness centrality (Brandes algorithm): for each node, the fraction of",
"all shortest paths in the graph that pass through it.",
"Scale: 0.0 = no shortest paths traverse this node; 1.0 = all paths do.",
"High bc = bridge/bottleneck — removing this node would disconnect or lengthen",
"paths between other parts of the graph. Normalized by (n-1)(n-2).",
"Bar length proportional to highest bc value. Color: red = top, green = low.",
],
);
// PageRank
let mut pr_sorted: Vec<(&String, f64)> = analysis
.node_metrics
.iter()
.map(|(n, m)| (n, m.pagerank))
.collect();
pr_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
render_metric_section(
"PageRank (recursive importance)",
&pr_sorted,
bar_budget,
top_n,
verbose,
&[
"PageRank: iterative importance score. A node is important if important nodes",
"point to it. Damping factor = 0.85, 100 iterations.",
"Dangling nodes (no outgoing edges) distribute their rank uniformly.",
"Higher value = more central/important in the call graph.",
"All PageRank values sum to 1.0 across the entire graph.",
],
);
// Clustering coefficient
let mut cc_sorted: Vec<(&String, f64)> = analysis
.node_metrics
.iter()
.map(|(n, m)| (n, m.clustering))
.filter(|(_, v)| *v > f64::EPSILON)
.collect();
cc_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
render_metric_section(
"Clustering Coefficient (neighborhood density)",
&cc_sorted,
bar_budget,
top_n,
verbose,
&[
"Clustering coefficient: for each node, the fraction of its neighbors that",
"are also connected to each other. Neighbors = union of successors and predecessors.",
"Scale: 0.0 = none of the node's neighbors know each other; 1.0 = complete clique.",
"High cc = tight neighborhood cluster. Low cc = broker between different groups.",
"Only nodes with cc > 0 are shown.",
],
);
println!();
}
// ── Public entry point ─────────────────────────────────────────────
/// Compact summary data for embedding in the dashboard.
pub struct GraphSummary {
/// Node with highest betweenness centrality (bridge).
pub top_bridge: Option<(String, f64)>,
/// Node with highest PageRank (hub).
pub top_hub: Option<(String, f64)>,
/// Node with highest clustering coefficient.
pub top_clustered: Option<(String, f64)>,
}
/// Compute compact graph summary from the call graph for dashboard use.
pub fn graph_summary(rs_files: &[PathBuf], project_path: &Path) -> Option<GraphSummary> {
let cg = flow::build_call_graph(rs_files, project_path);
if cg.functions.is_empty() {
return None;
}
let dg = digraph_from_call_graph(&cg);
let analysis = analyze_graph(&dg, "");
let top_bridge = analysis
.node_metrics
.iter()
.filter(|(_, m)| m.betweenness > f64::EPSILON)
.max_by(|a, b| {
a.1.betweenness
.partial_cmp(&b.1.betweenness)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(n, m)| (n.clone(), m.betweenness));
let top_hub = analysis
.node_metrics
.iter()
.max_by(|a, b| {
a.1.pagerank
.partial_cmp(&b.1.pagerank)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(n, m)| (n.clone(), m.pagerank));
let top_clustered = analysis
.node_metrics
.iter()
.filter(|(_, m)| m.clustering > f64::EPSILON)
.max_by(|a, b| {
a.1.clustering
.partial_cmp(&b.1.clustering)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(n, m)| (n.clone(), m.clustering));
Some(GraphSummary {
top_bridge,
top_hub,
top_clustered,
})
}
pub fn render_graph(
rs_files: &[PathBuf],
project_path: &Path,
call_only: bool,
dep_only: bool,
top_n: usize,
verbose: bool,
) {
if !dep_only {
let cg = flow::build_call_graph(rs_files, project_path);
if cg.functions.is_empty() {
println!("{}", "No functions found for call graph analysis.".yellow());
} else {
let dg = digraph_from_call_graph(&cg);
let analysis = analyze_graph(&dg, "Call Graph Analysis");
render_analysis(&analysis, top_n, verbose);
}
}
if !call_only {
let da = deps::analyze_deps(rs_files, project_path);
if da.modules.is_empty() {
println!(
"{}",
"No modules found for dependency graph analysis.".yellow()
);
} else {
let dg = digraph_from_dep_analysis(&da);
let analysis = analyze_graph(&dg, "Module Dependency Graph Analysis");
render_analysis(&analysis, top_n, verbose);
}
}
}
// ── JSON Output ────────────────────────────────────────────────────
#[derive(Serialize)]
struct NodeMetricsJson {
node: String,
betweenness: f64,
pagerank: f64,
clustering: f64,
}
#[derive(Serialize)]
struct GraphStatsJson {
density: f64,
diameter: Option<usize>,
avg_path_length: f64,
component_count: usize,
}
#[derive(Serialize)]
struct GraphAnalysisJson {
label: String,
node_count: usize,
edge_count: usize,
sccs: Vec<Vec<String>>,
node_metrics: Vec<NodeMetricsJson>,
stats: GraphStatsJson,
}
#[derive(Serialize)]
struct GraphOutputJson {
cstat_version: String,
call_graph: Option<GraphAnalysisJson>,
dep_graph: Option<GraphAnalysisJson>,
}
fn analysis_to_json(analysis: &GraphAnalysis) -> GraphAnalysisJson {
let mut node_metrics: Vec<NodeMetricsJson> = analysis
.node_metrics
.iter()
.map(|(n, m)| NodeMetricsJson {
node: n.clone(),
betweenness: m.betweenness,
pagerank: m.pagerank,
clustering: m.clustering,
})
.collect();
node_metrics.sort_by(|a, b| {
b.betweenness
.partial_cmp(&a.betweenness)
.unwrap_or(std::cmp::Ordering::Equal)
});
GraphAnalysisJson {
label: analysis.label.clone(),
node_count: analysis.node_count,
edge_count: analysis.edge_count,
sccs: analysis.sccs.clone(),
node_metrics,
stats: GraphStatsJson {
density: analysis.stats.density,
diameter: analysis.stats.diameter,
avg_path_length: analysis.stats.avg_path_length,
component_count: analysis.stats.component_count,
},
}
}
/// Render graph analysis as JSON.
pub fn render_graph_json(
rs_files: &[PathBuf],
project_path: &Path,
call_only: bool,
dep_only: bool,
) {
let call_graph_json = if !dep_only {
let cg = flow::build_call_graph(rs_files, project_path);
if cg.functions.is_empty() {
None
} else {
let dg = digraph_from_call_graph(&cg);
let analysis = analyze_graph(&dg, "Call Graph Analysis");
Some(analysis_to_json(&analysis))
}
} else {
None
};
let dep_graph_json = if !call_only {
let da = deps::analyze_deps(rs_files, project_path);
if da.modules.is_empty() {
None
} else {
let dg = digraph_from_dep_analysis(&da);
let analysis = analyze_graph(&dg, "Module Dependency Graph Analysis");
Some(analysis_to_json(&analysis))
}
} else {
None
};
let output = GraphOutputJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
call_graph: call_graph_json,
dep_graph: dep_graph_json,
};
println!("{}", serde_json::to_string(&output).unwrap());
}

467
src/guide.rs Normal file
View file

@ -0,0 +1,467 @@
use colored::Colorize;
use serde::Serialize;
// ── Topic definitions ──────────────────────────────────────────────
pub const TOPICS: &[(&str, &str)] = &[
(
"getting-started",
"First steps: reading your codebase with cstat",
),
("size", "File and function size, LoC distribution"),
(
"complexity",
"Cyclomatic, cognitive, nesting, parameter metrics",
),
(
"modularity",
"Coupling, cohesion, dependency structure, circuits",
),
("flow", "Call graph, reachability, dead code, hot paths"),
("redundancy", "Similar and duplicate function detection"),
(
"architecture",
"Centrality, SCCs, hubs, tiers, graph topology",
),
];
struct TopicContent {
description: &'static str,
metrics: &'static [(&'static str, &'static str)],
commands: &'static [&'static str],
patterns: &'static [&'static str],
}
fn topic_getting_started() -> TopicContent {
TopicContent {
description: "\
cstat analyzes Rust codebases along several dimensions — size, complexity, \
dependency structure, call flow, and duplication — then surfaces observations \
about what it finds. Start with `cstat summary` for a human-readable dashboard, \
or `cstat dump` for a machine-readable JSON diagnostic report.",
metrics: &[
("summary dashboard", "Compact overview of all dimensions with key stats"),
("dump report", "Full JSON diagnostic with health scores, observations, hotspots, and topology"),
("health scores", "Normalized 0.0–1.0 scores for modularity, complexity, and maintainability (higher = healthier)"),
],
commands: &[
"cstat summary — human-readable dashboard (default command)",
"cstat summary -v — dashboard with educational explanations",
"cstat dump — machine-readable JSON diagnostic report",
"cstat <command> --json — structured JSON output for any command",
],
patterns: &[
"Human workflow: run summary first, read the dashboard, drill into specific \
dimensions with loc/branching/signature/span/deps/flow/graph as needed.",
"Agent workflow: run dump for the full diagnostic picture, read scores to \
identify weak dimensions, use individual commands with --json to explore specifics.",
"The -v (verbose) flag adds contextual explanations to any command — useful \
when learning what the metrics mean.",
],
}
}
fn topic_size() -> TopicContent {
TopicContent {
description: "\
Size metrics measure the volume and distribution of Rust source. `cstat loc` owns the line-count contract; run `cstat loc --explain` for modes, code_lines rules, and JSON fields.",
metrics: &[
(
"file size shape",
"Project-level file-size distribution from `cstat loc --path .`.",
),
(
"selected-file line reachability",
"Projected static reachable/unreachable production spans from `cstat loc --path <file.rs>`.",
),
(
"distribution statistics",
"Mean, median, std_dev, min, and max summarize project file sizes.",
),
],
commands: &[
"cstat loc --explain — loc modes, code_lines rules, and JSON field contract",
"cstat loc --path . — project size-shape report",
"cstat loc --path src/lib.rs — selected-file projected line reachability",
"cstat dist --metric loc — histogram and outlier analysis of LoC distribution",
],
patterns: &[
"Use `cstat loc --explain` as the canonical reference before consuming loc output.",
"A right-skewed project-mode code_lines distribution means a few files dominate size.",
"Selected-file loc is a static projection from function spans and test/benchmark reachability; do not read it as runtime coverage.",
],
}
}
fn topic_complexity() -> TopicContent {
TopicContent {
description: "\
Complexity metrics are split by question: branching for decision/path shape, \
signature for API-boundary difficulty, and span for function-local implementation \
size. No single metric captures the full picture — cyclomatic counts paths, \
cognitive models reading difficulty in diagnostics/distributions, nesting measures \
structural depth, and parameter count reflects interface width.",
metrics: &[
("cyclomatic complexity", "Number of linearly independent paths through a function (branch_points + 1). Higher values mean more test cases needed for full coverage."),
("cognitive complexity", "SonarSource model: +1 per control flow break, +nesting_level penalty. Used by diagnostics/distributions, not exposed as a top-level probe."),
("nesting depth", "Maximum depth of nested control structures. Deep nesting forces readers to hold more context in working memory."),
("parameter count", "Number of function parameters. Wide interfaces are harder to call correctly and may indicate a function doing too much."),
("function span", "Inclusive function-local line range and top-level body statement count. This complements file/project LOC."),
],
commands: &[
"cstat branching — decision/path complexity by function",
"cstat signature — API-boundary complexity by function and file",
"cstat span — function-local implementation size and line ranges",
"cstat advanced dist --metric cyclomatic — distribution and outlier analysis",
],
patterns: &[
"High cyclomatic but low cognitive complexity usually means straightforward \
branching (e.g. match statements with simple arms). High cognitive with \
moderate cyclomatic often means nested conditionals.",
"Functions with nesting depth > 4 are nearly always worth refactoring — \
extract inner blocks into helper functions to flatten the structure.",
"Use branching, signature, and span together: a function can be easy to call \
but branch-heavy, short but hard to satisfy generically, or long without many decisions.",
],
}
}
fn topic_modularity() -> TopicContent {
TopicContent {
description: "\
Modularity metrics describe the dependency structure between modules — how \
tightly they are coupled to each other and how cohesive each module is \
internally. Good modularity means changes tend to stay local rather than \
rippling across the codebase.",
metrics: &[
("fan-in", "Number of modules that depend on this module. High fan-in modules are foundational — changes to them have wide impact."),
("fan-out", "Number of modules this module depends on. High fan-out may indicate a module that coordinates too many concerns."),
("bidirectional coupling", "Module pairs that depend on each other. Often a sign that the modules should be merged or the interface redesigned."),
("call cohesion", "Fraction of a module's functions that call at least one other function in the same module. Low values suggest the module groups unrelated functionality."),
("type cohesion", "Fraction of functions sharing parameter or return types with siblings. Measures data-level relatedness."),
("modularity Q", "Graph-theoretic partition quality from community detection. Higher values indicate well-separated clusters."),
],
commands: &[
"cstat deps — module dependency graph with coupling and cohesion",
"cstat deps --json — structured dependency and cohesion data",
"cstat circuits — community detection showing functional clusters",
"cstat circuits --json — structured circuit membership data",
],
patterns: &[
"Bidirectional coupling pairs are the highest-priority modularity issue. \
They create change amplification — modifying either module risks breaking \
the other.",
"A module with high fan-in AND high fan-out is a potential god module — it \
both serves many consumers and depends on many providers.",
"Low cohesion (< 0.3) combined with large file size strongly suggests the \
module bundles unrelated concerns. Check if its functions form distinct \
clusters using `cstat circuits`.",
],
}
}
fn topic_flow() -> TopicContent {
TopicContent {
description: "\
Flow analysis maps the call graph — which functions call which others — and \
uses it to identify entry points, dead code, hot paths, and structural \
properties of execution flow. This is static analysis; it shows what *can* \
be called, not runtime frequency.",
metrics: &[
("entry points", "Functions reachable as starting points: main() and #[test] functions."),
("reachable functions", "Functions reachable from any entry point via the call graph."),
("cold functions", "Functions with zero incoming calls. May be dead code, or may be entry points for external consumers."),
("max call depth", "Longest chain of function calls. Very deep chains can indicate over-decomposition or recursion."),
("coverage", "Percentage of functions reachable from entry points. Low coverage means much of the code may be unused."),
("hot paths", "Paths through the call graph that visit the most functions. Shows the main execution spine."),
],
commands: &[
"cstat flow — call graph edges, entry points, cycles, cold functions",
"cstat flow --json — structured call graph data",
"cstat test-reachability — static test/benchmark reachability",
"cstat test-reachability --json — structured test/benchmark reachability data",
"cstat advanced flow-heatmap — legacy random-walk heatmap",
"cstat advanced flow-heatmap --json — structured visit counts and hot paths",
],
patterns: &[
"Cold functions that are not pub items or test helpers are likely dead code. \
Verify by checking if they appear in the module's public interface.",
"Cycles in the call graph (recursive or mutually recursive functions) are \
worth noting — they make reasoning about termination harder and can cause \
stack overflows.",
"Low coverage (< 70%) may indicate modules that are libraries consumed \
externally, or it may indicate accumulated dead code.",
],
}
}
fn topic_redundancy() -> TopicContent {
TopicContent {
description: "\
Redundancy detection identifies functions that are structurally or \
signature-similar to each other. Duplicated logic is a maintenance burden — \
bug fixes need to be applied in multiple places, and divergent copies create \
subtle inconsistencies.",
metrics: &[
("signature similarity", "Compares function name, parameter types, and return type. High similarity suggests functions that evolved from copy-paste."),
("structural similarity", "Normalizes function ASTs (strips identifiers, collapses literals) and compares hashes. Catches duplicates even when variable names differ."),
("similarity score", "Combined score from 0.0 to 1.0. Above 0.7 is suspicious; above 0.9 is near-certain duplication."),
("similarity kind", "Whether the match is by signature, structure, or both. 'Both' matches are the strongest signal."),
],
commands: &[
"cstat redundancy — table of suspected duplicate function pairs",
"cstat redundancy --json — structured similarity data for all pairs",
],
patterns: &[
"Structural duplicates with different names often indicate utility functions \
that were independently implemented in different modules. Consider extracting \
to a shared location.",
"Signature-only matches (same parameter/return types, similar names) may be \
intentional polymorphism or may indicate an interface that should be a trait.",
"A high count of redundant pairs in a single module suggests the module grew \
by accretion rather than design.",
],
}
}
fn topic_architecture() -> TopicContent {
TopicContent {
description: "\
Architecture metrics apply graph theory to the call graph and dependency \
graph to reveal structural properties invisible at the function level — \
bridges, hubs, clusters, and overall connectivity patterns. These metrics \
describe the shape of the codebase.",
metrics: &[
("betweenness centrality", "How often a node lies on shortest paths between other nodes. High-betweenness nodes are bridges — their removal disconnects the graph."),
("PageRank", "Recursive importance: a node is important if important nodes point to it. Identifies the most depended-upon functions."),
("SCCs (strongly connected components)", "Groups of nodes where every node can reach every other. In a call graph, these are mutual recursion groups. In a dependency graph, these are cyclic dependency clusters."),
("clustering coefficient", "How densely connected a node's neighbors are to each other. High values mean tight local clusters; low values mean the node bridges separate groups."),
("graph density", "Ratio of actual edges to possible edges. Very low density means a sparse, tree-like structure; higher density means more interconnection."),
],
commands: &[
"cstat graph — SCC analysis, centrality, PageRank, clustering",
"cstat graph --json — structured graph metrics",
"cstat graph --call-only — analyze only the call graph",
"cstat graph --dep-only — analyze only the module dependency graph",
"cstat map — architectural tier visualization",
],
patterns: &[
"High-betweenness nodes are refactoring leverage points: splitting them can \
decouple large portions of the codebase.",
"Large SCCs in the dependency graph indicate tightly coupled module groups. \
These tend to grow over time as dependencies accumulate.",
"A hub (high PageRank + high fan-out) that is also a bridge (high betweenness) \
is a critical risk point — it is both heavily depended upon and structurally \
load-bearing.",
],
}
}
fn topic_content(name: &str) -> Option<TopicContent> {
match name {
"getting-started" => Some(topic_getting_started()),
"size" => Some(topic_size()),
"complexity" => Some(topic_complexity()),
"modularity" => Some(topic_modularity()),
"flow" => Some(topic_flow()),
"redundancy" => Some(topic_redundancy()),
"architecture" => Some(topic_architecture()),
_ => None,
}
}
// ── JSON serialization ─────────────────────────────────────────────
#[derive(Serialize)]
struct GuideTocJson {
cstat_version: String,
topics: Vec<GuideTopicEntry>,
}
#[derive(Serialize)]
struct GuideTopicEntry {
name: String,
summary: String,
}
#[derive(Serialize)]
struct GuideTopicJson {
cstat_version: String,
topic: String,
description: String,
metrics: Vec<GuideMetricJson>,
commands: Vec<String>,
patterns: Vec<String>,
}
#[derive(Serialize)]
struct GuideMetricJson {
name: String,
description: String,
}
// ── Public interface ───────────────────────────────────────────────
/// Render the guide table of contents (no topic specified).
pub fn render_guide_toc(json: bool) {
if json {
render_guide_toc_json();
} else {
render_guide_toc_human();
}
}
/// Render a specific guide topic.
pub fn render_guide_topic(topic: &str, json: bool) {
if json {
render_guide_topic_json(topic);
} else {
render_guide_topic_human(topic);
}
}
// ── Human-readable rendering ───────────────────────────────────────
fn render_guide_toc_human() {
println!();
println!(
"{}",
"cstat guide — code health reference".bright_cyan().bold()
);
println!();
for (name, summary) in TOPICS {
println!(" {:<18}{}", name.bold(), summary);
}
println!();
println!("Run: {}", "cstat guide <topic>".white().bold());
println!();
}
fn render_guide_topic_human(name: &str) {
let content = match topic_content(name) {
Some(c) => c,
None => {
eprintln!("Unknown guide topic: {}", name);
eprintln!();
eprintln!("Available topics:");
for (t, _) in TOPICS {
eprintln!(" {}", t);
}
std::process::exit(1);
}
};
let topic_summary = TOPICS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, s)| *s)
.unwrap_or("");
println!();
println!(
"{} — {}",
format!("cstat guide {}", name).bright_cyan().bold(),
topic_summary
);
// Description
println!();
for line in content.description.lines() {
println!(" {}", line);
}
// Metrics
if !content.metrics.is_empty() {
println!();
println!("{}", " Key metrics:".bold());
for (metric, desc) in content.metrics {
println!();
println!(" {}", metric.yellow().bold());
// Wrap description text
for line in desc.lines() {
println!(" {}", line);
}
}
}
// Commands
if !content.commands.is_empty() {
println!();
println!("{}", " Relevant commands:".bold());
for cmd in content.commands {
println!(" {}", cmd.dimmed());
}
}
// Patterns
if !content.patterns.is_empty() {
println!();
println!("{}", " Patterns worth noticing:".bold());
for pattern in content.patterns {
println!();
print!(" • ");
// Simple word-wrap at ~72 chars for readability
let words: Vec<&str> = pattern.split_whitespace().collect();
let mut col = 6; // indent of " • "
for (i, word) in words.iter().enumerate() {
if i > 0 && col + 1 + word.len() > 76 {
println!();
print!(" ");
col = 6;
} else if i > 0 {
print!(" ");
col += 1;
}
print!("{}", word);
col += word.len();
}
println!();
}
}
println!();
}
// ── JSON rendering ─────────────────────────────────────────────────
fn render_guide_toc_json() {
let output = GuideTocJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
topics: TOPICS
.iter()
.map(|(name, summary)| GuideTopicEntry {
name: name.to_string(),
summary: summary.to_string(),
})
.collect(),
};
println!("{}", serde_json::to_string(&output).unwrap());
}
fn render_guide_topic_json(name: &str) {
let content = match topic_content(name) {
Some(c) => c,
None => {
let err = serde_json::json!({"error": format!("Unknown guide topic: {}", name)});
println!("{}", serde_json::to_string(&err).unwrap());
std::process::exit(1);
}
};
let output = GuideTopicJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
topic: name.to_string(),
description: content.description.to_string(),
metrics: content
.metrics
.iter()
.map(|(n, d)| GuideMetricJson {
name: n.to_string(),
description: d.to_string(),
})
.collect(),
commands: content.commands.iter().map(|s| s.to_string()).collect(),
patterns: content.patterns.iter().map(|s| s.to_string()).collect(),
};
println!("{}", serde_json::to_string(&output).unwrap());
}

View file

@ -1,17 +1,27 @@
mod ast_parser;
mod call_trace;
mod circuits;
mod cluster;
mod complexity;
mod coverage;
mod datapaths;
mod dead_code;
mod deps;
mod diagnostics;
mod discovery;
mod dist;
mod dump;
mod file_metrics;
mod file_summary;
mod flow;
mod graph;
mod guide;
mod loc;
mod map;
mod redundancy;
mod render;
mod scorecard;
mod report;
mod summary;
mod symbols;
use clap::{Parser, Subcommand};
@ -21,7 +31,7 @@ use std::path::PathBuf;
#[command(name = "cstat", about = "Rust codebase complexity analyzer")]
struct Cli {
#[command(subcommand)]
command: Commands,
command: Option<Commands>,
/// Rust project directory or Rust source file to analyze (defaults to current directory)
#[arg(long, default_value = ".", global = true)]
@ -42,6 +52,10 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Focused module metrics report (default)
Summary,
/// Alias for the focused module metrics report
Report,
#[command(
about = "Lines-of-code size-shape analysis",
long_about = r#"Lines-of-code size-shape analysis for Rust source.
@ -85,12 +99,6 @@ Use --explain to print this usage and JSON field contract without running analys
Signature,
/// Function implementation span rankings
Span,
/// Deterministic structural code complexity scorecard
Scorecard {
/// Show only the top N contributors
#[arg(long)]
top: Option<usize>,
},
/// Module dependency connectome
Deps {
/// Show only the top N most connected modules
@ -115,6 +123,66 @@ Use --explain to print this usage and JSON field contract without running analys
},
/// Value-cluster transcript from AST def-use structure.
Cluster,
/// Opt-in legacy and exploratory analyses
Advanced {
#[command(subcommand)]
command: AdvancedCommands,
},
}
#[derive(Subcommand)]
enum AdvancedCommands {
/// Previous broad dashboard overview
Summary,
/// Bird's eye module map — architectural overview
Map,
/// Static call graph / execution flow analysis
Flow,
/// Graph-theoretic analysis (SCCs, centrality, PageRank, clustering)
Graph {
/// Analyze only the call graph
#[arg(long)]
call_only: bool,
/// Analyze only the module dependency graph
#[arg(long)]
dep_only: bool,
/// Show only the top N nodes per metric
#[arg(long, default_value = "10")]
top: usize,
},
/// Previous random-walk execution flow heatmap
FlowHeatmap {
/// Number of random walks to simulate
#[arg(long, default_value = "10000")]
walks: usize,
/// Start from a specific function instead of all entry points
#[arg(long, name = "FUNCTION")]
from: Option<String>,
},
/// Distribution visualizations (histograms, scatter, outliers, correlations)
Dist {
/// Show only a single metric (loc, cyclomatic, cognitive, nesting, params)
#[arg(long)]
metric: Option<String>,
/// Number of histogram bins
#[arg(long, default_value = "10")]
bins: usize,
},
/// Functional cluster decomposition via community detection
Circuits {
/// Minimum circuit size to display
#[arg(long, default_value = "1")]
min_size: usize,
},
/// Detect suspected duplicate or near-duplicate functions
Redundancy,
/// Machine-readable diagnostic summary (always JSON)
Dump,
/// Code health reference — topic-based workflow and metric guide
Guide {
/// Topic to display (e.g. getting-started, size, complexity, modularity, flow, redundancy, architecture)
topic: Option<String>,
},
}
fn main() {
@ -127,13 +195,12 @@ fn main() {
}
let verbose = cli.verbose;
let command = cli.command;
let command = cli.command.unwrap_or(Commands::Summary);
if let Commands::Loc { explain: true, .. } = &command {
loc::render_loc_explain(json);
return;
}
let target = match discovery::resolve_target(&cli.path) {
Ok(target) => target,
Err(e) => {
@ -153,6 +220,18 @@ fn main() {
let project_rs_files = discovery::files_for_project(&target);
let target_rs_files = discovery::files_for_target(&target);
match command {
Commands::Summary | Commands::Report => {
if json {
file_summary::render_file_summary_json(project_rs_files, project_path, file);
} else {
file_summary::render_file_summary(
project_rs_files,
project_path,
file,
verbose,
);
}
}
Commands::Loc { .. } => {
if json {
loc::render_loc_file_json(project_rs_files, project_path, file);
@ -183,16 +262,6 @@ fn main() {
cluster::render_cluster_file(project_rs_files, project_path, file, verbose);
}
}
Commands::Scorecard { top } => {
scorecard::render_scorecard(
target_rs_files,
project_path,
&cli.path.display().to_string(),
top,
json,
verbose,
);
}
Commands::Deps { .. } => {
if json {
deps::render_deps_file_json(project_rs_files, project_path, file);
@ -248,7 +317,6 @@ fn main() {
Some(file),
no_run,
json,
verbose,
) {
if json {
let err = serde_json::json!({"error": message});
@ -259,11 +327,29 @@ fn main() {
std::process::exit(1);
}
}
Commands::Advanced { .. } => {
let message =
"advanced commands require a project directory; pass --path <crate-dir>";
if json {
let err = serde_json::json!({"error": message});
println!("{}", serde_json::to_string(&err).unwrap());
} else {
eprintln!("Error: {}", message);
}
std::process::exit(1);
}
}
return;
}
match command {
Commands::Summary | Commands::Report => {
if json {
report::render_report_json(&rs_files, &project_path);
} else {
report::render_report(&rs_files, &project_path, verbose);
}
}
Commands::Loc { top, .. } => {
if json {
loc::render_loc_json(&rs_files, &project_path);
@ -287,16 +373,6 @@ fn main() {
Commands::Span => {
complexity::run_span(&rs_files, &project_path, json, verbose);
}
Commands::Scorecard { top } => {
scorecard::render_scorecard(
&rs_files,
&project_path,
&cli.path.display().to_string(),
top,
json,
verbose,
);
}
Commands::Cluster => {
cluster::render_cluster(&rs_files, &project_path, json, verbose);
}
@ -336,7 +412,7 @@ fn main() {
}
Commands::Coverage { no_run } => {
if let Err(message) =
coverage::render_coverage(&rs_files, &project_path, None, no_run, json, verbose)
coverage::render_coverage(&rs_files, &project_path, None, no_run, json)
{
if json {
let err = serde_json::json!({"error": message});
@ -347,5 +423,93 @@ fn main() {
std::process::exit(1);
}
}
Commands::Advanced { command } => match command {
AdvancedCommands::Summary => {
if json {
summary::render_summary_json(&rs_files, &project_path);
} else {
summary::render_summary(&rs_files, &project_path, verbose);
}
}
AdvancedCommands::Map => {
if json {
map::render_map_json(&rs_files, &project_path);
} else {
map::render_map(&rs_files, &project_path, verbose);
}
}
AdvancedCommands::Flow => {
if json {
flow::render_flow_json(&rs_files, &project_path);
} else {
flow::render_flow(&rs_files, &project_path, verbose);
}
}
AdvancedCommands::Graph {
call_only,
dep_only,
top,
} => {
if json {
graph::render_graph_json(&rs_files, &project_path, call_only, dep_only);
} else {
graph::render_graph(
&rs_files,
&project_path,
call_only,
dep_only,
top,
verbose,
);
}
}
AdvancedCommands::FlowHeatmap { walks, from } => {
if json {
datapaths::render_datapaths_json(
&rs_files,
&project_path,
walks,
from.as_deref(),
);
} else {
datapaths::render_datapaths(
&rs_files,
&project_path,
walks,
from.as_deref(),
verbose,
);
}
}
AdvancedCommands::Dist { metric, bins } => {
if json {
dist::render_dist_json(&rs_files, &project_path, metric.as_deref(), bins);
} else {
dist::render_dist(&rs_files, &project_path, metric.as_deref(), bins, verbose);
}
}
AdvancedCommands::Circuits { min_size } => {
if json {
circuits::render_circuits_json(&rs_files, &project_path, min_size);
} else {
circuits::render_circuits(&rs_files, &project_path, min_size, verbose);
}
}
AdvancedCommands::Redundancy => {
if json {
redundancy::render_redundancy_json(&rs_files, &project_path);
} else {
redundancy::render_redundancy(&rs_files, &project_path, verbose);
}
}
AdvancedCommands::Dump => {
colored::control::set_override(false);
dump::render_dump(&rs_files, &project_path);
}
AdvancedCommands::Guide { topic } => match topic {
Some(t) => guide::render_guide_topic(&t, json),
None => guide::render_guide_toc(json),
},
},
}
}

867
src/map.rs Normal file
View file

@ -0,0 +1,867 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use crate::ast_parser::{self, ProjectSymbols};
use crate::complexity;
use crate::deps;
use crate::loc;
use crate::render;
/// Information about a module for the map view.
struct ModuleBox {
name: String,
structs: Vec<String>,
enums: Vec<String>,
traits: Vec<String>,
functions: Vec<String>,
pub_count: usize,
total_count: usize,
code_lines: usize,
/// Per-function complexity scores (for heatbar).
func_scores: Vec<f64>,
/// Total connectivity (in-degree + out-degree).
connectivity: usize,
}
/// An inter-tier connection.
struct TierEdge {
from_tier: usize,
to_tier: usize,
from_mod: String,
to_mod: String,
}
/// Print the tier header separator line.
fn render_tier_header(tier_idx: usize, max_tier: usize, term_w: usize) {
let tier_label = match tier_idx {
0 => "Entry Points".to_string(),
t if t == max_tier && max_tier > 0 => "Leaf Modules".to_string(),
t => format!("Layer {}", t),
};
let separator = format!(
"╶╶╶ {} {}",
tier_label,
"╶".repeat(term_w.saturating_sub(tier_label.len()).saturating_sub(6))
);
println!("\n{}", separator.dimmed());
}
/// Render the module boxes for a single tier in a side-by-side layout.
fn render_tier_modules(
group: &[String],
module_boxes: &HashMap<String, ModuleBox>,
hub_threshold: usize,
max_loc: usize,
term_w: usize,
) {
let box_min_width = 30;
let max_side_by_side = (term_w / box_min_width).max(1);
let mut i = 0;
while i < group.len() {
let row_end = (i + max_side_by_side).min(group.len());
let row = &group[i..row_end];
let col_count = row.len();
let col_width = if col_count > 1 {
(term_w / col_count).min(term_w).max(box_min_width)
} else {
term_w.min(80).max(box_min_width)
};
let mut box_lines: Vec<Vec<String>> = Vec::new();
for mod_name in row {
if let Some(mbox) = module_boxes.get(mod_name.as_str()) {
let is_hub = mbox.connectivity >= hub_threshold && hub_threshold > 0;
let height = proportional_height(mbox.code_lines, max_loc);
let lines = render_module_box(mbox, col_width.saturating_sub(1), is_hub, height);
box_lines.push(lines);
}
}
let max_lines = box_lines.iter().map(|b| b.len()).max().unwrap_or(0);
for line_idx in 0..max_lines {
let mut row_str = String::new();
for (col_idx, lines) in box_lines.iter().enumerate() {
let line = if line_idx < lines.len() {
&lines[line_idx]
} else {
""
};
let vis_len = render::visible_len(line);
let pad = col_width.saturating_sub(1).saturating_sub(vis_len);
row_str.push_str(line);
row_str.push_str(&" ".repeat(pad));
if col_idx < box_lines.len() - 1 {
row_str.push(' ');
}
}
println!("{}", row_str);
}
i = row_end;
}
}
/// Render weighted dependency arrows from the given tier to deeper tiers.
fn render_tier_arrows(tier_edges: &[TierEdge], tier_idx: usize) {
let arrows_from_tier: Vec<&TierEdge> = tier_edges
.iter()
.filter(|e| e.from_tier == tier_idx && e.to_tier > tier_idx)
.collect();
if arrows_from_tier.is_empty() {
return;
}
let mut arrow_groups: HashMap<(&str, &str), usize> = HashMap::new();
for e in &arrows_from_tier {
*arrow_groups
.entry((e.from_mod.as_str(), e.to_mod.as_str()))
.or_insert(0) += 1;
}
let mut arrow_list: Vec<((&str, &str), usize)> = arrow_groups.into_iter().collect();
arrow_list.sort_by(|a, b| b.1.cmp(&a.1).then(a.0 .0.cmp(b.0 .0)));
let show_count = arrow_list.len().min(6);
let mut arrow_strs: Vec<String> = Vec::new();
for ((from, to), count) in arrow_list.iter().take(show_count) {
let weight_str = if *count > 1 {
format!("({})", count)
} else {
String::new()
};
arrow_strs.push(format!(
"{}→{}{}",
from.bright_blue(),
to.bright_blue(),
weight_str.dimmed()
));
}
if arrow_list.len() > show_count {
arrow_strs.push(
format!("+{} more", arrow_list.len() - show_count)
.dimmed()
.to_string(),
);
}
println!(" {} {}", "───▶".dimmed(), arrow_strs.join(" "));
}
/// Prepared data for rendering the module map.
struct MapRenderData {
module_boxes: HashMap<String, ModuleBox>,
tier_groups: Vec<Vec<String>>,
tier_edges: Vec<TierEdge>,
max_loc: usize,
hub_threshold: usize,
max_tier: usize,
}
/// Gather and prepare all data needed to render the module map.
fn prepare_map_data(
rs_files: &[PathBuf],
project_path: &Path,
symbols: &ProjectSymbols,
dep_analysis: &deps::DepAnalysis,
) -> Option<MapRenderData> {
// Get LoC data per file
let (file_stats, _) = loc::analyze_files(rs_files);
let mut loc_by_mod: HashMap<String, usize> = HashMap::new();
for stat in &file_stats {
let mod_name = render::module_name_from_path(&stat.path, project_path);
loc_by_mod.insert(mod_name, stat.code_lines);
}
// Get complexity data
let func_complexities = complexity::compute_all(symbols, project_path);
let mut complexity_by_mod: HashMap<String, Vec<f64>> = HashMap::new();
for fc in &func_complexities {
let mod_name = file_to_module_name(&fc.file);
complexity_by_mod
.entry(mod_name)
.or_default()
.push(fc.composite_score);
}
// Build module boxes
let module_boxes = build_module_boxes(
symbols,
project_path,
&loc_by_mod,
&complexity_by_mod,
dep_analysis,
);
if module_boxes.is_empty() {
return None;
}
// Compute tier assignments via dependency depth (BFS from roots)
let tiers = compute_tiers(&module_boxes, dep_analysis);
// Group modules by tier
let max_tier = tiers.values().copied().max().unwrap_or(0);
let mut tier_groups: Vec<Vec<String>> = vec![Vec::new(); max_tier + 1];
for (name, &tier) in &tiers {
tier_groups[tier].push(name.clone());
}
// Sort modules within each tier by LoC descending
for group in &mut tier_groups {
group.sort_by(|a, b| {
let a_loc = module_boxes
.get(a.as_str())
.map(|m| m.code_lines)
.unwrap_or(0);
let b_loc = module_boxes
.get(b.as_str())
.map(|m| m.code_lines)
.unwrap_or(0);
b_loc.cmp(&a_loc).then(a.cmp(b))
});
}
// Collect inter-tier edges
let mut tier_edges: Vec<TierEdge> = Vec::new();
for edge in &dep_analysis.edges {
let from_tier = tiers.get(edge.from.as_str()).copied().unwrap_or(0);
let to_tier = tiers.get(edge.to.as_str()).copied().unwrap_or(0);
tier_edges.push(TierEdge {
from_tier,
to_tier,
from_mod: edge.from.clone(),
to_mod: edge.to.clone(),
});
}
// Find max LoC for proportional sizing
let max_loc = module_boxes
.values()
.map(|m| m.code_lines)
.max()
.unwrap_or(1)
.max(1);
// Compute connectivity threshold for hub detection
let connectivities: Vec<usize> = module_boxes.values().map(|m| m.connectivity).collect();
let mean_conn = if connectivities.is_empty() {
0.0
} else {
connectivities.iter().sum::<usize>() as f64 / connectivities.len() as f64
};
let hub_threshold = (mean_conn + 1.0) as usize;
Some(MapRenderData {
module_boxes,
tier_groups,
tier_edges,
max_loc,
hub_threshold,
max_tier,
})
}
/// Render the bird's eye module map.
pub fn render_map(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
let term_w = render::terminal_width();
let symbols = ast_parser::parse_project(rs_files);
let dep_analysis = deps::analyze_deps(rs_files, project_path);
println!(
"\n{}",
"── Architectural Module Map ───────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Bird's-eye module map arranged by dependency depth (BFS from roots).",
"Tier 0 = Entry Points (zero in-degree — nothing depends on them).",
"Deepest tier = Leaf Modules (only depended upon, never import others).",
"Inside each box: S = structs, E = enums, T = traits, f = functions.",
" Numbers after the module name: LoC count and pub/total item ratio.",
" ● (white) in the legend = public items (pub fn, pub struct, etc.).",
"Heatbar (▁▃▅▇) = per-function complexity distribution within the module:",
" green (▁) = low complexity, yellow (▅) = medium, red (▇) = high.",
"Border style: ╔ (double-line, bold) = hub module (connectivity > mean+1σ).",
" ┌ (single-line, dimmed) = regular or leaf module.",
"Arrows (───▶) show cross-tier dependency flow between modules.",
" Arrow weight in parentheses = number of dependency edges along that path.",
]);
render::guide_ref("architecture");
}
if symbols.files.is_empty() {
println!(" {}", "No modules found.".dimmed());
return;
}
let data = match prepare_map_data(rs_files, project_path, &symbols, &dep_analysis) {
Some(d) => d,
None => {
println!(" {}", "No modules found.".dimmed());
return;
}
};
// Render tier by tier (top = tier 0 = entry points, bottom = leaves)
for tier_idx in 0..=data.max_tier {
let group = &data.tier_groups[tier_idx];
if group.is_empty() {
continue;
}
render_tier_header(tier_idx, data.max_tier, term_w);
render_tier_modules(
group,
&data.module_boxes,
data.hub_threshold,
data.max_loc,
term_w,
);
render_tier_arrows(&data.tier_edges, tier_idx);
}
// Legend
println!();
println!(
" {} struct {} enum {} trait {} fn {} pub {} hub {} leaf",
"S".bright_green().bold(),
"E".bright_yellow().bold(),
"T".bright_magenta().bold(),
"f".bright_blue(),
"●".white().bold(),
"╔".bold(),
"┌".dimmed(),
);
println!(
" {} complexity heatbar: {} low {} med {} high",
"▁▃▅▇".dimmed(),
"▁".green(),
"▅".yellow(),
"▇".red(),
);
println!();
}
/// Convert a file path like "src/foo.rs" to module name "foo".
fn file_to_module_name(file: &str) -> String {
let s = file.strip_suffix(".rs").unwrap_or(file);
let s = s.replace('/', "::");
let s = s.strip_prefix("src::").unwrap_or(&s);
if s.ends_with("::mod") {
s.strip_suffix("::mod").unwrap_or(s).to_string()
} else {
s.to_string()
}
}
/// Build module boxes with LoC, complexity, and connectivity data.
fn build_module_boxes(
symbols: &ProjectSymbols,
project_path: &Path,
loc_by_mod: &HashMap<String, usize>,
complexity_by_mod: &HashMap<String, Vec<f64>>,
dep_analysis: &deps::DepAnalysis,
) -> HashMap<String, ModuleBox> {
let mut boxes: HashMap<String, ModuleBox> = HashMap::new();
for file_syms in &symbols.files {
let mod_name = render::module_name_from_path(&file_syms.path, project_path);
let structs: Vec<String> = file_syms.structs.iter().map(|s| s.name.clone()).collect();
let enums: Vec<String> = file_syms.enums.iter().map(|e| e.name.clone()).collect();
let traits: Vec<String> = file_syms.traits.iter().map(|t| t.name.clone()).collect();
let functions: Vec<String> = file_syms
.functions
.iter()
.filter(|f| !f.name.contains("::"))
.map(|f| f.name.clone())
.collect();
let total = structs.len() + enums.len() + traits.len() + functions.len();
let pub_count = count_pub_items(&file_syms.path);
let code_lines = loc_by_mod.get(&mod_name).copied().unwrap_or(0);
let func_scores = complexity_by_mod
.get(&mod_name)
.cloned()
.unwrap_or_default();
let in_d = dep_analysis.in_degree.get(&mod_name).copied().unwrap_or(0);
let out_d = dep_analysis.out_degree.get(&mod_name).copied().unwrap_or(0);
let connectivity = in_d + out_d;
boxes.insert(
mod_name.clone(),
ModuleBox {
name: mod_name,
structs,
enums,
traits,
functions,
pub_count,
total_count: total,
code_lines,
func_scores,
connectivity,
},
);
}
boxes
}
/// Count `pub fn`, `pub struct`, `pub enum`, `pub trait` in source file.
fn count_pub_items(path: &Path) -> usize {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return 0,
};
let ast = match syn::parse_file(&content) {
Ok(f) => f,
Err(_) => return 0,
};
let mut count = 0;
for item in &ast.items {
match item {
syn::Item::Fn(f) => {
if matches!(f.vis, syn::Visibility::Public(_)) {
count += 1;
}
}
syn::Item::Struct(s) => {
if matches!(s.vis, syn::Visibility::Public(_)) {
count += 1;
}
}
syn::Item::Enum(e) => {
if matches!(e.vis, syn::Visibility::Public(_)) {
count += 1;
}
}
syn::Item::Trait(t) => {
if matches!(t.vis, syn::Visibility::Public(_)) {
count += 1;
}
}
_ => {}
}
}
count
}
/// Compute dependency depth tiers via BFS from root modules (zero in-degree).
fn compute_tiers(
module_boxes: &HashMap<String, ModuleBox>,
dep_analysis: &deps::DepAnalysis,
) -> HashMap<String, usize> {
let mut tiers: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<String> = VecDeque::new();
// Build adjacency list: from -> [to] (follows dependency direction)
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &dep_analysis.edges {
adj.entry(edge.from.as_str())
.or_default()
.push(edge.to.as_str());
}
// Start from modules with zero in-degree (entry points / roots)
for name in module_boxes.keys() {
let in_d = dep_analysis.in_degree.get(name).copied().unwrap_or(0);
if in_d == 0 {
tiers.insert(name.clone(), 0);
queue.push_back(name.clone());
}
}
// If no roots found (all cycles), start everyone at tier 0
if queue.is_empty() {
for name in module_boxes.keys() {
tiers.insert(name.clone(), 0);
}
return tiers;
}
// BFS: propagate max depth. Limit iterations to avoid infinite loops on cycles.
let max_iterations = module_boxes.len() * module_boxes.len() + 1;
let mut iterations = 0;
while let Some(current) = queue.pop_front() {
iterations += 1;
if iterations > max_iterations {
break;
}
let current_tier = tiers[&current];
if let Some(neighbors) = adj.get(current.as_str()) {
for &neighbor in neighbors {
let new_tier = current_tier + 1;
let existing = tiers.get(neighbor).copied();
if existing.is_none() || new_tier > existing.unwrap() {
// Cap tier depth to prevent runaway on cycles
if new_tier <= module_boxes.len() {
tiers.insert(neighbor.to_string(), new_tier);
queue.push_back(neighbor.to_string());
}
}
}
}
}
// Ensure all modules have a tier
for name in module_boxes.keys() {
tiers.entry(name.clone()).or_insert(0);
}
tiers
}
/// Compute proportional height (content lines) based on LoC.
/// Minimum 1, maximum 6.
fn proportional_height(code_lines: usize, max_loc: usize) -> usize {
if max_loc == 0 || code_lines == 0 {
return 1;
}
let ratio = code_lines as f64 / max_loc as f64;
let height = (ratio * 5.0).ceil() as usize + 1;
height.clamp(1, 6)
}
/// Build the header line for a module box.
fn render_box_header(
mbox: &ModuleBox,
box_width: usize,
is_hub: bool,
h: &str,
tl: &str,
tr: &str,
) -> String {
let header = format!(" {} ", mbox.name);
let pub_hint = format!("{}/{} pub", mbox.pub_count, mbox.total_count);
let loc_hint = format!("{} loc", mbox.code_lines);
let meta = format!("{} {}", loc_hint, pub_hint);
let fill_len = box_width
.saturating_sub(2)
.saturating_sub(header.len())
.saturating_sub(meta.len())
.saturating_sub(2);
let fill = h.repeat(fill_len);
if is_hub {
format!(
"{}{}{}{}{}{}",
tl.bold(),
h.repeat(1).bold(),
header.white().bold(),
fill.bold(),
format!(" {} ", meta).dimmed(),
tr.bold(),
)
} else {
format!(
"{}{}{}{}{}{}",
tl.dimmed(),
h.repeat(1).dimmed(),
header.white().bold(),
fill.dimmed(),
format!(" {} ", meta).dimmed(),
tr.dimmed(),
)
}
}
/// Build the content lines (heatbar, symbols) for a module box.
fn build_box_content(mbox: &ModuleBox, inner_w: usize, prop_height: usize) -> Vec<String> {
let mut content: Vec<String> = Vec::new();
// Complexity heatbar
let heatbar = build_heatbar(&mbox.func_scores, inner_w);
if !heatbar.is_empty() {
content.push(heatbar);
}
// Structs
if !mbox.structs.is_empty() {
let items = abbreviate_list(&mbox.structs, inner_w.saturating_sub(2));
content.push(format!("{} {}", "S".bright_green().bold(), items));
}
// Enums
if !mbox.enums.is_empty() {
let items = abbreviate_list(&mbox.enums, inner_w.saturating_sub(2));
content.push(format!("{} {}", "E".bright_yellow().bold(), items));
}
// Traits
if !mbox.traits.is_empty() {
let items = abbreviate_list(&mbox.traits, inner_w.saturating_sub(2));
content.push(format!("{} {}", "T".bright_magenta().bold(), items));
}
// Functions
if !mbox.functions.is_empty() {
let items = abbreviate_list(&mbox.functions, inner_w.saturating_sub(2));
content.push(format!("{} {}", "f".bright_blue(), items));
}
if content.is_empty() {
content.push(format!("{}", "(empty)".dimmed()));
}
// Pad or trim to proportional height
while content.len() < prop_height {
content.push(String::new());
}
if content.len() > prop_height && prop_height > 0 {
content.truncate(prop_height);
}
content
}
/// Render bordered content lines and the footer of a module box.
fn render_box_body(
content: &[String],
inner_w: usize,
box_width: usize,
is_hub: bool,
v: &str,
bl: &str,
br: &str,
h: &str,
) -> Vec<String> {
let mut lines = Vec::new();
for line in content {
let vis_len = render::visible_len(line);
let pad = inner_w.saturating_sub(vis_len);
if is_hub {
lines.push(format!(
"{} {}{} {}",
v.bold(),
line,
" ".repeat(pad),
v.bold()
));
} else {
lines.push(format!(
"{} {}{} {}",
v.dimmed(),
line,
" ".repeat(pad),
v.dimmed()
));
}
}
// Footer
let footer_fill = box_width.saturating_sub(2);
if is_hub {
lines.push(format!(
"{}{}{}",
bl.bold(),
h.repeat(footer_fill).bold(),
br.bold()
));
} else {
lines.push(format!(
"{}{}{}",
bl.dimmed(),
h.repeat(footer_fill).dimmed(),
br.dimmed()
));
}
lines
}
/// Render a single module box, returning lines.
fn render_module_box(
mbox: &ModuleBox,
box_width: usize,
is_hub: bool,
prop_height: usize,
) -> Vec<String> {
let box_width = box_width.max(20);
let inner_w = box_width.saturating_sub(4); // "│ " + " │" = 4 chars
// Border characters: hubs get bold double-line borders
let (tl, tr, bl, br, h, v) = if is_hub {
("╔", "╗", "╚", "╝", "═", "║")
} else {
("┌", "┐", "└", "┘", "─", "│")
};
let mut lines = Vec::new();
lines.push(render_box_header(mbox, box_width, is_hub, h, tl, tr));
let content = build_box_content(mbox, inner_w, prop_height);
lines.extend(render_box_body(
&content, inner_w, box_width, is_hub, v, bl, br, h,
));
lines
}
/// Build a complexity heatbar from function scores.
fn build_heatbar(scores: &[f64], max_width: usize) -> String {
if scores.is_empty() {
return String::new();
}
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let max_score = scores.iter().cloned().fold(0.0f64, f64::max).max(1.0);
let bar_width = max_width.min(scores.len());
let mut result = String::new();
if scores.len() <= bar_width {
let mut sorted_scores = scores.to_vec();
sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
for &score in &sorted_scores {
let ratio = score / max_score;
let idx = (ratio * (blocks.len() - 1) as f64).round() as usize;
let idx = idx.min(blocks.len() - 1);
let ch = blocks[idx].to_string();
let colored = if ratio < 0.33 {
ch.green().to_string()
} else if ratio < 0.66 {
ch.yellow().to_string()
} else {
ch.red().to_string()
};
result.push_str(&colored);
}
} else {
let bucket_size = (scores.len() as f64 / bar_width as f64).ceil() as usize;
let mut sorted_scores = scores.to_vec();
sorted_scores.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
for chunk in sorted_scores.chunks(bucket_size) {
let bucket_max = chunk.iter().cloned().fold(0.0f64, f64::max);
let ratio = bucket_max / max_score;
let idx = (ratio * (blocks.len() - 1) as f64).round() as usize;
let idx = idx.min(blocks.len() - 1);
let ch = blocks[idx].to_string();
let colored = if ratio < 0.33 {
ch.green().to_string()
} else if ratio < 0.66 {
ch.yellow().to_string()
} else {
ch.red().to_string()
};
result.push_str(&colored);
}
}
result
}
/// Abbreviate a list of names to fit within the given width.
fn abbreviate_list(names: &[String], max_width: usize) -> String {
let mut result = String::new();
let mut remaining = names.len();
for (i, name) in names.iter().enumerate() {
let separator = if i > 0 { ", " } else { "" };
let addition = format!("{}{}", separator, name);
if result.len() + addition.len() > max_width && i > 0 {
let more = remaining;
result.push_str(&format!(" +{}", more));
break;
}
result.push_str(&addition);
remaining -= 1;
}
result
}
#[derive(Serialize)]
struct ModuleMetadataJson {
name: String,
structs: Vec<String>,
enums: Vec<String>,
traits: Vec<String>,
functions: Vec<String>,
pub_count: usize,
total_count: usize,
code_lines: usize,
tier: usize,
is_hub: bool,
complexity_scores: Vec<f64>,
}
#[derive(Serialize)]
struct MapJson {
cstat_version: String,
modules: Vec<ModuleMetadataJson>,
}
/// Render module map as JSON.
pub fn render_map_json(rs_files: &[PathBuf], project_path: &Path) {
let symbols = ast_parser::parse_project(rs_files);
let dep_analysis = deps::analyze_deps(rs_files, project_path);
let (file_stats, _) = loc::analyze_files(rs_files);
let mut loc_by_mod: HashMap<String, usize> = HashMap::new();
for stat in &file_stats {
let mod_name = render::module_name_from_path(&stat.path, project_path);
loc_by_mod.insert(mod_name, stat.code_lines);
}
let func_complexities = complexity::compute_all(&symbols, project_path);
let mut complexity_by_mod: HashMap<String, Vec<f64>> = HashMap::new();
for fc in &func_complexities {
let mod_name = file_to_module_name(&fc.file);
complexity_by_mod
.entry(mod_name)
.or_default()
.push(fc.composite_score);
}
let module_boxes = build_module_boxes(
&symbols,
project_path,
&loc_by_mod,
&complexity_by_mod,
&dep_analysis,
);
let tiers = compute_tiers(&module_boxes, &dep_analysis);
let connectivities: Vec<usize> = module_boxes.values().map(|m| m.connectivity).collect();
let mean_conn = if connectivities.is_empty() {
0.0
} else {
connectivities.iter().sum::<usize>() as f64 / connectivities.len() as f64
};
let hub_threshold = (mean_conn + 1.0) as usize;
let mut modules: Vec<ModuleMetadataJson> = module_boxes
.values()
.map(|mbox| ModuleMetadataJson {
name: mbox.name.clone(),
structs: mbox.structs.clone(),
enums: mbox.enums.clone(),
traits: mbox.traits.clone(),
functions: mbox.functions.clone(),
pub_count: mbox.pub_count,
total_count: mbox.total_count,
code_lines: mbox.code_lines,
tier: tiers.get(&mbox.name).copied().unwrap_or(0),
is_hub: mbox.connectivity >= hub_threshold && hub_threshold > 0,
complexity_scores: mbox.func_scores.clone(),
})
.collect();
modules.sort_by(|a, b| a.tier.cmp(&b.tier).then(b.code_lines.cmp(&a.code_lines)));
let output = MapJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
modules,
};
println!("{}", serde_json::to_string(&output).unwrap());
}

673
src/redundancy.rs Normal file
View file

@ -0,0 +1,673 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use syn::visit::Visit;
use syn::{Expr, File as SynFile, ImplItem, Item, ReturnType, Type};
use crate::render;
/// Information about a function sufficient for redundancy comparison.
#[derive(Debug, Clone)]
struct FunctionSig {
name: String,
file: String,
line_start: usize,
param_types: Vec<String>,
return_type: String,
}
/// A pair of functions flagged as potentially redundant.
#[derive(Debug, Clone)]
pub struct RedundantPair {
pub func_a: String,
pub file_a: String,
pub line_a: usize,
pub func_b: String,
pub file_b: String,
pub line_b: usize,
pub similarity: f64,
pub kind: SimilarityKind,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SimilarityKind {
Signature,
Structural,
Both,
}
impl std::fmt::Display for SimilarityKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SimilarityKind::Signature => write!(f, "signature"),
SimilarityKind::Structural => write!(f, "structural"),
SimilarityKind::Both => write!(f, "both"),
}
}
}
// ── Signature extraction ─────────────────────────────────────────────
/// Extract function signatures from all project files.
fn extract_signatures(rs_files: &[PathBuf], project_path: &Path) -> Vec<FunctionSig> {
let mut sigs = Vec::new();
for file in rs_files {
let content = match std::fs::read_to_string(file) {
Ok(c) => c,
Err(_) => continue,
};
let ast: SynFile = match syn::parse_file(&content) {
Ok(f) => f,
Err(_) => continue,
};
let rel_path = file
.strip_prefix(project_path)
.unwrap_or(file)
.display()
.to_string();
for item in &ast.items {
match item {
Item::Fn(func) => {
let sig = extract_fn_sig(&func.sig, &func.sig.ident.to_string(), &rel_path);
sigs.push(sig);
}
Item::Impl(imp) => {
let type_name = type_to_short(&imp.self_ty);
for impl_item in &imp.items {
if let ImplItem::Fn(method) = impl_item {
let qualified = format!("{}::{}", type_name, method.sig.ident);
let sig = extract_fn_sig(&method.sig, &qualified, &rel_path);
sigs.push(sig);
}
}
}
_ => {}
}
}
}
sigs
}
fn extract_fn_sig(sig: &syn::Signature, name: &str, file: &str) -> FunctionSig {
let param_types: Vec<String> = sig
.inputs
.iter()
.filter_map(|arg| match arg {
syn::FnArg::Typed(pat_type) => Some(type_to_short(&pat_type.ty)),
syn::FnArg::Receiver(_) => Some("self".to_string()),
})
.collect();
let return_type = match &sig.output {
ReturnType::Default => "()".to_string(),
ReturnType::Type(_, ty) => type_to_short(ty),
};
let line_start = sig.fn_token.span.start().line;
FunctionSig {
name: name.to_string(),
file: file.to_string(),
line_start,
param_types,
return_type,
}
}
fn type_to_short(ty: &Type) -> String {
match ty {
Type::Path(tp) => tp
.path
.segments
.iter()
.map(|s| {
let ident = s.ident.to_string();
if let syn::PathArguments::AngleBracketed(args) = &s.arguments {
let inner: Vec<String> = args
.args
.iter()
.filter_map(|a| {
if let syn::GenericArgument::Type(t) = a {
Some(type_to_short(t))
} else {
None
}
})
.collect();
if inner.is_empty() {
ident
} else {
format!("{}<{}>", ident, inner.join(", "))
}
} else {
ident
}
})
.collect::<Vec<_>>()
.join("::"),
Type::Reference(r) => {
let mutability = if r.mutability.is_some() { "&mut " } else { "&" };
format!("{}{}", mutability, type_to_short(&r.elem))
}
Type::Tuple(t) => {
let inner: Vec<String> = t.elems.iter().map(type_to_short).collect();
format!("({})", inner.join(", "))
}
Type::Slice(s) => format!("[{}]", type_to_short(&s.elem)),
Type::Array(a) => format!("[{}; _]", type_to_short(&a.elem)),
Type::ImplTrait(_) => "impl Trait".to_string(),
Type::TraitObject(_) => "dyn Trait".to_string(),
_ => "?".to_string(),
}
}
// ── Pass 1: Signature similarity ─────────────────────────────────────
/// Compute normalized Levenshtein distance between two strings (0.0 = identical, 1.0 = completely different).
fn levenshtein_distance(a: &str, b: &str) -> f64 {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let m = a_chars.len();
let n = b_chars.len();
if m == 0 && n == 0 {
return 0.0;
}
if m == 0 || n == 0 {
return 1.0;
}
let mut prev = vec![0usize; n + 1];
let mut curr = vec![0usize; n + 1];
for j in 0..=n {
prev[j] = j;
}
for i in 1..=m {
curr[0] = i;
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
let max_len = m.max(n);
prev[n] as f64 / max_len as f64
}
/// Name similarity: 1.0 - normalized_levenshtein. Also checks substring containment.
fn name_similarity(a: &str, b: &str) -> f64 {
// Use the "bare" name (after last ::) for comparison
let a_bare = a.rsplit("::").next().unwrap_or(a);
let b_bare = b.rsplit("::").next().unwrap_or(b);
let lev_sim = 1.0 - levenshtein_distance(a_bare, b_bare);
// Substring containment bonus
let substr_sim = if a_bare.len() >= 3 && b_bare.len() >= 3 {
if a_bare.contains(b_bare) || b_bare.contains(a_bare) {
let shorter = a_bare.len().min(b_bare.len()) as f64;
let longer = a_bare.len().max(b_bare.len()) as f64;
shorter / longer
} else {
0.0
}
} else {
0.0
};
lev_sim.max(substr_sim)
}
/// Jaccard index on parameter type sets.
fn param_type_jaccard(a: &[String], b: &[String]) -> f64 {
if a.is_empty() && b.is_empty() {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
let set_a: HashSet<&str> = a.iter().map(|s| s.as_str()).collect();
let set_b: HashSet<&str> = b.iter().map(|s| s.as_str()).collect();
let intersection = set_a.intersection(&set_b).count() as f64;
let union = set_a.union(&set_b).count() as f64;
if union == 0.0 {
0.0
} else {
intersection / union
}
}
/// Compute signature similarity between two functions. Returns 0.0..1.0.
fn signature_similarity(a: &FunctionSig, b: &FunctionSig) -> f64 {
let name_sim = name_similarity(&a.name, &b.name);
let param_sim = param_type_jaccard(&a.param_types, &b.param_types);
let ret_sim = if a.return_type == b.return_type {
1.0
} else {
0.0
};
// Weighted combination
0.4 * name_sim + 0.4 * param_sim + 0.2 * ret_sim
}
/// Find signature-similar pairs above a threshold.
fn find_signature_pairs(sigs: &[FunctionSig], threshold: f64) -> Vec<(usize, usize, f64)> {
let mut pairs = Vec::new();
for i in 0..sigs.len() {
for j in (i + 1)..sigs.len() {
// Skip trivially tiny functions (getters, etc.)
if sigs[i].param_types.is_empty()
&& sigs[j].param_types.is_empty()
&& sigs[i].return_type == "()"
&& sigs[j].return_type == "()"
{
continue;
}
let sim = signature_similarity(&sigs[i], &sigs[j]);
if sim >= threshold {
pairs.push((i, j, sim));
}
}
}
pairs
}
// ── Pass 2: Structural similarity ────────────────────────────────────
/// Normalized AST node kind (stripping identifiers and literals).
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum NormNode {
Block(usize), // number of statements
If,
Match(usize), // number of arms
For,
While,
Loop,
Let,
Return,
Call(usize), // number of arguments
MethodCall(usize),
Binary,
Unary,
Closure(usize), // number of params
Index,
Field,
Assign,
Break,
Continue,
Literal,
Path,
Other,
}
/// Extract a structural hash from a function body by normalizing the AST.
fn structural_hash_from_file(
file_path: &Path,
_project_path: &Path,
func_name: &str,
) -> Option<u64> {
let content = std::fs::read_to_string(file_path).ok()?;
let ast = syn::parse_file(&content).ok()?;
for item in &ast.items {
match item {
Item::Fn(func) => {
if func.sig.ident == func_name || func_name == func.sig.ident.to_string() {
return Some(hash_block(&func.block));
}
}
Item::Impl(imp) => {
let type_name = type_to_short(&imp.self_ty);
for impl_item in &imp.items {
if let ImplItem::Fn(method) = impl_item {
let qualified = format!("{}::{}", type_name, method.sig.ident);
if qualified == func_name {
return Some(hash_block(&method.block));
}
}
}
}
_ => {}
}
}
None
}
/// Hash a block by normalizing its structure.
fn hash_block(block: &syn::Block) -> u64 {
let mut nodes = Vec::new();
let mut visitor = StructuralVisitor { nodes: &mut nodes };
visitor.visit_block(block);
let mut hasher = DefaultHasher::new();
nodes.hash(&mut hasher);
hasher.finish()
}
struct StructuralVisitor<'a> {
nodes: &'a mut Vec<NormNode>,
}
impl<'a, 'ast> Visit<'ast> for StructuralVisitor<'a> {
fn visit_block(&mut self, block: &'ast syn::Block) {
self.nodes.push(NormNode::Block(block.stmts.len()));
syn::visit::visit_block(self, block);
}
fn visit_expr(&mut self, expr: &'ast Expr) {
match expr {
Expr::If(_) => self.nodes.push(NormNode::If),
Expr::Match(m) => self.nodes.push(NormNode::Match(m.arms.len())),
Expr::ForLoop(_) => self.nodes.push(NormNode::For),
Expr::While(_) => self.nodes.push(NormNode::While),
Expr::Loop(_) => self.nodes.push(NormNode::Loop),
Expr::Return(_) => self.nodes.push(NormNode::Return),
Expr::Call(c) => self.nodes.push(NormNode::Call(c.args.len())),
Expr::MethodCall(m) => self.nodes.push(NormNode::MethodCall(m.args.len())),
Expr::Binary(_) => self.nodes.push(NormNode::Binary),
Expr::Unary(_) => self.nodes.push(NormNode::Unary),
Expr::Closure(c) => self.nodes.push(NormNode::Closure(c.inputs.len())),
Expr::Index(_) => self.nodes.push(NormNode::Index),
Expr::Field(_) => self.nodes.push(NormNode::Field),
Expr::Assign(_) => self.nodes.push(NormNode::Assign),
Expr::Break(_) => self.nodes.push(NormNode::Break),
Expr::Continue(_) => self.nodes.push(NormNode::Continue),
Expr::Lit(_) => self.nodes.push(NormNode::Literal),
Expr::Path(_) => self.nodes.push(NormNode::Path),
_ => self.nodes.push(NormNode::Other),
}
syn::visit::visit_expr(self, expr);
}
fn visit_local(&mut self, _local: &'ast syn::Local) {
self.nodes.push(NormNode::Let);
syn::visit::visit_local(self, _local);
}
}
// ── Analysis entry point ─────────────────────────────────────────────
const SIG_THRESHOLD: f64 = 0.6;
const REPORT_THRESHOLD: f64 = 0.7;
/// Find structural duplicate pairs from functions sharing identical AST hashes.
fn find_structural_duplicates(
sigs: &[FunctionSig],
structural_hashes: &HashMap<usize, u64>,
) -> (Vec<RedundantPair>, HashSet<(usize, usize)>) {
let mut hash_to_indices: HashMap<u64, Vec<usize>> = HashMap::new();
for (&idx, &hash) in structural_hashes {
hash_to_indices.entry(hash).or_default().push(idx);
}
let mut results: Vec<RedundantPair> = Vec::new();
let mut seen_pairs: HashSet<(usize, usize)> = HashSet::new();
for indices in hash_to_indices.values() {
if indices.len() < 2 {
continue;
}
for i in 0..indices.len() {
for j in (i + 1)..indices.len() {
let a = indices[i].min(indices[j]);
let b = indices[i].max(indices[j]);
if seen_pairs.insert((a, b)) {
let sig_sim = signature_similarity(&sigs[a], &sigs[b]);
let kind = if sig_sim >= SIG_THRESHOLD {
SimilarityKind::Both
} else {
SimilarityKind::Structural
};
results.push(RedundantPair {
func_a: sigs[a].name.clone(),
file_a: sigs[a].file.clone(),
line_a: sigs[a].line_start,
func_b: sigs[b].name.clone(),
file_b: sigs[b].file.clone(),
line_b: sigs[b].line_start,
similarity: 1.0,
kind,
});
}
}
}
}
(results, seen_pairs)
}
/// Merge signature-similar pairs that were not already found as structural duplicates.
fn merge_signature_pairs(
sig_pairs: &[(usize, usize, f64)],
sigs: &[FunctionSig],
structural_hashes: &HashMap<usize, u64>,
seen_pairs: &mut HashSet<(usize, usize)>,
) -> Vec<RedundantPair> {
let mut results = Vec::new();
for (i, j, sim) in sig_pairs {
let a = (*i).min(*j);
let b = (*i).max(*j);
if seen_pairs.contains(&(a, b)) || *sim < REPORT_THRESHOLD {
continue;
}
let struct_match = match (structural_hashes.get(&a), structural_hashes.get(&b)) {
(Some(ha), Some(hb)) => ha == hb,
_ => false,
};
let (final_sim, kind) = if struct_match {
(1.0, SimilarityKind::Both)
} else {
(*sim, SimilarityKind::Signature)
};
seen_pairs.insert((a, b));
results.push(RedundantPair {
func_a: sigs[a].name.clone(),
file_a: sigs[a].file.clone(),
line_a: sigs[a].line_start,
func_b: sigs[b].name.clone(),
file_b: sigs[b].file.clone(),
line_b: sigs[b].line_start,
similarity: final_sim,
kind,
});
}
results
}
/// Run redundancy detection on the project. Returns pairs sorted by similarity descending.
pub fn detect_redundancy(rs_files: &[PathBuf], project_path: &Path) -> Vec<RedundantPair> {
let sigs = extract_signatures(rs_files, project_path);
if sigs.len() < 2 {
return Vec::new();
}
// Pass 1: signature similarity
let sig_pairs = find_signature_pairs(&sigs, SIG_THRESHOLD);
// Pass 2: structural hashes for all functions
let mut structural_hashes: HashMap<usize, u64> = HashMap::new();
for (i, sig) in sigs.iter().enumerate() {
let full_path = project_path.join(&sig.file);
if let Some(hash) = structural_hash_from_file(&full_path, project_path, &sig.name) {
structural_hashes.insert(i, hash);
}
}
// Find structural duplicates
let (mut results, mut seen_pairs) = find_structural_duplicates(&sigs, &structural_hashes);
// Merge in signature-similar pairs
let sig_results = merge_signature_pairs(&sig_pairs, &sigs, &structural_hashes, &mut seen_pairs);
results.extend(sig_results);
// Sort by similarity descending
results.sort_by(|a, b| {
b.similarity
.partial_cmp(&a.similarity)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
// ── Human-readable rendering ─────────────────────────────────────────
pub fn render_redundancy(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
let pairs = detect_redundancy(rs_files, project_path);
println!(
"\n{}",
"── Redundancy Detection ──────────────────────────────────"
.bright_cyan()
.bold()
);
if verbose {
render::verbose_block(&[
"Detects suspected duplicate or near-duplicate functions.",
"Pass 1: Signature similarity — compares function names, parameter types, and return types.",
"Pass 2: Structural similarity — normalizes ASTs (strips identifiers/literals) and compares hashes.",
"Similarity 1.00 = structurally identical. >= 0.70 = signature-similar candidates.",
"Kind: 'signature' = similar signatures, 'structural' = identical AST structure, 'both' = both match.",
]);
render::guide_ref("redundancy");
}
if pairs.is_empty() {
println!("\n {}", "No suspected duplicates found.".green());
println!();
return;
}
println!(
"\n {} suspected duplicate pair(s) found:\n",
format!("{}", pairs.len()).bold()
);
// Header
println!(
" {:<35} {:<35} {:>5} {}",
"Function A".bold().underline(),
"Function B".bold().underline(),
"Sim".bold().underline(),
"Kind".bold().underline(),
);
for pair in &pairs {
let name_a = if pair.func_a.len() > 33 {
format!("{}...", &pair.func_a[..30])
} else {
pair.func_a.clone()
};
let name_b = if pair.func_b.len() > 33 {
format!("{}...", &pair.func_b[..30])
} else {
pair.func_b.clone()
};
let sim_str = format!("{:.2}", pair.similarity);
let colored_sim = if pair.similarity >= 0.9 {
sim_str.red().bold().to_string()
} else {
sim_str.yellow().to_string()
};
let kind_str = match pair.kind {
SimilarityKind::Both => "both".red().to_string(),
SimilarityKind::Structural => "structural".yellow().to_string(),
SimilarityKind::Signature => "signature".dimmed().to_string(),
};
println!(
" {:<35} {:<35} {:>5} {}",
name_a.dimmed(),
name_b.dimmed(),
colored_sim,
kind_str,
);
println!(
" {:<35} {}",
format!(" {}:{}", pair.file_a, pair.line_a).dimmed(),
format!(" {}:{}", pair.file_b, pair.line_b).dimmed(),
);
}
println!();
}
// ── JSON rendering ───────────────────────────────────────────────────
#[derive(Serialize)]
struct RedundantPairJson {
func_a: String,
file_a: String,
line_a: usize,
func_b: String,
file_b: String,
line_b: usize,
similarity: f64,
kind: String,
}
#[derive(Serialize)]
struct RedundancyJson {
cstat_version: String,
redundant_pairs: Vec<RedundantPairJson>,
total_pairs: usize,
}
pub fn render_redundancy_json(rs_files: &[PathBuf], project_path: &Path) {
let pairs = detect_redundancy(rs_files, project_path);
let total = pairs.len();
let json_pairs: Vec<RedundantPairJson> = pairs
.into_iter()
.map(|p| RedundantPairJson {
func_a: p.func_a,
file_a: p.file_a,
line_a: p.line_a,
func_b: p.func_b,
file_b: p.file_b,
line_b: p.line_b,
similarity: p.similarity,
kind: p.kind.to_string(),
})
.collect();
let output = RedundancyJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
redundant_pairs: json_pairs,
total_pairs: total,
};
println!("{}", serde_json::to_string(&output).unwrap());
}

298
src/report.rs Normal file
View file

@ -0,0 +1,298 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::{ast_parser, datapaths, dead_code, deps, flow, loc, render, symbols};
#[derive(Debug, Clone, Serialize)]
pub struct LineCountFile {
pub path: String,
pub total_lines: usize,
pub code_lines: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct LineCountReport {
pub total_files: usize,
pub total_lines: usize,
pub total_code_lines: usize,
pub mean_code_lines: f64,
pub median_code_lines: usize,
pub files: Vec<LineCountFile>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ModuleDegree {
pub module: String,
pub fan_in: usize,
pub fan_out: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ModuleDependencyMatrix {
pub modules: Vec<String>,
pub matrix: Vec<Vec<usize>>,
pub edges: usize,
pub degrees: Vec<ModuleDegree>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FocusReport {
pub cstat_version: String,
pub project: String,
pub line_counts: LineCountReport,
pub symbols: symbols::SymbolReport,
pub dependencies: ModuleDependencyMatrix,
pub dead_code: dead_code::DeadCodeReport,
pub test_reachability: datapaths::TestReachabilityAnalysis,
}
pub fn build_report(rs_files: &[PathBuf], project_path: &Path) -> FocusReport {
let project_path = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
let project = project_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let (file_stats, aggregate) = loc::analyze_files(rs_files);
let symbols_raw = ast_parser::parse_project(rs_files);
let dep_analysis = deps::analyze_deps(rs_files, &project_path);
let call_graph = flow::build_call_graph(rs_files, &project_path);
FocusReport {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
project,
line_counts: build_line_report(&file_stats, aggregate.as_ref(), &project_path),
symbols: symbols::analyze_symbols(&symbols_raw, &project_path),
dependencies: build_dependency_matrix(&dep_analysis),
dead_code: dead_code::analyze_graph(&call_graph),
test_reachability: datapaths::analyze_graph_test_reachability(&call_graph),
}
}
pub fn render_report(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
let report = build_report(rs_files, project_path);
render_report_data(&report, verbose);
}
pub fn render_report_json(rs_files: &[PathBuf], project_path: &Path) {
let report = build_report(rs_files, project_path);
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn render_report_data(report: &FocusReport, verbose: bool) {
println!(
"\n{} {}",
"cstat focused report".bright_cyan().bold(),
format!("({})", report.project).dimmed()
);
println!(
"{}",
"Objective module metrics: size, symbols, dependencies, dead-code candidates, and test/bench reachability."
.dimmed()
);
render_line_counts(&report.line_counts, verbose);
symbols::render_symbol_report(&report.symbols, verbose);
render_dependency_matrix(&report.dependencies, verbose);
dead_code::render_dead_code_report(&report.dead_code, verbose);
datapaths::render_test_reachability_report(&report.test_reachability, verbose);
}
fn build_line_report(
file_stats: &[loc::FileLocStats],
aggregate: Option<&loc::AggregateStats>,
project_path: &Path,
) -> LineCountReport {
let total_lines = file_stats.iter().map(|file| file.total_lines).sum();
let files: Vec<LineCountFile> = file_stats
.iter()
.map(|file| LineCountFile {
path: file
.path
.strip_prefix(project_path)
.unwrap_or(&file.path)
.display()
.to_string(),
total_lines: file.total_lines,
code_lines: file.code_lines,
})
.collect();
LineCountReport {
total_files: aggregate.map_or(file_stats.len(), |agg| agg.total_files),
total_lines,
total_code_lines: aggregate.map_or(0, |agg| agg.total_loc),
mean_code_lines: aggregate.map_or(0.0, |agg| agg.mean),
median_code_lines: aggregate.map_or(0, |agg| agg.median),
files,
}
}
fn build_dependency_matrix(dep_analysis: &deps::DepAnalysis) -> ModuleDependencyMatrix {
let modules = dep_analysis.modules.clone();
let module_index: HashMap<&str, usize> = modules
.iter()
.enumerate()
.map(|(idx, module)| (module.as_str(), idx))
.collect();
let mut matrix = vec![vec![0usize; modules.len()]; modules.len()];
for edge in &dep_analysis.edges {
if let (Some(from), Some(to)) = (
module_index.get(edge.from.as_str()),
module_index.get(edge.to.as_str()),
) {
matrix[*from][*to] = 1;
}
}
let mut degrees: Vec<ModuleDegree> = modules
.iter()
.map(|module| ModuleDegree {
module: module.clone(),
fan_in: dep_analysis.in_degree.get(module).copied().unwrap_or(0),
fan_out: dep_analysis.out_degree.get(module).copied().unwrap_or(0),
})
.collect();
degrees.sort_by(|a, b| {
(b.fan_in + b.fan_out)
.cmp(&(a.fan_in + a.fan_out))
.then(a.module.cmp(&b.module))
});
ModuleDependencyMatrix {
modules,
matrix,
edges: dep_analysis.edges.len(),
degrees,
}
}
fn render_line_counts(report: &LineCountReport, verbose: bool) {
render::section_header("Line counts");
if verbose {
render::verbose_block(&[
"Code lines exclude blank lines and comment-only lines.",
"Physical lines are kept beside code lines so generated or dense files are visible.",
]);
}
println!(
" {} {} {} {} {} {:.1} {} {}",
"files".cyan(),
report.total_files.to_string().bold(),
"code lines".cyan(),
report.total_code_lines.to_string().bold(),
"mean".cyan(),
report.mean_code_lines,
"median".cyan(),
report.median_code_lines.to_string().bold(),
);
println!();
println!(
" {:<56} {:>8} {:>8}",
"file".bold(),
"code".bold(),
"physical".bold()
);
for file in &report.files {
println!(
" {:<56} {:>8} {:>8}",
truncate(&file.path, 56),
file.code_lines,
file.total_lines,
);
}
}
fn render_dependency_matrix(report: &ModuleDependencyMatrix, verbose: bool) {
render::section_header("Module dependency matrix");
if verbose {
render::verbose_block(&[
"Rows are modules that depend on columns.",
"A 1 means at least one source-level use/mod edge from row to column.",
"The index list keeps the matrix readable for medium-sized crates.",
]);
}
println!(
" {} {} {} {}",
"modules".cyan(),
report.modules.len().to_string().bold(),
"edges".cyan(),
report.edges.to_string().bold(),
);
if report.modules.is_empty() {
println!(" {}", "No modules found.".yellow());
return;
}
println!();
for (idx, module) in report.modules.iter().enumerate() {
println!(" [{:>2}] {}", idx, module);
}
let cell_width = report
.modules
.len()
.saturating_sub(1)
.to_string()
.len()
.max(1);
println!();
print!(" {:>3} │", "");
for idx in 0..report.modules.len() {
print!(" {:>width$}", idx, width = cell_width);
}
println!();
print!(" {}─┼", "─".repeat(3));
for _ in 0..report.modules.len() {
print!("{}", "─".repeat(cell_width + 1));
}
println!();
for (idx, row) in report.matrix.iter().enumerate() {
print!(" {:>3} │", idx);
for value in row {
print!(" {:>width$}", value, width = cell_width);
}
println!();
}
let connected: Vec<&ModuleDegree> = report
.degrees
.iter()
.filter(|degree| degree.fan_in + degree.fan_out > 0)
.take(10)
.collect();
if !connected.is_empty() {
println!();
println!(" {}", "Highest fan-in/fan-out:".bold());
for degree in connected {
println!(
" {:<40} in {:>3} out {:>3}",
truncate(&degree.module, 40),
degree.fan_in,
degree.fan_out,
);
}
}
}
fn truncate(value: &str, max: usize) -> String {
if value.chars().count() <= max {
value.to_string()
} else {
let mut out: String = value.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}

View file

@ -1,980 +0,0 @@
use colored::Colorize;
use serde::Serialize;
use serde_json::json;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use crate::{ast_parser, dead_code, deps, flow, loc, render};
const SCORE_VERSION: &str = "code_complexity_cost_v0";
const DEFAULT_TOP: usize = 20;
#[derive(Debug, Clone, Serialize)]
pub struct ScorecardReport {
pub cstat_version: String,
pub score_version: String,
pub target: String,
pub code_complexity_cost: f64,
pub code_complexity_cost_per_kloc: f64,
pub component_costs: ComponentCosts,
pub scope_breakdown: BTreeMap<String, ScopeSummary>,
pub top_contributors: Vec<TopContributor>,
pub metadata: ScoreMetadata,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComponentCosts {
pub function_complexity: FunctionComponent,
pub file_concentration: FileComponent,
pub module_coupling: ModuleComponent,
pub abstraction_surface: AbstractionComponent,
pub stale_surface: StaleComponent,
}
#[derive(Debug, Clone, Serialize)]
pub struct FunctionComponent {
pub cost: f64,
pub functions_scored: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileComponent {
pub cost: f64,
pub files_scored: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ModuleComponent {
pub cost: f64,
pub modules_scored: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct AbstractionComponent {
pub cost: f64,
pub symbols_scored: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct StaleComponent {
pub cost: f64,
pub candidate_count: usize,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ScopeSummary {
pub cost: f64,
pub functions_scored: usize,
pub files_scored: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct TopContributor {
pub kind: String,
pub scope: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub module: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function: Option<String>,
pub cost: f64,
pub reasons: BTreeMap<String, serde_json::Value>,
pub component_costs: BTreeMap<String, f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScoreMetadata {
pub rust_files: usize,
pub code_lines: usize,
pub parse_error_files: usize,
}
#[derive(Debug, Clone, Default)]
struct ScopeAccumulator {
cost: f64,
functions_scored: usize,
files_scored: usize,
}
#[derive(Debug, Clone)]
struct Contributor {
kind: String,
scope: String,
file: Option<String>,
module: Option<String>,
function: Option<String>,
cost: f64,
reasons: BTreeMap<String, serde_json::Value>,
component_costs: BTreeMap<String, f64>,
}
#[derive(Debug, Clone)]
struct FunctionScoreInput {
file: String,
function: String,
graph_name: String,
scope: String,
cyclomatic: usize,
nesting_depth: usize,
line_count: usize,
body_stmt_count: usize,
signature_score: usize,
}
pub fn render_scorecard(
files: &[PathBuf],
project_path: &Path,
target: &str,
top: Option<usize>,
json_output: bool,
verbose: bool,
) {
let report = analyze_scorecard(files, project_path, target, top.unwrap_or(DEFAULT_TOP));
if json_output {
println!("{}", serde_json::to_string(&report).unwrap());
} else {
render_scorecard_human(&report, verbose);
}
}
pub fn analyze_scorecard(
files: &[PathBuf],
project_path: &Path,
target: &str,
top: usize,
) -> ScorecardReport {
let symbols = ast_parser::parse_project(files);
let graph = flow::build_call_graph(files, project_path);
let loc_by_file = collect_loc_by_file(files);
let file_scopes = collect_file_scopes(files, project_path);
let function_inputs = collect_function_inputs(&symbols, &graph, project_path, &file_scopes);
let mut scope_totals = initial_scope_totals(files, &symbols, project_path, &file_scopes);
let mut contributors = Vec::new();
let function_complexity =
score_functions(&function_inputs, &mut scope_totals, &mut contributors);
let file_concentration = score_files(
&symbols,
&loc_by_file,
project_path,
&file_scopes,
&mut scope_totals,
&mut contributors,
);
let abstraction_surface = score_abstraction_surface(
&symbols,
project_path,
&file_scopes,
&mut scope_totals,
&mut contributors,
);
let module_coupling = score_module_coupling(
files,
project_path,
&file_scopes,
&mut scope_totals,
&mut contributors,
);
let stale_surface = score_stale_surface(
&graph,
&function_inputs,
&mut scope_totals,
&mut contributors,
);
contributors.sort_by(|a, b| {
b.cost
.partial_cmp(&a.cost)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.kind.cmp(&b.kind))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.module.cmp(&b.module))
.then_with(|| a.function.cmp(&b.function))
});
contributors.truncate(top);
let code_lines: usize = loc_by_file.values().map(|stats| stats.code_lines).sum();
let total_cost = function_complexity.cost
+ file_concentration.cost
+ module_coupling.cost
+ abstraction_surface.cost
+ stale_surface.cost;
let kloc = (code_lines as f64 / 1000.0).max(1.0);
ScorecardReport {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
score_version: SCORE_VERSION.to_string(),
target: target.to_string(),
code_complexity_cost: round1(total_cost),
code_complexity_cost_per_kloc: round1(total_cost / kloc),
component_costs: ComponentCosts {
function_complexity: FunctionComponent {
cost: round1(function_complexity.cost),
functions_scored: function_complexity.functions_scored,
},
file_concentration: FileComponent {
cost: round1(file_concentration.cost),
files_scored: file_concentration.files_scored,
},
module_coupling: ModuleComponent {
cost: round1(module_coupling.cost),
modules_scored: module_coupling.modules_scored,
},
abstraction_surface: AbstractionComponent {
cost: round1(abstraction_surface.cost),
symbols_scored: abstraction_surface.symbols_scored,
},
stale_surface: StaleComponent {
cost: round1(stale_surface.cost),
candidate_count: stale_surface.candidate_count,
},
},
scope_breakdown: finalize_scope_totals(scope_totals),
top_contributors: contributors
.into_iter()
.map(|contributor| TopContributor {
kind: contributor.kind,
scope: contributor.scope,
file: contributor.file,
module: contributor.module,
function: contributor.function,
cost: round1(contributor.cost),
reasons: contributor.reasons,
component_costs: contributor
.component_costs
.into_iter()
.map(|(key, value)| (key, round1(value)))
.collect(),
})
.collect(),
metadata: ScoreMetadata {
rust_files: files.len(),
code_lines,
parse_error_files: symbols.files.iter().filter(|file| file.parse_error).count(),
},
}
}
fn render_scorecard_human(report: &ScorecardReport, verbose: bool) {
render::section_header("Code complexity score");
println!("score version: {}", report.score_version.bold());
println!("lower is cleaner; harness guards behavior separately");
if verbose {
render::verbose_block(&[
"Pure structural cost; no tests, benchmark, coverage, reward, or behavior signals are included.",
"All discovered Rust scopes contribute equally; scope breakdown is explanatory only.",
"This command is a compact scorecard, not a bundle of every underlying probe.",
]);
} else {
println!();
}
println!("total cost: {:.1}", report.code_complexity_cost);
println!("cost / KLOC: {:.1}", report.code_complexity_cost_per_kloc);
println!();
println!("components:");
println!(
" {:<24} {:>8.1}",
"function complexity:", report.component_costs.function_complexity.cost
);
println!(
" {:<24} {:>8.1}",
"file concentration:", report.component_costs.file_concentration.cost
);
println!(
" {:<24} {:>8.1}",
"module coupling:", report.component_costs.module_coupling.cost
);
println!(
" {:<24} {:>8.1}",
"abstraction surface:", report.component_costs.abstraction_surface.cost
);
println!(
" {:<24} {:>8.1}",
"stale surface:", report.component_costs.stale_surface.cost
);
println!();
println!("top contributors:");
if report.top_contributors.is_empty() {
println!(" <none>");
} else {
for (index, contributor) in report.top_contributors.iter().enumerate() {
println!(
" {:>2}. {:<19} {:<48} {:>8.1}",
index + 1,
contributor.kind,
contributor_label(contributor),
contributor.cost,
);
}
}
}
fn contributor_label(contributor: &TopContributor) -> String {
match contributor.kind.as_str() {
"function" => match (&contributor.file, &contributor.function) {
(Some(file), Some(function)) => format!("{file}::{function}"),
_ => contributor
.function
.clone()
.unwrap_or_else(|| "<unknown>".to_string()),
},
"file" | "abstraction_surface" => contributor
.file
.clone()
.unwrap_or_else(|| "<unknown>".to_string()),
"module" => contributor
.module
.clone()
.unwrap_or_else(|| "<unknown>".to_string()),
"stale_surface" => contributor
.function
.clone()
.unwrap_or_else(|| "<unknown>".to_string()),
_ => "<unknown>".to_string(),
}
}
#[derive(Default)]
struct FunctionScoreSummary {
cost: f64,
functions_scored: usize,
}
#[derive(Default)]
struct FileScoreSummary {
cost: f64,
files_scored: usize,
}
#[derive(Default)]
struct ModuleScoreSummary {
cost: f64,
modules_scored: usize,
}
#[derive(Default)]
struct AbstractionScoreSummary {
cost: f64,
symbols_scored: usize,
}
#[derive(Default)]
struct StaleScoreSummary {
cost: f64,
candidate_count: usize,
}
fn score_functions(
functions: &[FunctionScoreInput],
scopes: &mut BTreeMap<String, ScopeAccumulator>,
contributors: &mut Vec<Contributor>,
) -> FunctionScoreSummary {
let mut summary = FunctionScoreSummary {
functions_scored: functions.len(),
..Default::default()
};
for function in functions {
let branch_cost = 2.0 * excess(function.cyclomatic, 5);
let nesting_cost = 3.0 * excess(function.nesting_depth, 3);
let span_cost = 0.2 * excess(function.line_count, 50);
let body_cost = 0.5 * excess(function.body_stmt_count, 20);
let signature_cost = excess(function.signature_score, 6);
let cost = branch_cost + nesting_cost + span_cost + body_cost + signature_cost;
summary.cost += cost;
add_scope_cost(scopes, &function.scope, cost);
if cost > 0.0 {
let mut reasons = BTreeMap::new();
reasons.insert("cyclomatic".to_string(), json!(function.cyclomatic));
reasons.insert("nesting_depth".to_string(), json!(function.nesting_depth));
reasons.insert("line_count".to_string(), json!(function.line_count));
reasons.insert(
"body_stmt_count".to_string(),
json!(function.body_stmt_count),
);
reasons.insert(
"signature_score".to_string(),
json!(function.signature_score),
);
let mut component_costs = BTreeMap::new();
component_costs.insert("branching".to_string(), branch_cost);
component_costs.insert("nesting".to_string(), nesting_cost);
component_costs.insert("span".to_string(), span_cost);
component_costs.insert("body_statements".to_string(), body_cost);
component_costs.insert("signature".to_string(), signature_cost);
contributors.push(Contributor {
kind: "function".to_string(),
scope: function.scope.clone(),
file: Some(function.file.clone()),
module: None,
function: Some(function.function.clone()),
cost,
reasons,
component_costs,
});
}
}
summary
}
fn score_files(
symbols: &ast_parser::ProjectSymbols,
loc_by_file: &HashMap<PathBuf, loc::FileLocStats>,
project_path: &Path,
file_scopes: &HashMap<PathBuf, String>,
scopes: &mut BTreeMap<String, ScopeAccumulator>,
contributors: &mut Vec<Contributor>,
) -> FileScoreSummary {
let mut summary = FileScoreSummary {
files_scored: symbols.files.len(),
..Default::default()
};
for file in &symbols.files {
let code_lines = loc_by_file
.get(&file.path)
.map(|stats| stats.code_lines)
.unwrap_or_default();
let total_symbols = total_symbols(file);
let function_count = file.functions.len();
let line_cost = 0.04 * excess(code_lines, 400);
let symbol_cost = 0.25 * excess(total_symbols, 35);
let function_count_cost = excess(function_count, 25);
let cost = line_cost + symbol_cost + function_count_cost;
let scope = file_scopes
.get(&file.path)
.cloned()
.unwrap_or_else(|| file_scope(&file.path, project_path));
summary.cost += cost;
add_scope_cost(scopes, &scope, cost);
if cost > 0.0 {
let mut reasons = BTreeMap::new();
reasons.insert("code_lines".to_string(), json!(code_lines));
reasons.insert("total_symbols".to_string(), json!(total_symbols));
reasons.insert("function_count".to_string(), json!(function_count));
let mut component_costs = BTreeMap::new();
component_costs.insert("code_lines".to_string(), line_cost);
component_costs.insert("symbols".to_string(), symbol_cost);
component_costs.insert("functions".to_string(), function_count_cost);
contributors.push(Contributor {
kind: "file".to_string(),
scope,
file: Some(relative_file(&file.path, project_path)),
module: None,
function: None,
cost,
reasons,
component_costs,
});
}
}
summary
}
fn score_abstraction_surface(
symbols: &ast_parser::ProjectSymbols,
project_path: &Path,
file_scopes: &HashMap<PathBuf, String>,
scopes: &mut BTreeMap<String, ScopeAccumulator>,
contributors: &mut Vec<Contributor>,
) -> AbstractionScoreSummary {
let mut summary = AbstractionScoreSummary::default();
for file in &symbols.files {
let structs = file.structs.len();
let enums = file.enums.len();
let traits = file.traits.len();
let trait_impls = file
.impls
.iter()
.filter(|imp| imp.trait_name.is_some())
.count();
let consts_statics = file.consts.len() + file.statics.len();
summary.symbols_scored += total_symbols(file);
let struct_cost = 0.5 * excess(structs, 12);
let enum_cost = 0.5 * excess(enums, 8);
let trait_cost = excess(traits, 4);
let trait_impl_cost = 0.5 * excess(trait_impls, 8);
let const_static_cost = 0.2 * excess(consts_statics, 10);
let cost = struct_cost + enum_cost + trait_cost + trait_impl_cost + const_static_cost;
let scope = file_scopes
.get(&file.path)
.cloned()
.unwrap_or_else(|| file_scope(&file.path, project_path));
summary.cost += cost;
add_scope_cost(scopes, &scope, cost);
if cost > 0.0 {
let mut reasons = BTreeMap::new();
reasons.insert("structs".to_string(), json!(structs));
reasons.insert("enums".to_string(), json!(enums));
reasons.insert("traits".to_string(), json!(traits));
reasons.insert("trait_impls".to_string(), json!(trait_impls));
reasons.insert("consts_and_statics".to_string(), json!(consts_statics));
let mut component_costs = BTreeMap::new();
component_costs.insert("structs".to_string(), struct_cost);
component_costs.insert("enums".to_string(), enum_cost);
component_costs.insert("traits".to_string(), trait_cost);
component_costs.insert("trait_impls".to_string(), trait_impl_cost);
component_costs.insert("consts_and_statics".to_string(), const_static_cost);
contributors.push(Contributor {
kind: "abstraction_surface".to_string(),
scope,
file: Some(relative_file(&file.path, project_path)),
module: None,
function: None,
cost,
reasons,
component_costs,
});
}
}
summary
}
fn score_module_coupling(
files: &[PathBuf],
project_path: &Path,
file_scopes: &HashMap<PathBuf, String>,
scopes: &mut BTreeMap<String, ScopeAccumulator>,
contributors: &mut Vec<Contributor>,
) -> ModuleScoreSummary {
let analysis = deps::analyze_deps(files, project_path);
let cohesion = deps::compute_cohesion(files, project_path);
let module_scopes = module_scopes(files, project_path, file_scopes);
let mut summary = ModuleScoreSummary {
modules_scored: analysis.modules.len(),
..Default::default()
};
for module in &analysis.modules {
let in_degree = analysis.in_degree.get(module).copied().unwrap_or(0);
let out_degree = analysis.out_degree.get(module).copied().unwrap_or(0);
let out_cost = 2.0 * excess(out_degree, 5);
let in_cost = excess(in_degree, 10);
let cost = out_cost + in_cost;
let scope = module_scopes
.get(module)
.cloned()
.unwrap_or_else(|| "production".to_string());
summary.cost += cost;
add_scope_cost(scopes, &scope, cost);
if cost > 0.0 {
let mut reasons = BTreeMap::new();
reasons.insert("in_degree".to_string(), json!(in_degree));
reasons.insert("out_degree".to_string(), json!(out_degree));
let mut component_costs = BTreeMap::new();
component_costs.insert("fan_out".to_string(), out_cost);
component_costs.insert("fan_in".to_string(), in_cost);
contributors.push(Contributor {
kind: "module".to_string(),
scope,
file: None,
module: Some(module.clone()),
function: None,
cost,
reasons,
component_costs,
});
}
}
for (module_a, module_b, strength) in &analysis.coupling_pairs {
if *strength < 2 {
continue;
}
let cost = 20.0 + 5.0 * *strength as f64;
let scope = pair_scope(module_a, module_b, &module_scopes);
summary.cost += cost;
add_scope_cost(scopes, &scope, cost);
let mut reasons = BTreeMap::new();
reasons.insert("strength".to_string(), json!(strength));
reasons.insert("direction".to_string(), json!("bidirectional"));
let mut component_costs = BTreeMap::new();
component_costs.insert("bidirectional_pair".to_string(), cost);
contributors.push(Contributor {
kind: "module".to_string(),
scope,
file: None,
module: Some(format!("{module_a} <-> {module_b}")),
function: None,
cost,
reasons,
component_costs,
});
}
for row in &cohesion {
if row.function_count < 4 {
continue;
}
let cohesion_gap = (0.55 - row.combined).max(0.0);
let cost = 2.0 * row.function_count as f64 * cohesion_gap;
let scope = module_scopes
.get(&row.module)
.cloned()
.unwrap_or_else(|| "production".to_string());
summary.cost += cost;
add_scope_cost(scopes, &scope, cost);
if cost > 0.0 {
let mut reasons = BTreeMap::new();
reasons.insert("combined_cohesion".to_string(), json!(row.combined));
reasons.insert("function_count".to_string(), json!(row.function_count));
let mut component_costs = BTreeMap::new();
component_costs.insert("low_cohesion".to_string(), cost);
contributors.push(Contributor {
kind: "module".to_string(),
scope,
file: None,
module: Some(row.module.clone()),
function: None,
cost,
reasons,
component_costs,
});
}
}
summary
}
fn score_stale_surface(
graph: &flow::CallGraph,
functions: &[FunctionScoreInput],
scopes: &mut BTreeMap<String, ScopeAccumulator>,
contributors: &mut Vec<Contributor>,
) -> StaleScoreSummary {
let report = dead_code::analyze_graph(graph);
let function_scopes: HashMap<&str, &str> = functions
.iter()
.map(|function| (function.graph_name.as_str(), function.scope.as_str()))
.collect();
let function_files: HashMap<&str, &str> = functions
.iter()
.map(|function| (function.graph_name.as_str(), function.file.as_str()))
.collect();
let summary = StaleScoreSummary {
candidate_count: report.candidate_count,
cost: 0.5 * report.candidate_count as f64,
};
for candidate in &report.candidates {
let cost = 0.5;
let scope = function_scopes
.get(candidate.function.as_str())
.map(|scope| (*scope).to_string())
.unwrap_or_else(|| graph_function_scope(graph, &candidate.function));
add_scope_cost(scopes, &scope, cost);
let mut reasons = BTreeMap::new();
reasons.insert("reason".to_string(), json!(candidate.reason));
let mut component_costs = BTreeMap::new();
component_costs.insert("stale_candidate".to_string(), cost);
contributors.push(Contributor {
kind: "stale_surface".to_string(),
scope,
file: function_files
.get(candidate.function.as_str())
.map(|file| (*file).to_string()),
module: None,
function: Some(candidate.function.clone()),
cost,
reasons,
component_costs,
});
}
summary
}
fn collect_function_inputs(
symbols: &ast_parser::ProjectSymbols,
graph: &flow::CallGraph,
project_path: &Path,
file_scopes: &HashMap<PathBuf, String>,
) -> Vec<FunctionScoreInput> {
let mut inputs = Vec::new();
for file in &symbols.files {
let file_module = render::module_name_from_path(&file.path, project_path);
let rel_file = relative_file(&file.path, project_path);
let base_scope = file_scopes
.get(&file.path)
.cloned()
.unwrap_or_else(|| file_scope(&file.path, project_path));
for function in &file.functions {
let graph_name = graph_name_for_function(&file_module, &function.name);
let scope = function_scope(graph, &graph_name, &base_scope, function.is_test_support);
let line_count = function_line_count(function);
let signature_score = signature_score(function);
inputs.push(FunctionScoreInput {
file: rel_file.clone(),
function: function.name.clone(),
graph_name,
scope,
cyclomatic: function.branch_points + 1,
nesting_depth: function.nesting_depth,
line_count,
body_stmt_count: function.body_stmt_count,
signature_score,
});
}
}
inputs
}
fn collect_loc_by_file(files: &[PathBuf]) -> HashMap<PathBuf, loc::FileLocStats> {
files
.iter()
.filter_map(|file| loc::count_file_lines(file).map(|stats| (file.clone(), stats)))
.collect()
}
fn collect_file_scopes(files: &[PathBuf], project_path: &Path) -> HashMap<PathBuf, String> {
files
.iter()
.map(|file| (file.clone(), file_scope(file, project_path)))
.collect()
}
fn initial_scope_totals(
files: &[PathBuf],
symbols: &ast_parser::ProjectSymbols,
project_path: &Path,
file_scopes: &HashMap<PathBuf, String>,
) -> BTreeMap<String, ScopeAccumulator> {
let mut scopes = BTreeMap::new();
for standard in ["production", "tests", "benches"] {
scopes.insert(standard.to_string(), ScopeAccumulator::default());
}
for file in files {
let scope = file_scopes
.get(file)
.cloned()
.unwrap_or_else(|| file_scope(file, project_path));
scopes.entry(scope).or_default().files_scored += 1;
}
for file in &symbols.files {
let scope = file_scopes
.get(&file.path)
.cloned()
.unwrap_or_else(|| file_scope(&file.path, project_path));
scopes.entry(scope).or_default().functions_scored += file.functions.len();
}
scopes
}
fn finalize_scope_totals(
scopes: BTreeMap<String, ScopeAccumulator>,
) -> BTreeMap<String, ScopeSummary> {
scopes
.into_iter()
.map(|(scope, summary)| {
(
scope,
ScopeSummary {
cost: round1(summary.cost),
functions_scored: summary.functions_scored,
files_scored: summary.files_scored,
},
)
})
.collect()
}
fn add_scope_cost(scopes: &mut BTreeMap<String, ScopeAccumulator>, scope: &str, cost: f64) {
scopes.entry(scope.to_string()).or_default().cost += cost;
}
fn module_scopes(
files: &[PathBuf],
project_path: &Path,
file_scopes: &HashMap<PathBuf, String>,
) -> HashMap<String, String> {
files
.iter()
.map(|file| {
let module = render::module_name_from_path(file, project_path);
let scope = file_scopes
.get(file)
.cloned()
.unwrap_or_else(|| file_scope(file, project_path));
(module, scope)
})
.collect()
}
fn pair_scope(module_a: &str, module_b: &str, module_scopes: &HashMap<String, String>) -> String {
let scope_a = module_scopes
.get(module_a)
.cloned()
.unwrap_or_else(|| "production".to_string());
let scope_b = module_scopes
.get(module_b)
.cloned()
.unwrap_or_else(|| "production".to_string());
if scope_a == scope_b {
scope_a
} else {
"mixed".to_string()
}
}
fn function_scope(
graph: &flow::CallGraph,
graph_name: &str,
file_scope: &str,
is_test_support: bool,
) -> String {
if file_scope == "benches" || file_scope == "tests" {
return file_scope.to_string();
}
if matches!(
graph.entry_point_kinds.get(graph_name),
Some(flow::EntryPointKind::Bench)
) {
return "benches".to_string();
}
if matches!(
graph.entry_point_kinds.get(graph_name),
Some(flow::EntryPointKind::Test)
) {
return "tests".to_string();
}
if let Some(node) = graph.function_nodes.get(graph_name) {
if node.is_bench_entry {
return "benches".to_string();
}
if node.is_test_entry || node.is_test_support {
return "tests".to_string();
}
}
if is_test_support {
return "tests".to_string();
}
file_scope.to_string()
}
fn graph_function_scope(graph: &flow::CallGraph, graph_name: &str) -> String {
if matches!(
graph.entry_point_kinds.get(graph_name),
Some(flow::EntryPointKind::Bench)
) {
return "benches".to_string();
}
if matches!(
graph.entry_point_kinds.get(graph_name),
Some(flow::EntryPointKind::Test)
) {
return "tests".to_string();
}
if let Some(node) = graph.function_nodes.get(graph_name) {
if node.is_bench_entry {
return "benches".to_string();
}
if node.is_test_entry || node.is_test_support {
return "tests".to_string();
}
}
"production".to_string()
}
fn file_scope(file: &Path, project_path: &Path) -> String {
let rel = file.strip_prefix(project_path).unwrap_or(file);
let first = rel
.components()
.next()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.unwrap_or_else(|| "src".to_string());
match first.as_str() {
"tests" => "tests".to_string(),
"benches" => "benches".to_string(),
"examples" => "examples".to_string(),
_ => "production".to_string(),
}
}
fn graph_name_for_function(file_module: &str, function_name: &str) -> String {
if file_module == "main" || file_module == "lib" {
function_name.to_string()
} else {
format!("{file_module}::{function_name}")
}
}
fn total_symbols(file: &ast_parser::FileSymbols) -> usize {
file.functions.len()
+ file.structs.len()
+ file.enums.len()
+ file.traits.len()
+ file.impls.len()
+ file.consts.len()
+ file.statics.len()
}
fn function_line_count(function: &ast_parser::FunctionInfo) -> usize {
if function.line_end >= function.line_start {
function.line_end - function.line_start + 1
} else {
function.body_stmt_count.max(1)
}
}
fn signature_score(function: &ast_parser::FunctionInfo) -> usize {
function.param_count
+ function.return_type_complexity
+ function.generic_param_count
+ function.trait_bound_count
+ function.where_predicate_count
}
fn excess(value: usize, threshold: usize) -> f64 {
value.saturating_sub(threshold) as f64
}
fn relative_file(path: &Path, project_path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn round1(value: f64) -> f64 {
(value * 10.0).round() / 10.0
}

644
src/summary/mod.rs Normal file
View file

@ -0,0 +1,644 @@
mod sections;
use colored::Colorize;
use serde::Serialize;
use std::path::{Path, PathBuf};
use crate::ast_parser;
use crate::circuits;
use crate::complexity;
use crate::datapaths;
use crate::deps;
use crate::graph;
use crate::loc;
use crate::render;
use sections::*;
/// Render the summary dashboard — the default view combining all analyses.
pub fn render_summary(rs_files: &[PathBuf], project_path: &Path, verbose: bool) {
let term_w = render::terminal_width();
let project_path_canon = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
// ── Gather data ──────────────────────────────────────────
let (file_stats, agg_loc) = loc::analyze_files(rs_files);
let symbols = ast_parser::parse_project(rs_files);
let func_complexities = complexity::compute_all(&symbols, &project_path_canon);
let complexity_stats = complexity::compute_stats(&func_complexities);
let dep_analysis = deps::analyze_deps(rs_files, &project_path_canon);
// ── Project name ─────────────────────────────────────────
let project_name = project_path_canon
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let total_files = file_stats.len();
let total_loc = agg_loc.as_ref().map_or(0, |a| a.total_loc);
let total_functions = func_complexities.len();
let total_modules = dep_analysis.modules.len();
// ── Render each section ──────────────────────────────────
render_summary_header(
&project_name,
total_files,
total_loc,
total_functions,
total_modules,
term_w,
verbose,
);
render_loc_section(&file_stats, &project_path_canon, term_w, total_loc, verbose);
render_complexity_section(&func_complexities, verbose);
render_connectome_section(&dep_analysis, term_w, verbose);
render_distributions_section(&func_complexities, verbose);
render_centrality_section(rs_files, &project_path_canon, verbose);
render_reachability_section(rs_files, &project_path_canon, verbose);
render_circuits_section(rs_files, &project_path_canon, verbose);
render_stats_footer(
&agg_loc,
&complexity_stats,
total_files,
total_functions,
total_modules,
verbose,
);
}
/// Render the dashboard header banner.
fn render_summary_header(
project_name: &str,
total_files: usize,
total_loc: usize,
total_functions: usize,
total_modules: usize,
term_w: usize,
verbose: bool,
) {
let header_line = "═".repeat(term_w.min(60));
println!("\n{}", header_line.bright_cyan().bold());
println!(
"{} {}",
"cstat".bright_cyan().bold(),
project_name.white().bold()
);
println!(
"{} files · {} code lines · {} functions · {} modules",
format!("{}", total_files).bold(),
format!("{}", total_loc).bold(),
format!("{}", total_functions).bold(),
format!("{}", total_modules).bold(),
);
println!("{}", header_line.bright_cyan().bold());
if verbose {
render::verbose_block(&[
"This dashboard gives a bird's-eye view of your codebase.",
"Each section below summarizes a different dimension of the project:",
"size, complexity, dependencies, graph structure, execution flow, and modularity.",
"",
"Tip: For machine-readable output, use 'cstat dump' to get a structured JSON",
"diagnostic report, or pass --json to any subcommand for raw structured data.",
"Run 'cstat guide' for a topic-based reference explaining what each metric means.",
]);
}
}
/// Render the Lines of Code section with top-10 chart and directory breakdown.
fn render_loc_section(
file_stats: &[loc::FileLocStats],
project_path_canon: &Path,
term_w: usize,
total_loc: usize,
verbose: bool,
) {
render::section_header("Lines of Code — Top Files");
if verbose {
render::verbose_block(&[
"Files ranked by non-blank, non-comment source lines of code (LoC).",
"Bar length is proportional to LoC. Color gradient: red = largest, green = smallest.",
"Only the top 10 files are shown; the full list is available via 'cstat loc'.",
]);
}
if file_stats.is_empty() {
println!(" {}", "No source files found.".dimmed());
} else {
render_loc_bars(file_stats, project_path_canon, term_w);
if verbose {
println!();
render::verbose_block(&[
"Directory breakdown: proportional LoC by top-level directory.",
"Bar size = share of total code. Color: red > 50%, yellow > 25%, green otherwise.",
]);
}
render_dir_breakdown(file_stats, project_path_canon, term_w, total_loc);
}
}
/// Render the LoC bar chart for up to 10 files.
fn render_loc_bars(file_stats: &[loc::FileLocStats], project_path_canon: &Path, term_w: usize) {
let display_count = file_stats.len().min(10);
let display_stats = &file_stats[..display_count];
let max_label_len = display_stats
.iter()
.map(|f| short_path(&f.path, project_path_canon).len())
.max()
.unwrap_or(0)
.min(35);
let max_loc = display_stats
.iter()
.map(|f| f.code_lines)
.max()
.unwrap_or(1);
let loc_digits = format!("{}", max_loc).len();
let bar_budget = term_w
.saturating_sub(max_label_len + 2)
.saturating_sub(3)
.saturating_sub(loc_digits)
.saturating_sub(2)
.max(10);
for (i, stat) in display_stats.iter().enumerate() {
let ratio = i as f64 / display_count.max(1) as f64;
let label = short_path_truncated(&stat.path, project_path_canon, max_label_len);
let bar_len = if max_loc > 0 {
(stat.code_lines as f64 / max_loc as f64 * bar_budget as f64).ceil() as usize
} else {
0
}
.max(if stat.code_lines > 0 { 1 } else { 0 });
let bar = render::bar_color(&"█".repeat(bar_len), ratio);
let padding = " ".repeat(bar_budget.saturating_sub(bar_len));
println!(
" {:>width$} │ {}{} {}",
label.dimmed(),
bar,
padding,
format!("{}", stat.code_lines).bold(),
width = max_label_len,
);
}
if file_stats.len() > display_count {
println!(
" {}",
format!("... and {} more files", file_stats.len() - display_count).dimmed()
);
}
}
/// Render the branching hotspots table.
fn render_complexity_section(func_complexities: &[complexity::FunctionComplexity], verbose: bool) {
render::section_header("Branching Hotspots");
if verbose {
render::verbose_block(&[
"Functions ranked by cyclomatic branching complexity (highest first).",
"Cycl = cyclomatic complexity = branch points + 1 (linearly independent paths).",
"Branches = cyclomatic - 1. Nest = deepest nesting depth within the function body.",
"See 'cstat branching', 'cstat signature', and 'cstat span' for focused detail.",
]);
}
if func_complexities.is_empty() {
println!(" {}", "No functions found.".dimmed());
} else {
let mut rows: Vec<_> = func_complexities.iter().collect();
rows.sort_by(|a, b| {
b.cyclomatic
.cmp(&a.cyclomatic)
.then_with(|| b.nesting_depth.cmp(&a.nesting_depth))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.name.cmp(&b.name))
});
let display_count = rows.len().min(10);
println!(
" {:<36} {:>5} {:>8} {:>5}",
"Function".bold().underline(),
"Cycl".bold().underline(),
"Branches".bold().underline(),
"Nest".bold().underline(),
);
for fc in rows.into_iter().take(display_count) {
let name = if fc.name.len() > 34 {
format!("{}...", &fc.name[..31])
} else {
fc.name.clone()
};
println!(
" {:<36} {:>5} {:>8} {:>5}",
name.dimmed(),
fc.cyclomatic,
fc.cyclomatic.saturating_sub(1),
fc.nesting_depth,
);
}
if func_complexities.len() > display_count {
println!(
" {}",
format!(
"... and {} more functions",
func_complexities.len() - display_count
)
.dimmed()
);
}
}
}
/// Render the module connectome heatmap and coupling statistics.
fn render_connectome_section(dep_analysis: &deps::DepAnalysis, term_w: usize, verbose: bool) {
render::section_header("Module Connectome");
if verbose {
render::verbose_block(&[
"Heatmap of inter-module dependencies built from 'use' and 'mod' statements.",
"Cell values: 0 (dim) = no dependency, 1 (yellow) = unidirectional, 2 (red) = bidirectional.",
"Diagonal = · (a module cannot depend on itself).",
"Bidirectional (2, red) means both modules import each other — a coupling risk.",
"Top coupled pairs: ◆ bidi (red) = bidirectional, → uni (yellow) = one-way.",
"Fan-in (blue) = how many modules depend on me (high = core utility).",
"Fan-out (yellow) = how many modules I depend on (high = potential god module).",
]);
}
if dep_analysis.modules.is_empty() || dep_analysis.edges.is_empty() {
println!(" {}", "No inter-module dependencies detected.".dimmed());
} else {
render_mini_heatmap(dep_analysis, term_w);
render_connectome_details(dep_analysis);
}
}
/// Render coupling pairs, fan-in/fan-out, and edge counts for the connectome.
fn render_connectome_details(dep_analysis: &deps::DepAnalysis) {
let coupling_display = dep_analysis.coupling_pairs.len().min(5);
if coupling_display > 0 {
println!(" {}", "Top coupled pairs:".bold());
for (a, b, strength) in dep_analysis.coupling_pairs.iter().take(coupling_display) {
let kind = if *strength >= 2 {
"◆ bidi".red().bold().to_string()
} else {
"→ uni".yellow().to_string()
};
println!(" {} ↔ {} {}", a.dimmed(), b.dimmed(), kind);
}
}
println!();
let mut by_fan_in: Vec<(&String, usize)> = dep_analysis
.in_degree
.iter()
.map(|(m, d)| (m, *d))
.filter(|(_, d)| *d > 0)
.collect();
by_fan_in.sort_by(|a, b| b.1.cmp(&a.1));
let mut by_fan_out: Vec<(&String, usize)> = dep_analysis
.out_degree
.iter()
.map(|(m, d)| (m, *d))
.filter(|(_, d)| *d > 0)
.collect();
by_fan_out.sort_by(|a, b| b.1.cmp(&a.1));
if let Some((name, deg)) = by_fan_in.first() {
println!(
" {} {} (fan-in: {})",
"Highest fan-in:".bold(),
name.bright_blue(),
format!("{}", deg).bold(),
);
}
if let Some((name, deg)) = by_fan_out.first() {
println!(
" {} {} (fan-out: {})",
"Highest fan-out:".bold(),
name.yellow(),
format!("{}", deg).bold(),
);
}
let total_edges = dep_analysis.edges.len();
let bidi_count = dep_analysis
.coupling_pairs
.iter()
.filter(|(_, _, s)| *s >= 2)
.count();
println!(
" {} edges, {} bidirectional pairs",
format!("{}", total_edges).bold(),
format!("{}", bidi_count).bold(),
);
}
/// Render the sparkline metric distributions section.
fn render_distributions_section(
func_complexities: &[complexity::FunctionComplexity],
verbose: bool,
) {
render::section_header("Metric Distributions");
if verbose {
render::verbose_block(&[
"Sparklines show the sorted distribution shape for each metric.",
"Values are sorted ascending, then binned into ~40 buckets to form the sparkline.",
"▁ = low, █ = high. A flat line means uniform values; a steep ramp means outliers.",
"Range shows min–max. μ = arithmetic mean across all functions.",
]);
}
if !func_complexities.is_empty() {
render_sparkline_distributions(func_complexities);
} else {
println!(" {}", "No functions to analyze.".dimmed());
}
}
/// Render the graph centrality highlights section.
fn render_centrality_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) {
render::section_header("Graph Centrality");
if verbose {
render::verbose_block(&[
"Key nodes in the call graph, identified by three complementary metrics.",
"bc = betweenness centrality (Brandes algorithm): fraction of all shortest paths",
" passing through this node. High bc = bridge/bottleneck — removing it would",
" disconnect parts of the graph. Scale: 0.0 (no shortest paths) to 1.0 (all paths).",
"pr = PageRank (damping 0.85, 100 iterations): recursive importance — a node is",
" important if important nodes point to it. Higher = more central.",
"cc = clustering coefficient: fraction of a node's neighbors that are also connected",
" to each other. High cc = tight clique; low cc = broker between groups.",
]);
}
match graph::graph_summary(rs_files, project_path_canon) {
Some(gs) => render_graph_centrality(&gs),
None => println!(" {}", "No call graph data available.".dimmed()),
}
}
/// Render the static entrypoint reachability section.
fn render_reachability_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) {
render::section_header("Static entrypoint reachability");
if verbose {
render::verbose_block(&[
"Percentage of functions reachable from entry points via static call graph traversal.",
"Entry points are main(), #[test] functions, and benchmarks. Reachability is computed via BFS.",
"Reachability = reachable / total. Cold functions = unreachable = dead code candidates.",
"Color: green > 80%, yellow > 50%, red ≤ 50%.",
]);
}
match datapaths::entrypoint_reachability_summary(rs_files, project_path_canon) {
Some(ds) => render_entrypoint_reachability(&ds),
None => println!(" {}", "No reachability data available.".dimmed()),
}
}
/// Render the circuits overview section.
fn render_circuits_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) {
render::section_header("Circuits");
if verbose {
render::verbose_block(&[
"Communities detected by label propagation on the call graph.",
"Each node starts with its own label, then iteratively adopts the most frequent",
"neighbor label until stable. The result is functional clusters (circuits).",
"Modularity Q measures partition quality: Q > 0.3 (green) = good modular structure,",
"Q > 0 (yellow) = weak, Q ≤ 0 (red) = worse than random.",
"Sparkline shows relative circuit sizes (█ = largest, ▁ = smallest).",
"Sizes: min–max (avg) of functions per circuit.",
]);
}
match circuits::circuit_summary(rs_files, project_path_canon) {
Some(cs) => render_circuit_overview(&cs),
None => println!(" {}", "No circuit data available.".dimmed()),
}
}
/// Render the statistics footer with aggregate metrics.
fn render_stats_footer(
agg_loc: &Option<loc::AggregateStats>,
complexity_stats: &Option<complexity::ComplexityStats>,
total_files: usize,
total_functions: usize,
total_modules: usize,
verbose: bool,
) {
render::section_header("Statistics");
if verbose {
render::verbose_block(&[
"Aggregate statistics across all analyzed source files and functions.",
"LoC mean / std = average and standard deviation of code lines per file.",
"LoC median = middle value when files are sorted by size (robust to outliers).",
"Complexity mean / std = average and spread of composite complexity scores.",
"Complexity median = middle composite score across all functions.",
]);
}
let box_w = 42;
println!(" ┌{}┐", "─".repeat(box_w));
if let Some(agg) = agg_loc {
print_stat(
"LoC mean / std",
&format!("{:.1} / {:.1}", agg.mean, agg.std_dev),
box_w,
);
print_stat("LoC median", &format!("{}", agg.median), box_w);
}
if let Some(cs) = complexity_stats {
print_stat(
"Complexity mean / std",
&format!("{:.1} / {:.1}", cs.mean, cs.std_dev),
box_w,
);
print_stat("Complexity median", &format!("{:.1}", cs.median), box_w);
}
print_stat("Total files", &format!("{}", total_files), box_w);
print_stat("Total functions", &format!("{}", total_functions), box_w);
print_stat("Total modules", &format!("{}", total_modules), box_w);
println!(" └{}┘", "─".repeat(box_w));
println!();
}
// ── JSON Output ──────────────────────────────────────────────────
#[derive(Serialize)]
struct SummaryLocJson {
total_files: usize,
total_loc: usize,
mean: f64,
std_dev: f64,
median: usize,
min: usize,
max: usize,
}
#[derive(Serialize)]
struct SummaryComplexityJson {
total_functions: usize,
mean: f64,
std_dev: f64,
median: f64,
max: f64,
}
#[derive(Serialize)]
struct SummaryDepsJson {
total_modules: usize,
total_edges: usize,
bidirectional_pairs: usize,
highest_fan_in: Option<(String, usize)>,
highest_fan_out: Option<(String, usize)>,
}
#[derive(Serialize)]
struct SummaryGraphJson {
top_bridge: Option<(String, f64)>,
top_hub: Option<(String, f64)>,
top_clustered: Option<(String, f64)>,
}
#[derive(Serialize)]
struct SummaryReachabilityJson {
reachable_pct: f64,
total_functions: usize,
reachable_count: usize,
cold_count: usize,
}
#[derive(Serialize)]
struct SummaryCircuitsJson {
circuit_count: usize,
sizes: Vec<usize>,
modularity: f64,
}
#[derive(Serialize)]
struct SummaryJson {
cstat_version: String,
project: String,
loc: Option<SummaryLocJson>,
complexity: Option<SummaryComplexityJson>,
deps: SummaryDepsJson,
graph: Option<SummaryGraphJson>,
reachability: Option<SummaryReachabilityJson>,
circuits: Option<SummaryCircuitsJson>,
}
/// Render the summary as JSON.
pub fn render_summary_json(rs_files: &[PathBuf], project_path: &Path) {
let project_path_canon = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
let project_name = project_path_canon
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let (_file_stats, agg_loc) = loc::analyze_files(rs_files);
let symbols = ast_parser::parse_project(rs_files);
let func_complexities = complexity::compute_all(&symbols, &project_path_canon);
let complexity_stats = complexity::compute_stats(&func_complexities);
let dep_analysis = deps::analyze_deps(rs_files, &project_path_canon);
let loc_json = agg_loc.map(|a| SummaryLocJson {
total_files: a.total_files,
total_loc: a.total_loc,
mean: a.mean,
std_dev: a.std_dev,
median: a.median,
min: a.min,
max: a.max,
});
let complexity_json = complexity_stats.map(|s| SummaryComplexityJson {
total_functions: s.total_functions,
mean: s.mean,
std_dev: s.std_dev,
median: s.median,
max: s.max,
});
let mut by_fan_in: Vec<(&String, usize)> = dep_analysis
.in_degree
.iter()
.map(|(m, d)| (m, *d))
.filter(|(_, d)| *d > 0)
.collect();
by_fan_in.sort_by(|a, b| b.1.cmp(&a.1));
let mut by_fan_out: Vec<(&String, usize)> = dep_analysis
.out_degree
.iter()
.map(|(m, d)| (m, *d))
.filter(|(_, d)| *d > 0)
.collect();
by_fan_out.sort_by(|a, b| b.1.cmp(&a.1));
let bidi_count = dep_analysis
.coupling_pairs
.iter()
.filter(|(_, _, s)| *s >= 2)
.count();
let deps_json = SummaryDepsJson {
total_modules: dep_analysis.modules.len(),
total_edges: dep_analysis.edges.len(),
bidirectional_pairs: bidi_count,
highest_fan_in: by_fan_in.first().map(|(n, d)| ((*n).clone(), *d)),
highest_fan_out: by_fan_out.first().map(|(n, d)| ((*n).clone(), *d)),
};
let graph_json =
graph::graph_summary(rs_files, &project_path_canon).map(|gs| SummaryGraphJson {
top_bridge: gs.top_bridge,
top_hub: gs.top_hub,
top_clustered: gs.top_clustered,
});
let reachability_json =
datapaths::entrypoint_reachability_summary(rs_files, &project_path_canon).map(|ds| {
SummaryReachabilityJson {
reachable_pct: ds.reachable_pct,
total_functions: ds.total_functions,
reachable_count: ds.reachable_count,
cold_count: ds.cold_count,
}
});
let circuits_json =
circuits::circuit_summary(rs_files, &project_path_canon).map(|cs| SummaryCircuitsJson {
circuit_count: cs.circuit_count,
sizes: cs.sizes,
modularity: cs.modularity,
});
let output = SummaryJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
project: project_name,
loc: loc_json,
complexity: complexity_json,
deps: deps_json,
graph: graph_json,
reachability: reachability_json,
circuits: circuits_json,
};
println!("{}", serde_json::to_string(&output).unwrap());
}

374
src/summary/sections.rs Normal file
View file

@ -0,0 +1,374 @@
use colored::Colorize;
use std::collections::HashSet;
use std::path::Path;
use crate::circuits;
use crate::complexity;
use crate::datapaths;
use crate::deps;
use crate::graph;
use crate::loc;
/// Build a sparkline string from a slice of f64 values.
pub(super) fn sparkline(values: &[f64]) -> String {
if values.is_empty() {
return String::new();
}
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = (max - min).max(f64::EPSILON);
let num_bins = 40.min(values.len());
let bin_size = (values.len() as f64 / num_bins as f64).ceil() as usize;
let mut bins: Vec<f64> = Vec::new();
for chunk in values.chunks(bin_size) {
let avg = chunk.iter().sum::<f64>() / chunk.len() as f64;
bins.push(avg);
}
bins.iter()
.map(|&v| {
let idx = ((v - min) / range * 7.0).round() as usize;
blocks[idx.min(7)]
})
.collect()
}
/// Render single-line sparkline distributions for key metrics.
pub(super) fn render_sparkline_distributions(fcs: &[complexity::FunctionComplexity]) {
let metrics: Vec<(&str, Vec<f64>)> = vec![
(
"Function LoC",
fcs.iter().map(|f| f.line_count as f64).collect(),
),
(
"Cyclomatic",
fcs.iter().map(|f| f.cyclomatic as f64).collect(),
),
(
"Cognitive",
fcs.iter().map(|f| f.cognitive as f64).collect(),
),
(
"Nesting",
fcs.iter().map(|f| f.nesting_depth as f64).collect(),
),
("Params", fcs.iter().map(|f| f.param_count as f64).collect()),
];
for (name, mut values) in metrics {
if values.is_empty() {
continue;
}
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let spark = sparkline(&values);
let min = values.first().copied().unwrap_or(0.0);
let max = values.last().copied().unwrap_or(0.0);
let mean = values.iter().sum::<f64>() / values.len() as f64;
println!(
" {:<14} {} {:.0}–{:.0} (μ={:.1})",
name.dimmed(),
spark.bright_yellow(),
min,
max,
mean,
);
}
}
/// Render graph centrality highlights.
pub(super) fn render_graph_centrality(gs: &graph::GraphSummary) {
if let Some((name, val)) = &gs.top_bridge {
let short = if name.len() > 30 {
&name[name.len() - 30..]
} else {
name.as_str()
};
println!(
" {} {} (bc={:.4})",
"Top bridge:".bold(),
short.bright_blue(),
val,
);
}
if let Some((name, val)) = &gs.top_hub {
let short = if name.len() > 30 {
&name[name.len() - 30..]
} else {
name.as_str()
};
println!(" {} {} (pr={:.4})", "Top hub:".bold(), short.yellow(), val,);
}
if let Some((name, val)) = &gs.top_clustered {
let short = if name.len() > 30 {
&name[name.len() - 30..]
} else {
name.as_str()
};
println!(
" {} {} (cc={:.4})",
"Most clustered:".bold(),
short.green(),
val,
);
}
if gs.top_bridge.is_none() && gs.top_hub.is_none() && gs.top_clustered.is_none() {
println!(" {}", "No centrality data available.".dimmed());
}
}
/// Render static entrypoint reachability section.
pub(super) fn render_entrypoint_reachability(ds: &datapaths::EntrypointReachabilitySummary) {
let reachability_color = if ds.reachable_pct > 80.0 {
format!("{:.1}%", ds.reachable_pct)
.green()
.bold()
.to_string()
} else if ds.reachable_pct > 50.0 {
format!("{:.1}%", ds.reachable_pct)
.yellow()
.bold()
.to_string()
} else {
format!("{:.1}%", ds.reachable_pct).red().bold().to_string()
};
println!(
" {} {} reachable from entry points",
"Reachability:".bold(),
reachability_color,
);
println!(
" {} reachable, {} cold (of {} total)",
format!("{}", ds.reachable_count).bold(),
if ds.cold_count > 0 {
format!("{}", ds.cold_count).red().to_string()
} else {
format!("{}", ds.cold_count).green().to_string()
},
ds.total_functions,
);
}
/// Render circuit overview section.
pub(super) fn render_circuit_overview(cs: &circuits::CircuitSummary) {
let blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let size_spark: String = if cs.sizes.len() > 1 {
let max_s = *cs.sizes.iter().max().unwrap_or(&1) as f64;
cs.sizes
.iter()
.map(|&s| {
let idx = ((s as f64 / max_s) * 7.0).round() as usize;
blocks[idx.min(7)]
})
.collect()
} else if cs.sizes.len() == 1 {
"█".to_string()
} else {
String::new()
};
println!(
" {} {} circuit{} {}",
"Circuits:".bold(),
format!("{}", cs.circuit_count).bold(),
if cs.circuit_count == 1 { "" } else { "s" },
size_spark.bright_yellow(),
);
let mod_color = if cs.modularity > 0.3 {
format!("{:.4}", cs.modularity).green().bold().to_string()
} else if cs.modularity > 0.0 {
format!("{:.4}", cs.modularity).yellow().to_string()
} else {
format!("{:.4}", cs.modularity).red().to_string()
};
println!(" {} {}", "Modularity:".bold(), mod_color,);
if !cs.sizes.is_empty() {
let max_s = cs.sizes.iter().max().unwrap_or(&0);
let min_s = cs.sizes.iter().min().unwrap_or(&0);
let avg_s = cs.sizes.iter().sum::<usize>() as f64 / cs.sizes.len() as f64;
println!(" Sizes: {}–{} (avg {:.1})", min_s, max_s, avg_s,);
}
}
pub(super) fn print_stat(label: &str, value: &str, box_w: usize) {
let inner = box_w - 2;
let val_w = inner.saturating_sub(label.len() + 3);
println!(" │ {} {:>w$} │", label.cyan(), value.bold(), w = val_w,);
}
pub(super) fn short_path(path: &Path, base: &Path) -> String {
let rel = path.strip_prefix(base).unwrap_or(path);
rel.display().to_string()
}
pub(super) fn short_path_truncated(path: &Path, base: &Path, max_len: usize) -> String {
let s = short_path(path, base);
if s.len() > max_len {
format!("...{}", &s[s.len() - (max_len - 3)..])
} else {
s
}
}
/// Render a compact mini heatmap matrix in the summary's connectome section.
pub(super) fn render_mini_heatmap(analysis: &deps::DepAnalysis, term_w: usize) {
let mut module_connectivity: Vec<(&String, usize)> = analysis
.modules
.iter()
.map(|m| {
let in_d = analysis.in_degree.get(m).copied().unwrap_or(0);
let out_d = analysis.out_degree.get(m).copied().unwrap_or(0);
(m, in_d + out_d)
})
.collect();
module_connectivity.sort_by(|a, b| b.1.cmp(&a.1));
let display_modules: Vec<&String> = module_connectivity
.iter()
.filter(|(_, c)| *c > 0)
.take(8)
.map(|(m, _)| *m)
.collect();
if display_modules.is_empty() {
return;
}
let edge_set: HashSet<(&str, &str)> = analysis
.edges
.iter()
.map(|e| (e.from.as_str(), e.to.as_str()))
.collect();
let n = display_modules.len();
let short_names: Vec<&str> = display_modules
.iter()
.map(|m| m.rsplit("::").next().unwrap_or(m))
.collect();
let max_short = short_names
.iter()
.map(|s| s.len())
.max()
.unwrap_or(3)
.min(12);
let matrix_cols = max_short + 3 + n * 2 + 4;
if matrix_cols > term_w {
return;
}
print!(" {:>width$} ", "", width = max_short);
for i in 0..n {
print!("{} ", format!("{}", i).dimmed());
}
println!();
for (ri, row_mod) in display_modules.iter().enumerate() {
let label = if short_names[ri].len() > max_short {
&short_names[ri][..max_short]
} else {
short_names[ri]
};
print!(" {:>width$} │ ", label.dimmed(), width = max_short);
for (ci, col_mod) in display_modules.iter().enumerate() {
if ri == ci {
print!("{} ", "·".dimmed());
} else {
let fwd = edge_set.contains(&(row_mod.as_str(), col_mod.as_str()));
let rev = edge_set.contains(&(col_mod.as_str(), row_mod.as_str()));
let strength = (if fwd { 1 } else { 0 }) + (if rev { 1 } else { 0 });
match strength {
0 => print!("{} ", "0".dimmed()),
1 => print!("{} ", "1".yellow()),
_ => print!("{} ", "2".red().bold()),
}
}
}
println!("{}", format!("{}", ri).dimmed());
}
println!(" {} {} {}", "0".dimmed(), "1".yellow(), "2".red().bold(),);
println!();
}
/// Render a proportional directory breakdown showing LoC distribution by directory.
pub(super) fn render_dir_breakdown(
file_stats: &[loc::FileLocStats],
project_path: &Path,
term_w: usize,
total_loc: usize,
) {
use std::collections::BTreeMap;
if total_loc == 0 {
return;
}
let mut dir_loc: BTreeMap<String, usize> = BTreeMap::new();
for stat in file_stats {
let rel = stat.path.strip_prefix(project_path).unwrap_or(&stat.path);
let components: Vec<_> = rel.components().collect();
let dir_name = if components.len() <= 1 {
".".to_string()
} else {
components[0].as_os_str().to_string_lossy().to_string()
};
*dir_loc.entry(dir_name).or_insert(0) += stat.code_lines;
}
if dir_loc.is_empty() {
return;
}
let mut dirs: Vec<(String, usize)> = dir_loc.into_iter().collect();
dirs.sort_by(|a, b| b.1.cmp(&a.1));
println!();
println!(" {}", "Directory breakdown:".bold());
let max_dir_label = dirs.iter().map(|(d, _)| d.len()).max().unwrap_or(3).min(20);
let bar_budget = term_w
.saturating_sub(max_dir_label + 4)
.saturating_sub(12)
.max(10);
for (dir, loc) in &dirs {
let pct = *loc as f64 / total_loc as f64 * 100.0;
let bar_len = (*loc as f64 / total_loc as f64 * bar_budget as f64).ceil() as usize;
let bar_len = bar_len.max(if *loc > 0 { 1 } else { 0 });
let ratio = *loc as f64 / total_loc as f64;
let color_fn = |t: &str| -> String {
if ratio > 0.5 {
t.red().bold().to_string()
} else if ratio > 0.25 {
t.yellow().to_string()
} else {
t.green().to_string()
}
};
let bar = color_fn(&"█".repeat(bar_len));
let padding = " ".repeat(bar_budget.saturating_sub(bar_len));
let label = if dir.len() > max_dir_label {
format!("..{}", &dir[dir.len() - (max_dir_label - 2)..])
} else {
format!("{}/", dir)
};
println!(
" {:>width$} {}{} {:>5.1}%",
label.dimmed(),
bar,
padding,
pct,
width = max_dir_label + 1,
);
}
}

View file

@ -50,14 +50,6 @@ fn call_trace_infers_cargo_run_entry() {
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("cstat call trace"), "stdout={stdout}");
assert!(
stdout.contains("static possible-call expansion; not a runtime execution trace"),
"stdout={stdout}"
);
assert!(
stdout.contains("static expansion summary:"),
"stdout={stdout}"
);
assert!(
stdout.contains("cargo command: cargo run --"),
"stdout={stdout}"

View file

@ -63,16 +63,6 @@ fn run_cstat(path: &Path, export: &Path, args: &[&str]) -> Output {
command.output().expect("invoke cstat binary")
}
fn run_cstat_without_export(path: &Path, args: &[&str]) -> Output {
let bin = env!("CARGO_BIN_EXE_cstat");
let mut command = Command::new(bin);
command.args(["--no-color", "--path"]);
command.arg(path);
command.args(args);
command.env_remove("CSTAT_LLVM_COV_EXPORT_JSON");
command.output().expect("invoke cstat binary")
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
@ -83,32 +73,6 @@ fn assert_success(output: &Output) {
);
}
#[test]
fn coverage_verbose_prints_guidance_before_no_run_env_error() {
let root = temp_project("missing-env");
let output = run_cstat_without_export(&root, &["coverage", "--no-run", "-v"]);
assert!(
!output.status.success(),
"coverage --no-run without export env should fail"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Normal mode runs cargo test with Rust source-based coverage flags"),
"stdout={stdout}"
);
assert!(
stdout.contains("Use coverage --no-run with CSTAT_LLVM_COV_EXPORT_JSON"),
"stdout={stdout}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("CSTAT_LLVM_COV_EXPORT_JSON is required"),
"stderr={stderr}"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn coverage_no_run_reports_dynamic_lines_and_branches_from_export_json() {
let root = temp_project("json");
@ -152,7 +116,7 @@ fn coverage_human_separates_static_projected_dynamic_and_branch_sections() {
let root = temp_project("human");
let file = root.join("src/lib.rs");
let export = export_json(&root);
let output = run_cstat(&file, &export, &["coverage", "--no-run", "-v"]);
let output = run_cstat(&file, &export, &["coverage", "--no-run"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
@ -162,27 +126,13 @@ fn coverage_human_separates_static_projected_dynamic_and_branch_sections() {
"Dynamic line coverage",
"Branch coverage",
] {
assert_eq!(
stdout
.lines()
.filter(|line| line.trim_start().starts_with(&format!("┌─ {section}")))
.count(),
1,
"section header should appear once: {section}: {stdout}"
);
}
for expected in [
"Normal mode runs cargo test with Rust source-based coverage flags",
"Line coverage is dynamic llvm-cov segment data; branch coverage uses branch records when the Rust/LLVM toolchain emits them.",
"Reachability is computed from the static call graph; it is not runtime coverage.",
"Projected reachable lines are production code lines inside functions statically reached from project tests/benches.",
"Coverable/executed lines come from llvm-cov segment counts after cstat's production-span filter.",
"Branch coverage counts true/false outcomes from llvm-cov branch records.",
"missing lines: 5",
"line 2: flag missing false",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
assert!(stdout.contains(section), "missing {section}: {stdout}");
}
assert!(stdout.contains("missing lines: 5"), "stdout={stdout}");
assert!(
stdout.contains("line 2: flag missing false"),
"stdout={stdout}"
);
assert!(!stdout.contains("Path coverage"), "stdout={stdout}");
fs::remove_dir_all(root).unwrap();

View file

@ -1,202 +0,0 @@
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_project(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("cstat-deps-cli-{name}-{unique}"));
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
fs::write(root.join("src/lib.rs"), "pub mod alpha;\npub mod beta;\n").unwrap();
fs::write(
root.join("src/alpha.rs"),
r#"pub struct Shared;
pub fn shared(input: Shared) -> Shared {
input
}
pub fn closure_owner(input: Shared) -> Shared {
let first = || shared(input);
let second = || shared(Shared);
first();
second()
}
pub fn sibling(input: Shared) -> Shared {
shared(input)
}
"#,
)
.unwrap();
fs::write(
root.join("src/beta.rs"),
r#"use crate::alpha::shared;
pub fn beta() {
let _ = shared(crate::alpha::Shared);
}
"#,
)
.unwrap();
root
}
fn run_cstat(project: &Path, args: &[&str]) -> Output {
let bin = env!("CARGO_BIN_EXE_cstat");
let mut command = Command::new(bin);
command.args(["--no-color", "--path"]);
command.arg(project);
command.args(args);
command.output().expect("invoke cstat binary")
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"cstat failed: status={:?}\nstderr={}\nstdout={}",
output.status,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
}
#[test]
fn deps_human_verbose_shows_actual_one_way_direction() {
let root = temp_project("human-direction");
let output = run_cstat(&root, &["deps", "--top", "10", "-v"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Cells are pair strength, not edge direction"),
"stdout={stdout}"
);
assert!(
stdout.contains("One-way rows show actual source module -> target module direction."),
"stdout={stdout}"
);
assert!(stdout.contains("signature type tokens"), "stdout={stdout}");
let directed_line = stdout
.lines()
.find(|line| line.contains("beta") && line.contains("alpha") && line.contains("one-way"))
.unwrap_or_else(|| panic!("missing beta -> alpha coupling line: {stdout}"));
assert!(
directed_line.find("beta") < directed_line.find("alpha"),
"line should show source before target: {directed_line}"
);
assert!(
!stdout.contains("alpha::closure_owner"),
"function-scope pseudo-module leaked into cohesion output: {stdout}"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn deps_json_coupling_edges_and_cohesion_use_source_modules() {
let root = temp_project("json-direction");
let output = run_cstat(&root, &["--json", "deps"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse deps JSON");
let pairs = value["coupling_pairs"]
.as_array()
.expect("coupling_pairs array");
let alpha_beta = pairs
.iter()
.find(|pair| pair["module_a"] == "alpha" && pair["module_b"] == "beta")
.unwrap_or_else(|| panic!("missing alpha/beta pair: {value}"));
assert_eq!(alpha_beta["direction"], "unidirectional");
assert_eq!(
alpha_beta["edges"],
serde_json::json!([{ "from": "beta", "to": "alpha" }]),
"pair={alpha_beta}"
);
let cohesion = value["cohesion"].as_array().expect("cohesion array");
let modules = cohesion
.iter()
.filter_map(|row| row["module"].as_str())
.collect::<Vec<_>>();
assert!(modules.contains(&"alpha"), "modules={modules:?}");
assert!(modules.contains(&"beta"), "modules={modules:?}");
assert!(
!modules
.iter()
.any(|module| module.contains("closure_owner")),
"cohesion should use source modules, not function scopes: {modules:?}"
);
let alpha = cohesion
.iter()
.find(|row| row["module"] == "alpha")
.unwrap_or_else(|| panic!("missing alpha cohesion row: {value}"));
assert_eq!(alpha["function_count"], 3);
fs::remove_dir_all(root).unwrap();
}
fn nested_project(name: &str) -> PathBuf {
let root = temp_project(name);
fs::remove_file(root.join("src/alpha.rs")).unwrap();
fs::remove_file(root.join("src/beta.rs")).unwrap();
fs::write(root.join("src/lib.rs"), "pub mod parent;\n").unwrap();
fs::create_dir_all(root.join("src/parent")).unwrap();
fs::write(
root.join("src/parent/mod.rs"),
r#"mod child;
pub struct Shared;
pub use child::child;
"#,
)
.unwrap();
fs::write(
root.join("src/parent/child.rs"),
r#"use super::Shared;
pub fn child(_: Shared) {}
"#,
)
.unwrap();
root
}
#[test]
fn deps_json_resolves_nested_mod_and_super_as_bidirectional_pair() {
let root = nested_project("nested-bidir");
let output = run_cstat(&root, &["--json", "deps"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse deps JSON");
let pairs = value["coupling_pairs"]
.as_array()
.expect("coupling_pairs array");
let parent_child = pairs
.iter()
.find(|pair| pair["module_a"] == "parent" && pair["module_b"] == "parent::child")
.unwrap_or_else(|| panic!("missing parent/child pair: {value}"));
assert_eq!(parent_child["direction"], "bidirectional");
assert_eq!(
parent_child["edges"],
serde_json::json!([
{ "from": "parent", "to": "parent::child" },
{ "from": "parent::child", "to": "parent" }
]),
"pair={parent_child}"
);
fs::remove_dir_all(root).unwrap();
}

View file

@ -94,6 +94,165 @@ fn assert_success(output: &Output) {
);
}
fn assert_json_array_contains(array: &Value, expected: &str) {
let values = array.as_array().expect("json array");
assert!(
values.iter().any(|value| value == expected),
"expected {expected:?} in {values:?}",
);
}
#[test]
fn file_summary_human_accepts_file_path() {
let root = temp_project("human");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["-v", "summary"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("cstat file summary (src/lib.rs)"),
"stdout={stdout}"
);
for section in [
"Projected line reachability",
"Static test/benchmark reachability",
"Symbols",
"Dependencies",
"Dead code candidates",
"Call trace size",
] {
assert!(
stdout.contains(section),
"missing section {section}: {stdout}"
);
}
for full_section in [
"ℹ Full section: cstat loc --path src/lib.rs -v",
"ℹ Full section: cstat test-reachability --path src/lib.rs -v",
"ℹ Full section: cstat symbols --path src/lib.rs -v",
"ℹ Full section: cstat deps --path src/lib.rs -v",
"ℹ Full section: cstat dead-code --path src/lib.rs -v",
"ℹ Full section: cstat call-trace --path src/lib.rs -v",
] {
assert!(
stdout.contains(full_section),
"missing verbose line {full_section}: {stdout}",
);
}
for expected in [
"fn 6", "struct 1", "enum 1", "trait 1", "const 1", "static 1",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
assert!(stdout.contains("test/bench roots 1"), "stdout={stdout}");
assert!(
stdout.contains("not statically reachable:"),
"stdout={stdout}"
);
assert!(stdout.contains("orphan"), "stdout={stdout}");
assert!(stdout.contains("dead_child"), "stdout={stdout}");
assert!(stdout.contains("in-file fan-in"), "stdout={stdout}");
for expected in ["Call trace size", "live", "dead_parent", "orphan"] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn file_summary_json_is_untruncated_and_file_scoped() {
let root = temp_project("json");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["--json", "summary"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse file summary JSON");
assert_eq!(value["file"], "src/lib.rs");
assert_eq!(value["project"], "fixture");
for field in [
"line_counts",
"static_reachability",
"symbols",
"dependencies",
"dead_code",
"call_trace",
] {
assert!(value.get(field).is_some(), "missing {field}: {stdout}");
}
assert_eq!(value["symbols"]["functions"], 6);
assert_eq!(value["symbols"]["structs"], 1);
assert_eq!(value["symbols"]["enums"], 1);
assert_eq!(value["symbols"]["traits"], 1);
assert_eq!(value["symbols"]["consts"], 1);
assert_eq!(value["symbols"]["statics"], 1);
assert_eq!(
value["static_reachability"]["test_benchmark_entry_count"],
1
);
assert_json_array_contains(&value["static_reachability"]["reachable_functions"], "live");
assert_json_array_contains(
&value["static_reachability"]["reachable_functions"],
"helper",
);
for function in ["orphan", "dead_parent", "dead_child"] {
assert_json_array_contains(
&value["static_reachability"]["unreachable_functions"],
function,
);
}
let dead_code = value["dead_code"]["functions"]
.as_array()
.expect("dead code functions");
let dead_child = dead_code
.iter()
.find(|entry| entry["function"] == "dead_child")
.expect("dead_child dead-code row");
assert_eq!(dead_child["candidate"], true);
assert_eq!(dead_child["in_file_fan_in"], 1);
assert!(
dead_child["reason"]
.as_str()
.unwrap()
.contains("no test/benchmark root reaches that caller"),
"dead_child reason={:?}",
dead_child["reason"]
);
let orphan = dead_code
.iter()
.find(|entry| entry["function"] == "orphan")
.expect("orphan dead-code row");
assert!(
orphan["reason"]
.as_str()
.unwrap()
.contains("no direct, trait, drop, function-item, or closure edge"),
"orphan reason={:?}",
orphan["reason"]
);
let traces = value["call_trace"]["functions"]
.as_array()
.expect("call trace functions");
let live = traces
.iter()
.find(|entry| entry["function"] == "live")
.expect("live trace row");
assert!(
live["functions_reached"].as_u64().unwrap() > 1,
"live trace={live:?}",
);
let orphan = traces
.iter()
.find(|entry| entry["function"] == "orphan")
.expect("orphan trace row");
assert_eq!(orphan["functions_reached"], 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn file_section_commands_accept_file_path() {
let root = temp_project("sections");
@ -133,7 +292,7 @@ fn file_path_errors_are_clear() {
let notes = root.join("notes.rs");
fs::write(&notes, "fn scratch() {}\n").unwrap();
let output = run_cstat(&notes, &["loc"]);
let output = run_cstat(&notes, &["summary"]);
assert!(
!output.status.success(),
"unexpected success: stdout={} stderr={}",
@ -146,7 +305,7 @@ fn file_path_errors_are_clear() {
"stderr={stderr}",
);
let output = run_cstat(&notes, &["--json", "loc"]);
let output = run_cstat(&notes, &["--json", "summary"]);
assert!(
!output.status.success(),
"unexpected json success: stdout={} stderr={}",

View file

@ -1,414 +0,0 @@
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_project(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("cstat-scorecard-{name}-{unique}"));
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
root
}
fn run_cstat(project: &Path, args: &[&str]) -> Output {
let bin = env!("CARGO_BIN_EXE_cstat");
let mut command = Command::new(bin);
command.args(["--no-color", "--path"]);
command.arg(project);
command.args(args);
command.output().expect("invoke cstat binary")
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"cstat failed: status={:?}\nstderr={}\nstdout={}",
output.status,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
}
fn score(project: &Path) -> Value {
let output = run_cstat(project, &["--json", "scorecard"]);
assert_success(&output);
serde_json::from_slice(&output.stdout).expect("parse scorecard JSON")
}
fn component(value: &Value, name: &str) -> f64 {
value["component_costs"][name]["cost"]
.as_f64()
.unwrap_or_else(|| panic!("missing component {name}: {value}"))
}
fn total(value: &Value) -> f64 {
value["code_complexity_cost"]
.as_f64()
.unwrap_or_else(|| panic!("missing total: {value}"))
}
fn write_main(root: &Path, source: &str) {
fs::write(root.join("src/main.rs"), source).unwrap();
}
#[test]
fn scorecard_json_contract_is_stable_and_purely_structural() {
let root = temp_project("json-contract");
fs::create_dir_all(root.join("tests")).unwrap();
fs::create_dir_all(root.join("benches")).unwrap();
write_main(
&root,
r#"fn main() {
live();
}
fn live() -> usize {
1
}
fn branchy(input: usize) -> usize {
let mut total = 0;
if input > 0 { total += 1; }
if input > 1 { total += 1; }
if input > 2 { total += 1; }
if input > 3 { total += 1; }
if input > 4 { total += 1; }
total
}
"#,
);
fs::write(
root.join("tests/smoke.rs"),
"#[test]\nfn smoke() { assert_eq!(1 + 1, 2); }\n",
)
.unwrap();
fs::write(root.join("benches/bench.rs"), "fn bench_helper() {}\n").unwrap();
let value = score(&root);
assert_eq!(value["cstat_version"], env!("CARGO_PKG_VERSION"));
assert_eq!(value["score_version"], "code_complexity_cost_v0");
for field in [
"target",
"code_complexity_cost",
"code_complexity_cost_per_kloc",
"component_costs",
"scope_breakdown",
"top_contributors",
"metadata",
] {
assert!(value.get(field).is_some(), "missing {field}: {value}");
}
assert!(
value["component_costs"].get("verification_gap").is_none(),
"scorecard must not mix harness verification into code complexity: {value}",
);
for scope in ["production", "tests", "benches"] {
assert!(
value["scope_breakdown"].get(scope).is_some(),
"missing scope {scope}: {value}",
);
}
assert!(
value["top_contributors"]
.as_array()
.expect("top array")
.len()
<= 20,
"default top contributor count should be bounded: {value}",
);
let repeated = score(&root);
assert_eq!(value, repeated, "scorecard should be deterministic");
let human = run_cstat(&root, &["scorecard", "--top", "3"]);
assert_success(&human);
let stdout = String::from_utf8_lossy(&human.stdout);
for expected in [
"Code complexity score",
"score version: code_complexity_cost_v0",
"lower is cleaner; harness guards behavior separately",
"components:",
"top contributors:",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn deliberately_messy_code_scores_worse_than_simple_code() {
let clean = temp_project("clean");
write_main(
&clean,
r#"fn main() {
println!("{}", live());
}
fn live() -> usize {
1
}
"#,
);
let messy = temp_project("messy");
let mut source = String::from(
r#"fn main() {
println!("{}", tangled(12));
}
fn tangled(input: usize) -> usize {
let mut total = 0;
if input > 0 { total += 1; }
if input > 1 { total += 1; }
if input > 2 { total += 1; }
if input > 3 { total += 1; }
if input > 4 { total += 1; }
if input > 5 { total += 1; }
if input > 6 { total += 1; }
if input > 7 {
if input > 8 {
if input > 9 {
total += input;
}
}
}
"#,
);
for index in 0..70 {
source.push_str(&format!(" let value_{index} = {index};\n"));
}
source.push_str(" total\n}\n");
for index in 0..30 {
source.push_str(&format!("fn helper_{index}() -> usize {{ {index} }}\n"));
}
write_main(&messy, &source);
let clean_score = score(&clean);
let messy_score = score(&messy);
assert!(
total(&messy_score) > total(&clean_score),
"messy code should cost more: clean={clean_score} messy={messy_score}",
);
assert!(
component(&messy_score, "function_complexity")
> component(&clean_score, "function_complexity"),
"messy function shape should cost more: clean={clean_score} messy={messy_score}",
);
assert!(
component(&messy_score, "file_concentration")
> component(&clean_score, "file_concentration"),
"messy file concentration should cost more: clean={clean_score} messy={messy_score}",
);
fs::remove_dir_all(clean).unwrap();
fs::remove_dir_all(messy).unwrap();
}
#[test]
fn adding_branching_nesting_and_large_body_does_not_reduce_function_cost() {
let root = temp_project("function-monotonic");
write_main(
&root,
r#"fn main() {
println!("{}", value(1));
}
fn value(input: usize) -> usize {
input + 1
}
"#,
);
let before = score(&root);
let mut source = String::from(
r#"fn main() {
println!("{}", value(1));
}
fn value(input: usize) -> usize {
let mut total = input;
if input > 0 { total += 1; }
if input > 1 { total += 1; }
if input > 2 { total += 1; }
if input > 3 { total += 1; }
if input > 4 { total += 1; }
if input > 5 {
if input > 6 {
if input > 7 {
total += input;
}
}
}
"#,
);
for index in 0..80 {
source.push_str(&format!(" total += {index};\n"));
}
source.push_str(" total\n}\n");
write_main(&root, &source);
let after = score(&root);
assert!(
total(&after) > total(&before),
"larger, branchier body should not reduce total cost: before={before} after={after}",
);
assert!(
component(&after, "function_complexity") > component(&before, "function_complexity"),
"larger, branchier body should increase function cost: before={before} after={after}",
);
assert!(
component(&after, "file_concentration") >= component(&before, "file_concentration"),
"larger body should not reduce file concentration: before={before} after={after}",
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn symbol_piles_increase_abstraction_surface_and_file_concentration() {
let root = temp_project("symbol-pile");
write_main(
&root,
r#"fn main() {
println!("ok");
}
"#,
);
let before = score(&root);
let mut source = String::from(
r#"fn main() {
println!("ok");
}
"#,
);
for index in 0..18 {
source.push_str(&format!("struct Data{index};\n"));
}
for index in 0..12 {
source.push_str(&format!("enum Choice{index} {{ A, B }}\n"));
}
for index in 0..30 {
source.push_str(&format!("fn helper_{index}() -> usize {{ {index} }}\n"));
}
write_main(&root, &source);
let after = score(&root);
assert!(
component(&after, "abstraction_surface") > component(&before, "abstraction_surface"),
"symbol pile should increase abstraction surface: before={before} after={after}",
);
assert!(
component(&after, "file_concentration") > component(&before, "file_concentration"),
"symbol pile should increase file concentration: before={before} after={after}",
);
assert!(
total(&after) > total(&before),
"symbol pile should increase total cost: before={before} after={after}",
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn fan_out_and_bidirectional_dependencies_increase_module_coupling() {
let root = temp_project("coupling");
fs::write(root.join("src/lib.rs"), "pub mod root;\npub mod a;\n").unwrap();
fs::write(root.join("src/root.rs"), "pub fn touch() {}\n").unwrap();
fs::write(root.join("src/a.rs"), "pub fn a() {}\n").unwrap();
let before = score(&root);
fs::write(
root.join("src/lib.rs"),
"pub mod root;\npub mod a;\npub mod b;\npub mod c;\npub mod d;\npub mod e;\npub mod f;\n",
)
.unwrap();
fs::write(
root.join("src/root.rs"),
r#"use crate::a::a;
use crate::b::b;
use crate::c::c;
use crate::d::d;
use crate::e::e;
use crate::f::f;
pub fn touch() {
a(); b(); c(); d(); e(); f();
}
"#,
)
.unwrap();
fs::write(
root.join("src/a.rs"),
"use crate::root::touch;\npub fn a() { touch(); }\n",
)
.unwrap();
for module in ["b", "c", "d", "e", "f"] {
fs::write(
root.join(format!("src/{module}.rs")),
format!("pub fn {module}() {{}}\n"),
)
.unwrap();
}
let after = score(&root);
assert!(
component(&after, "module_coupling") > component(&before, "module_coupling"),
"fan-out and bidirectional pair should increase coupling: before={before} after={after}",
);
assert!(
total(&after) > total(&before),
"coupling fixture should increase total cost: before={before} after={after}",
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn adding_static_dead_code_candidate_increases_stale_surface() {
let root = temp_project("stale");
write_main(
&root,
r#"fn main() {
live();
}
fn live() {}
"#,
);
let before = score(&root);
write_main(
&root,
r#"fn main() {
live();
}
fn live() {}
fn unused_candidate() {}
"#,
);
let after = score(&root);
assert!(
component(&after, "stale_surface") > component(&before, "stale_surface"),
"unused private function should increase stale surface: before={before} after={after}",
);
assert!(
total(&after) > total(&before),
"unused private function should increase total cost: before={before} after={after}",
);
fs::remove_dir_all(root).unwrap();
}

View file

@ -188,21 +188,21 @@ fn test_reachability_json_uses_reachability_fields() {
}
#[test]
fn stale_summary_report_and_advanced_commands_are_unavailable() {
let root = temp_project("stale-commands");
fn focused_report_json_renames_top_level_field() {
let root = temp_project("report-json");
let output = run_cstat(&root, &["--json", "report"]);
assert_success(&output);
for args in [
&["summary"][..],
&["report"][..],
&["advanced"][..],
&["advanced", "flow-heatmap", "--walks", "10"][..],
] {
let output = run_cstat(&root, args);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse focused report JSON");
assert!(value.get("test_reachability").is_some());
assert!(value.get("datapaths").is_none());
assert_eq!(value["test_reachability"]["reachable_function_count"], 3);
assert_eq!(value["test_reachability"]["unreachable_function_count"], 1);
for field in ["line_counts", "symbols", "dependencies", "dead_code"] {
assert!(
!output.status.success(),
"stale command unexpectedly succeeded for {args:?}: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
value.get(field).is_some(),
"missing focused report field: {field}"
);
}
@ -210,7 +210,52 @@ fn stale_summary_report_and_advanced_commands_are_unavailable() {
}
#[test]
fn help_lists_focused_commands_and_omits_pruned_commands() {
fn advanced_flow_heatmap_runs_legacy_random_walk_analysis() {
let root = temp_project("advanced");
let output = run_cstat(&root, &["advanced", "flow-heatmap", "--walks", "10"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Execution Flow Heatmap"), "stdout={stdout}");
assert!(stdout.contains("random walks"), "stdout={stdout}");
assert!(
!stdout.contains("Static test/benchmark reachability"),
"stdout={stdout}"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn old_focused_datapaths_command_is_not_available() {
let root = temp_project("old-command");
let output = run_cstat(&root, &["datapaths"]);
assert!(
!output.status.success(),
"legacy root command unexpectedly succeeded: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn old_advanced_datapaths_command_is_not_available() {
let root = temp_project("old-advanced-command");
let output = run_cstat(&root, &["advanced", "datapaths"]);
assert!(
!output.status.success(),
"legacy advanced command unexpectedly succeeded: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn help_lists_test_reachability_and_advanced_flow_heatmap() {
let root = temp_project("help");
let root_help = run_cstat(&root, &["--help"]);
@ -224,14 +269,30 @@ fn help_lists_focused_commands_and_omits_pruned_commands() {
root_stdout.contains("Static test/benchmark reachability"),
"stdout={root_stdout}"
);
for pruned in ["summary", "report", "advanced", "datapaths"] {
assert!(
!root_stdout
.lines()
.any(|line| line.trim_start().starts_with(pruned)),
"help still listed pruned command {pruned}: {root_stdout}"
);
}
assert!(
!root_stdout
.lines()
.any(|line| line.starts_with(" datapaths")),
"root help still listed datapaths: {root_stdout}"
);
let advanced_help = run_cstat(&root, &["advanced", "--help"]);
assert_success(&advanced_help);
let advanced_stdout = String::from_utf8_lossy(&advanced_help.stdout);
assert!(
advanced_stdout.contains("flow-heatmap"),
"stdout={advanced_stdout}"
);
assert!(
!advanced_stdout
.lines()
.any(|line| line.starts_with(" datapaths")),
"advanced help still listed datapaths: {advanced_stdout}"
);
assert!(
advanced_stdout.contains("random-walk") || advanced_stdout.contains("heatmap"),
"stdout={advanced_stdout}"
);
fs::remove_dir_all(root).unwrap();
}