Compare commits
No commits in common. "2b14234d6c051a90ae2d62e64b3293f8db626e25" and "33114847122813ccc7d63141495744b2a0013a40" have entirely different histories.
2b14234d6c
...
3311484712
37 changed files with 9352 additions and 2460 deletions
2
.mono/config
Normal file
2
.mono/config
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[project]
|
||||
name = "code-analysis"
|
||||
Binary file not shown.
1
.mono/refs/HEAD
Normal file
1
.mono/refs/HEAD
Normal file
|
|
@ -0,0 +1 @@
|
|||
c813292f1400c21462beea82e2f3bcb5a3b7cc8dc8ca26010cd530d6f9166658
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
687
src/circuits.rs
Normal 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
108
src/commands.rs
Normal 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
178
src/coverage.rs
178
src/coverage.rs
|
|
@ -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");
|
||||
|
|
|
|||
583
src/datapaths.rs
583
src/datapaths.rs
|
|
@ -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(¤t) {
|
||||
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(¤t) {
|
||||
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(¤t) {
|
||||
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::*;
|
||||
|
|
|
|||
153
src/deps/mod.rs
153
src/deps/mod.rs
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
699
src/diagnostics/mod.rs
Normal 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
220
src/diagnostics/scoring.rs
Normal 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
706
src/dist.rs
Normal 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
248
src/dump.rs
Normal 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
507
src/file_summary.rs
Normal 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
|
||||
}
|
||||
}
|
||||
395
src/flow.rs
395
src/flow.rs
|
|
@ -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
411
src/graph/algorithms.rs
Normal 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
511
src/graph/mod.rs
Normal 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
467
src/guide.rs
Normal 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());
|
||||
}
|
||||
228
src/main.rs
228
src/main.rs
|
|
@ -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
867
src/map.rs
Normal 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[¤t];
|
||||
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
673
src/redundancy.rs
Normal 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
298
src/report.rs
Normal 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(°ree.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
|
||||
}
|
||||
}
|
||||
980
src/scorecard.rs
980
src/scorecard.rs
|
|
@ -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
644
src/summary/mod.rs
Normal 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
374
src/summary/sections.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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(¬es, "fn scratch() {}\n").unwrap();
|
||||
|
||||
let output = run_cstat(¬es, &["loc"]);
|
||||
let output = run_cstat(¬es, &["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(¬es, &["--json", "loc"]);
|
||||
let output = run_cstat(¬es, &["--json", "summary"]);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"unexpected json success: stdout={} stderr={}",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue