stash current state

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-19 11:56:08 +04:00
parent 0414610d60
commit d699420eb1
20 changed files with 7306 additions and 182 deletions

View file

@ -21,6 +21,7 @@ pub struct FunctionInfo {
pub return_type_complexity: usize,
pub line_start: usize,
pub line_end: usize,
pub is_test_support: bool,
}
/// Extracted information about a struct.
@ -55,6 +56,22 @@ pub struct TraitInfo {
pub generic_param_count: usize,
}
/// Extracted information about a const item.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ConstInfo {
pub name: String,
pub file: PathBuf,
}
/// Extracted information about a static item.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct StaticInfo {
pub name: String,
pub file: PathBuf,
}
/// Extracted information about an impl block.
#[derive(Debug, Clone)]
#[allow(dead_code)]
@ -75,6 +92,8 @@ pub struct FileSymbols {
pub enums: Vec<EnumInfo>,
pub traits: Vec<TraitInfo>,
pub impls: Vec<ImplInfo>,
pub consts: Vec<ConstInfo>,
pub statics: Vec<StaticInfo>,
pub parse_error: bool,
}
@ -108,6 +127,8 @@ fn parse_file(path: &Path) -> FileSymbols {
enums: vec![],
traits: vec![],
impls: vec![],
consts: vec![],
statics: vec![],
parse_error: true,
};
}
@ -123,6 +144,8 @@ fn parse_file(path: &Path) -> FileSymbols {
enums: vec![],
traits: vec![],
impls: vec![],
consts: vec![],
statics: vec![],
parse_error: true,
};
}
@ -135,7 +158,11 @@ fn parse_file(path: &Path) -> FileSymbols {
enums: vec![],
traits: vec![],
impls: vec![],
consts: vec![],
statics: vec![],
module_parts: vec![],
in_cfg_test: false,
in_tests_module: false,
};
for item in &ast.items {
@ -149,6 +176,8 @@ fn parse_file(path: &Path) -> FileSymbols {
enums: extractor.enums,
traits: extractor.traits,
impls: extractor.impls,
consts: extractor.consts,
statics: extractor.statics,
parse_error: false,
}
}
@ -160,7 +189,11 @@ struct SymbolExtractor {
enums: Vec<EnumInfo>,
traits: Vec<TraitInfo>,
impls: Vec<ImplInfo>,
consts: Vec<ConstInfo>,
statics: Vec<StaticInfo>,
module_parts: Vec<String>,
in_cfg_test: bool,
in_tests_module: bool,
}
impl SymbolExtractor {
@ -168,8 +201,15 @@ impl SymbolExtractor {
match item {
Item::Fn(func) => {
let name = self.qualify_name(&func.sig.ident.to_string());
let info =
extract_function_info(&name, &self.file_path, &func.sig, Some(&func.block));
let is_test_support =
self.is_test_support_context() || has_cfg_test_attr(&func.attrs);
let info = extract_function_info(
&name,
&self.file_path,
&func.sig,
Some(&func.block),
is_test_support,
);
self.functions.push(info);
}
Item::Struct(s) => {
@ -213,6 +253,18 @@ impl SymbolExtractor {
generic_param_count,
});
}
Item::Const(c) => {
self.consts.push(ConstInfo {
name: self.qualify_name(&c.ident.to_string()),
file: self.file_path.clone(),
});
}
Item::Static(s) => {
self.statics.push(StaticInfo {
name: self.qualify_name(&s.ident.to_string()),
file: self.file_path.clone(),
});
}
Item::Impl(imp) => {
let target_type = self.qualify_name(&type_to_string(&imp.self_ty));
let trait_name = imp.trait_.as_ref().map(|(_, path, _)| {
@ -232,6 +284,9 @@ impl SymbolExtractor {
&self.file_path,
&method.sig,
Some(&method.block),
self.is_test_support_context()
|| has_cfg_test_attr(&method.attrs)
|| has_cfg_test_attr(&imp.attrs),
);
self.functions.push(info);
}
@ -245,11 +300,17 @@ impl SymbolExtractor {
}
Item::Mod(module) => {
if let Some((_, items)) = &module.content {
let previous_cfg = self.in_cfg_test;
let previous_tests = self.in_tests_module;
self.in_cfg_test |= has_cfg_test_attr(&module.attrs);
self.in_tests_module |= module.ident == "tests";
self.module_parts.push(module.ident.to_string());
for item in items {
self.extract_item(item);
}
self.module_parts.pop();
self.in_cfg_test = previous_cfg;
self.in_tests_module = previous_tests;
}
}
_ => {}
@ -263,6 +324,10 @@ impl SymbolExtractor {
format!("{}::{}", self.module_parts.join("::"), name)
}
}
fn is_test_support_context(&self) -> bool {
self.in_cfg_test || self.in_tests_module
}
}
fn extract_function_info(
@ -270,6 +335,7 @@ fn extract_function_info(
file: &Path,
sig: &syn::Signature,
body: Option<&syn::Block>,
is_test_support: bool,
) -> FunctionInfo {
let param_count = sig.inputs.len();
let generic_param_count = sig.generics.params.len();
@ -314,6 +380,7 @@ fn extract_function_info(
return_type_complexity,
line_start,
line_end,
is_test_support,
}
}
@ -322,6 +389,19 @@ fn count_derives(attrs: &[syn::Attribute]) -> usize {
attrs.iter().filter(|a| a.path().is_ident("derive")).count()
}
fn has_cfg_test_attr(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "cfg")
&& match &attr.meta {
syn::Meta::List(list) => list.tokens.to_string().contains("test"),
_ => false,
}
})
}
/// Count trait bounds across all generic parameters.
fn count_generic_trait_bounds(generics: &syn::Generics) -> usize {
let mut count = 0;

View file

@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::ast_parser;
use crate::file_metrics;
use crate::flow::{self, CallGraph};
use crate::render;
@ -27,10 +28,25 @@ enum EntrySource {
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
struct TraceNode {
function: String,
cycle: bool,
children: Vec<TraceNode>,
pub struct TraceNode {
pub function: String,
pub cycle: bool,
pub children: Vec<TraceNode>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FunctionTraceSummary {
pub function: String,
pub functions_reached: usize,
pub max_depth: usize,
pub direct_callees: Vec<String>,
pub tree: TraceNode,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileCallTraceReport {
pub file: String,
pub functions: Vec<FunctionTraceSummary>,
}
#[derive(Debug, Clone, Serialize)]
@ -149,6 +165,121 @@ fn analyze(
max_depth,
})
}
pub fn run_file(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
entry: Option<&str>,
json: bool,
verbose: bool,
) -> Result<(), String> {
let symbols = 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, &graph, project_path, file);
let file_display_path = relative_path_string(project_path, file);
let report = analyze_file_call_traces(&graph, &file_functions, &file_display_path);
let output = if let Some(entry) = entry {
let function = resolve_file_entry(&report, entry)?;
FileCallTraceReport {
file: report.file.clone(),
functions: vec![function.clone()],
}
} else {
report
};
if json {
println!("{}", serde_json::to_string(&output).unwrap());
} else {
render_file_call_trace_report(&output, verbose);
}
Ok(())
}
pub fn analyze_file_call_traces(
graph: &CallGraph,
file_functions: &file_metrics::FileFunctionIndex,
file_display_path: &str,
) -> FileCallTraceReport {
let mut functions: Vec<FunctionTraceSummary> = file_functions
.functions
.iter()
.filter(|function| !function.is_test && !function.is_bench)
.map(|function| {
let direct_callees = graph
.adjacency
.get(&function.graph_name)
.cloned()
.unwrap_or_default();
let (tree, functions_reached, max_depth) =
if graph.functions.contains(&function.graph_name) {
build_trace_tree(graph, &function.graph_name)
} else {
(
TraceNode {
function: function.graph_name.clone(),
cycle: false,
children: Vec::new(),
},
1,
1,
)
};
FunctionTraceSummary {
function: function.display_name.clone(),
functions_reached,
max_depth,
direct_callees,
tree,
}
})
.collect();
functions.sort_by(|a, b| {
b.functions_reached
.cmp(&a.functions_reached)
.then(b.max_depth.cmp(&a.max_depth))
.then(a.function.cmp(&b.function))
});
FileCallTraceReport {
file: file_display_path.to_string(),
functions,
}
}
fn resolve_file_entry(
report: &FileCallTraceReport,
requested: &str,
) -> Result<FunctionTraceSummary, String> {
if let Some(exact) = report
.functions
.iter()
.find(|function| function.function == requested)
{
return Ok(exact.clone());
}
let suffix = format!("::{requested}");
let matches: Vec<&FunctionTraceSummary> = report
.functions
.iter()
.filter(|function| function.function.ends_with(&suffix) || function.function == requested)
.collect();
if matches.len() == 1 {
Ok(matches[0].clone())
} else {
Err(format!(
"entry function not found or ambiguous: {requested}"
))
}
}
fn maybe_entry_listing(
project_path: &Path,
requested: &str,
@ -545,14 +676,14 @@ fn main_function_name(project_path: &Path, path: &Path) -> String {
}
}
fn relative_path_string(project_path: &Path, path: &Path) -> String {
pub(crate) fn relative_path_string(project_path: &Path, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn build_trace_tree(graph: &CallGraph, entry: &str) -> (TraceNode, usize, usize) {
pub(crate) fn build_trace_tree(graph: &CallGraph, entry: &str) -> (TraceNode, usize, usize) {
let mut stack = HashSet::new();
let mut reached = HashSet::new();
stack.insert(entry.to_string());
@ -678,6 +809,37 @@ fn render_human(output: &CallTraceJson, verbose: bool) {
println!(" max depth: {}", output.max_depth);
}
fn render_file_call_trace_report(report: &FileCallTraceReport, verbose: bool) {
println!("cstat file call trace ({})", report.file);
if verbose {
render::verbose_block(&[
"Static call graph rendered for every non-test function in the selected file.",
"Cycles stop expansion; shared functions expand in each stack.",
]);
}
if report.functions.is_empty() {
println!(" <none>");
return;
}
for function in &report.functions {
println!();
println!(
"{} (functions reached: {}, max depth: {}, direct callees: {})",
function.function,
function.functions_reached,
function.max_depth,
if function.direct_callees.is_empty() {
"-".to_string()
} else {
function.direct_callees.join(", ")
}
);
render_trace_node(&function.tree);
}
}
fn render_trace_node(node: &TraceNode) {
println!("{}", format_node(node));
render_children(&node.children, "");

631
src/cluster.rs Normal file
View file

@ -0,0 +1,631 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use syn::{BinOp, Block, Expr, ExprPath, FnArg, ImplItem, Item, Pat, Stmt};
use crate::render;
const DISPLAY_FUNCTION_LIMIT: usize = 8;
const DISPLAY_CLUSTER_LIMIT: usize = 10;
#[derive(Debug, Clone, Serialize)]
pub struct ClusterReport {
pub files: Vec<FileClusterReport>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileClusterReport {
pub path: String,
pub functions: Vec<FunctionClusterReport>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FunctionClusterReport {
pub name: String,
pub line_start: usize,
pub line_end: usize,
pub is_test: bool,
pub reference_count: usize,
pub mutation_count: usize,
pub clusters: Vec<ValueCluster>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValueCluster {
pub name: String,
pub reference_lines: Vec<usize>,
pub mutation_lines: Vec<usize>,
pub connected_values: Vec<String>,
#[serde(skip)]
sort_line: usize,
}
#[derive(Debug, Clone)]
struct StatementAccess {
line_start: usize,
defines: Vec<String>,
references: BTreeSet<String>,
mutations: BTreeSet<String>,
}
#[derive(Debug, Clone)]
struct WorkingCluster {
name: String,
defined_line: usize,
reference_lines: BTreeSet<usize>,
mutation_lines: BTreeSet<usize>,
connected_values: BTreeSet<String>,
}
pub fn analyze_files(rs_files: &[PathBuf], project_path: &Path) -> ClusterReport {
let mut files = Vec::new();
for file in rs_files {
if let Some(report) = analyze_file(file, project_path) {
files.push(report);
}
}
ClusterReport { files }
}
pub fn analyze_file(path: &Path, project_path: &Path) -> Option<FileClusterReport> {
let source = fs::read_to_string(path).ok()?;
let syntax = syn::parse_file(&source).ok()?;
let mut functions = Vec::new();
collect_item_functions(&syntax.items, &mut Vec::new(), &mut functions);
let mut function_reports = functions
.into_iter()
.filter_map(|function| analyze_function(function))
.collect::<Vec<_>>();
function_reports.sort_by(|a, b| {
a.is_test
.cmp(&b.is_test)
.then_with(|| b.reference_count.cmp(&a.reference_count))
.then_with(|| b.mutation_count.cmp(&a.mutation_count))
.then_with(|| a.line_start.cmp(&b.line_start))
.then_with(|| a.name.cmp(&b.name))
});
Some(FileClusterReport {
path: relative_path_string(project_path, path),
functions: function_reports,
})
}
pub fn render_cluster(rs_files: &[PathBuf], project_path: &Path, json: bool, verbose: bool) {
let report = analyze_files(rs_files, project_path);
if json {
println!("{}", serde_json::to_string_pretty(&report).unwrap());
} else {
render_cluster_report(&report, verbose);
}
}
pub fn render_cluster_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) {
let report = analyze_file(file, project_path).unwrap_or_else(|| FileClusterReport {
path: relative_path_string(project_path, file),
functions: Vec::new(),
});
render_file_cluster_report(&report, verbose);
// Keep signature parity with sibling modules that receive the full project file set.
let _ = rs_files;
}
pub fn render_cluster_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
let report = analyze_file(file, project_path).unwrap_or_else(|| FileClusterReport {
path: relative_path_string(project_path, file),
functions: Vec::new(),
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
let _ = rs_files;
}
fn render_cluster_report(report: &ClusterReport, verbose: bool) {
render::section_header("Value clusters");
if verbose {
render_cluster_explanation();
}
let mut functions = report
.files
.iter()
.flat_map(|file| {
file.functions
.iter()
.map(move |function| (&file.path, function))
})
.collect::<Vec<_>>();
functions.sort_by(|(path_a, a), (path_b, b)| {
a.is_test
.cmp(&b.is_test)
.then_with(|| b.reference_count.cmp(&a.reference_count))
.then_with(|| b.mutation_count.cmp(&a.mutation_count))
.then_with(|| path_a.cmp(path_b))
.then_with(|| a.line_start.cmp(&b.line_start))
.then_with(|| a.name.cmp(&b.name))
});
if functions.is_empty() {
println!("No reusable value clusters found.");
return;
}
for (path, function) in functions.into_iter().take(DISPLAY_FUNCTION_LIMIT) {
println!(
"{} {}:{}-{}",
"fn".cyan().bold(),
path,
function.line_start,
function.line_end
);
render_function_clusters(function);
}
}
fn render_file_cluster_report(report: &FileClusterReport, verbose: bool) {
render::section_header(&format!("Value clusters ({})", report.path));
if verbose {
render_cluster_explanation();
}
if report.functions.is_empty() {
println!("No reusable value clusters found.");
return;
}
for function in report.functions.iter().take(DISPLAY_FUNCTION_LIMIT) {
println!(
"{} {}:{}-{}",
"fn".cyan().bold(),
function.name.bold(),
function.line_start,
function.line_end
);
render_function_clusters(function);
}
}
fn render_function_clusters(function: &FunctionClusterReport) {
println!(" value references mutations connected connected values");
for cluster in function.clusters.iter().take(DISPLAY_CLUSTER_LIMIT) {
println!(
" {:<24} {:>10} {:>9} {:>9} {}",
cluster.name.bold(),
cluster.reference_lines.len(),
cluster.mutation_lines.len(),
cluster.connected_values.len(),
short_connected_values(&cluster.connected_values, 4).dimmed()
);
}
if function.clusters.len() > DISPLAY_CLUSTER_LIMIT {
println!(
" … {} more values hidden; use --json for the complete cluster record.",
function.clusters.len() - DISPLAY_CLUSTER_LIMIT
);
}
println!();
}
fn short_connected_values(connected_values: &[String], limit: usize) -> String {
if connected_values.is_empty() {
return "-".to_string();
}
let mut shown = connected_values
.iter()
.take(limit)
.cloned()
.collect::<Vec<_>>();
if connected_values.len() > limit {
shown.push(format!("+{} more", connected_values.len() - limit));
}
shown.join(", ")
}
fn render_cluster_explanation() {
render::verbose_line(
"value = a function parameter or local let-binding collected from the Rust AST.",
);
render::verbose_line(
"references = top-level statements where that value is read after it is created.",
);
render::verbose_line(
"mutations = top-level statements that assign to the value, compound-assign it, or pass it as &mut.",
);
render::verbose_line(
"connected = other local values that appear in the same top-level statements.",
);
println!();
}
struct FunctionCandidate<'a> {
name: String,
block: &'a Block,
line_start: usize,
line_end: usize,
params: Vec<String>,
is_test: bool,
}
fn collect_item_functions<'a>(
items: &'a [Item],
module_parts: &mut Vec<String>,
out: &mut Vec<FunctionCandidate<'a>>,
) {
for item in items {
match item {
Item::Fn(function) => {
let name = qualify_name(module_parts, &function.sig.ident.to_string());
out.push(FunctionCandidate {
name,
block: &function.block,
line_start: function.sig.fn_token.span.start().line,
line_end: function.block.brace_token.span.close().end().line,
params: collect_fn_params(&function.sig.inputs),
is_test: module_parts.iter().any(|part| part == "tests")
|| has_attr_named(&function.attrs, "test"),
});
}
Item::Impl(implementation) => {
let target = type_name(&implementation.self_ty);
for impl_item in &implementation.items {
if let ImplItem::Fn(method) = impl_item {
out.push(FunctionCandidate {
name: format!("{}::{}", target, method.sig.ident),
block: &method.block,
line_start: method.sig.fn_token.span.start().line,
line_end: method.block.brace_token.span.close().end().line,
params: collect_fn_params(&method.sig.inputs),
is_test: module_parts.iter().any(|part| part == "tests")
|| has_attr_named(&method.attrs, "test"),
});
}
}
}
Item::Mod(module) => {
if let Some((_, nested)) = &module.content {
module_parts.push(module.ident.to_string());
collect_item_functions(nested, module_parts, out);
module_parts.pop();
}
}
_ => {}
}
}
}
fn analyze_function(function: FunctionCandidate<'_>) -> Option<FunctionClusterReport> {
let mut known_values = BTreeSet::new();
let mut clusters: BTreeMap<String, WorkingCluster> = BTreeMap::new();
for param in &function.params {
if is_usable_binding(param) {
known_values.insert(param.clone());
clusters.insert(
param.clone(),
WorkingCluster {
name: param.clone(),
defined_line: function.line_start,
reference_lines: BTreeSet::new(),
mutation_lines: BTreeSet::new(),
connected_values: BTreeSet::new(),
},
);
}
}
let mut statements = Vec::new();
for stmt in &function.block.stmts {
let line_start = stmt.span().start().line;
let defines = collect_stmt_definitions(stmt);
let references = collect_stmt_references(stmt, &known_values);
let mutations = collect_stmt_mutations(stmt, &known_values);
for name in &defines {
if is_usable_binding(name) {
known_values.insert(name.clone());
}
}
statements.push(StatementAccess {
line_start,
defines,
references,
mutations,
});
}
for statement in &statements {
let mut participants = statement.references.clone();
participants.extend(statement.mutations.iter().cloned());
for name in &statement.defines {
if is_usable_binding(name) {
participants.insert(name.clone());
clusters
.entry(name.clone())
.or_insert_with(|| WorkingCluster {
name: name.clone(),
defined_line: statement.line_start,
reference_lines: BTreeSet::new(),
mutation_lines: BTreeSet::new(),
connected_values: BTreeSet::new(),
});
}
}
for name in &participants {
if let Some(cluster) = clusters.get_mut(name) {
if statement.references.contains(name) {
cluster.reference_lines.insert(statement.line_start);
}
if statement.mutations.contains(name) {
cluster.mutation_lines.insert(statement.line_start);
}
for connected_value in &participants {
if connected_value != name && is_usable_binding(connected_value) {
cluster.connected_values.insert(connected_value.clone());
}
}
}
}
}
let mut value_clusters = clusters
.into_values()
.filter(|cluster| !cluster.reference_lines.is_empty() || !cluster.mutation_lines.is_empty())
.map(finalize_cluster)
.collect::<Vec<_>>();
value_clusters.sort_by(|a, b| {
b.reference_lines
.len()
.cmp(&a.reference_lines.len())
.then_with(|| b.mutation_lines.len().cmp(&a.mutation_lines.len()))
.then_with(|| b.connected_values.len().cmp(&a.connected_values.len()))
.then_with(|| a.sort_line.cmp(&b.sort_line))
.then_with(|| a.name.cmp(&b.name))
});
if value_clusters.is_empty() {
return None;
}
let reference_count = value_clusters
.iter()
.map(|cluster| cluster.reference_lines.len())
.sum();
let mutation_count = value_clusters
.iter()
.map(|cluster| cluster.mutation_lines.len())
.sum();
Some(FunctionClusterReport {
name: function.name,
line_start: function.line_start,
line_end: function.line_end,
is_test: function.is_test,
reference_count,
mutation_count,
clusters: value_clusters,
})
}
fn finalize_cluster(cluster: WorkingCluster) -> ValueCluster {
ValueCluster {
name: cluster.name,
reference_lines: cluster.reference_lines.into_iter().collect(),
mutation_lines: cluster.mutation_lines.into_iter().collect(),
connected_values: cluster.connected_values.into_iter().collect(),
sort_line: cluster.defined_line,
}
}
fn collect_fn_params(
inputs: &syn::punctuated::Punctuated<FnArg, syn::token::Comma>,
) -> Vec<String> {
let mut params = Vec::new();
for input in inputs {
if let FnArg::Typed(pat_type) = input {
collect_pat_idents(&pat_type.pat, &mut params);
}
}
params
}
fn collect_stmt_definitions(stmt: &Stmt) -> Vec<String> {
let mut out = Vec::new();
if let Stmt::Local(local) = stmt {
collect_pat_idents(&local.pat, &mut out);
}
out.sort();
out.dedup();
out
}
fn collect_stmt_references(stmt: &Stmt, known_values: &BTreeSet<String>) -> BTreeSet<String> {
let mut collector = ReferenceCollector {
known_values,
references: BTreeSet::new(),
};
collector.visit_stmt(stmt);
collector.references
}
fn collect_stmt_mutations(stmt: &Stmt, known_values: &BTreeSet<String>) -> BTreeSet<String> {
let mut collector = MutationCollector {
known_values,
mutations: BTreeSet::new(),
};
collector.visit_stmt(stmt);
collector.mutations
}
struct ReferenceCollector<'a> {
known_values: &'a BTreeSet<String>,
references: BTreeSet<String>,
}
impl<'ast> Visit<'ast> for ReferenceCollector<'_> {
fn visit_expr_path(&mut self, node: &'ast ExprPath) {
if node.qself.is_none() && node.path.segments.len() == 1 {
let ident = node.path.segments[0].ident.to_string();
if self.known_values.contains(&ident) {
self.references.insert(ident);
}
}
visit::visit_expr_path(self, node);
}
fn visit_expr_assign(&mut self, node: &'ast syn::ExprAssign) {
self.visit_expr(&node.right);
}
}
struct MutationCollector<'a> {
known_values: &'a BTreeSet<String>,
mutations: BTreeSet<String>,
}
impl<'ast> Visit<'ast> for MutationCollector<'_> {
fn visit_expr_assign(&mut self, node: &'ast syn::ExprAssign) {
self.record_known_root(&node.left);
self.visit_expr(&node.right);
}
fn visit_expr_binary(&mut self, node: &'ast syn::ExprBinary) {
if is_assign_binop(&node.op) {
self.record_known_root(&node.left);
}
visit::visit_expr_binary(self, node);
}
fn visit_expr_reference(&mut self, node: &'ast syn::ExprReference) {
if node.mutability.is_some() {
self.record_known_root(&node.expr);
}
visit::visit_expr_reference(self, node);
}
}
impl MutationCollector<'_> {
fn record_known_root(&mut self, expr: &Expr) {
if let Some(name) = root_ident(expr) {
if self.known_values.contains(&name) {
self.mutations.insert(name);
}
}
}
}
fn root_ident(expr: &Expr) -> Option<String> {
match expr {
Expr::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => {
Some(path.path.segments[0].ident.to_string())
}
Expr::Field(field) => root_ident(&field.base),
Expr::Index(index) => root_ident(&index.expr),
Expr::Paren(paren) => root_ident(&paren.expr),
Expr::Group(group) => root_ident(&group.expr),
Expr::Reference(reference) => root_ident(&reference.expr),
Expr::Unary(unary) => root_ident(&unary.expr),
_ => None,
}
}
fn is_assign_binop(op: &BinOp) -> bool {
matches!(
op,
BinOp::AddAssign(_)
| BinOp::SubAssign(_)
| BinOp::MulAssign(_)
| BinOp::DivAssign(_)
| BinOp::RemAssign(_)
| BinOp::BitXorAssign(_)
| BinOp::BitAndAssign(_)
| BinOp::BitOrAssign(_)
| BinOp::ShlAssign(_)
| BinOp::ShrAssign(_)
)
}
fn collect_pat_idents(pat: &Pat, out: &mut Vec<String>) {
match pat {
Pat::Ident(ident) => {
let name = ident.ident.to_string();
if is_usable_binding(&name) {
out.push(name);
}
if let Some((_, subpat)) = &ident.subpat {
collect_pat_idents(subpat, out);
}
}
Pat::Reference(reference) => collect_pat_idents(&reference.pat, out),
Pat::Slice(slice) => {
for elem in &slice.elems {
collect_pat_idents(elem, out);
}
}
Pat::Struct(strukt) => {
for field in &strukt.fields {
collect_pat_idents(&field.pat, out);
}
}
Pat::Tuple(tuple) => {
for elem in &tuple.elems {
collect_pat_idents(elem, out);
}
}
Pat::TupleStruct(tuple) => {
for elem in &tuple.elems {
collect_pat_idents(elem, out);
}
}
Pat::Type(typed) => collect_pat_idents(&typed.pat, out),
Pat::Or(or) => {
for case in &or.cases {
collect_pat_idents(case, out);
}
}
_ => {}
}
}
fn is_usable_binding(name: &str) -> bool {
name != "_" && !name.starts_with('_') && name != "self"
}
fn has_attr_named(attrs: &[syn::Attribute], wanted: &str) -> bool {
attrs.iter().any(|attr| attr.path().is_ident(wanted))
}
fn qualify_name(parts: &[String], name: &str) -> String {
if parts.is_empty() {
name.to_string()
} else {
format!("{}::{}", parts.join("::"), name)
}
}
fn type_name(ty: &syn::Type) -> String {
match ty {
syn::Type::Path(path) => path
.path
.segments
.iter()
.map(|segment| segment.ident.to_string())
.collect::<Vec<_>>()
.join("::"),
syn::Type::Reference(reference) => type_name(&reference.elem),
_ => "impl".to_string(),
}
}
fn relative_path_string(project_path: &Path, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.display()
.to_string()
}

1297
src/coverage.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ use std::path::Path;
use std::path::PathBuf;
use crate::flow::{self, build_call_graph, CallGraph, EntryPointKind};
use crate::render;
use crate::{ast_parser, file_metrics, render};
#[derive(Debug, Clone, Serialize)]
pub struct TestReachabilityEntry {
@ -43,6 +43,37 @@ pub struct TestReachabilityAnalysis {
pub analysis_note: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileStaticReachabilityEntry {
pub entry: String,
pub kind: String,
pub reachable_functions: usize,
pub unique_call_steps: usize,
pub max_call_depth: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileStaticReachabilityEdge {
pub caller: String,
pub callee: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileStaticReachabilityReport {
pub file: String,
pub test_benchmark_entry_count: usize,
pub production_function_count: usize,
pub reachable_function_count: usize,
pub unreachable_function_count: usize,
pub local_edge_count: usize,
pub reachable_local_edge_count: usize,
pub entries: Vec<FileStaticReachabilityEntry>,
pub reachable_functions: Vec<String>,
pub unreachable_functions: Vec<String>,
pub reachable_edges: Vec<FileStaticReachabilityEdge>,
pub wrapper_entrypoints: Vec<String>,
}
pub fn analyze_test_reachability(
rs_files: &[PathBuf],
project_path: &Path,
@ -152,6 +183,126 @@ pub fn analyze_graph_test_reachability(graph: &CallGraph) -> TestReachabilityAna
}
}
pub fn analyze_file_static_reachability(
graph: &CallGraph,
file_functions: &file_metrics::FileFunctionIndex,
file_display_path: &str,
) -> FileStaticReachabilityReport {
let selected: HashSet<String> = file_functions
.functions
.iter()
.map(|function| function.graph_name.clone())
.collect();
let production: HashSet<String> = file_functions
.functions
.iter()
.filter(|function| {
!function.is_test
&& !function.is_bench
&& !function.is_test_support
&& !function.is_binary_wrapper_main
})
.map(|function| function.graph_name.clone())
.collect();
let mut wrapper_entrypoints: Vec<String> = file_functions
.functions
.iter()
.filter(|function| function.is_binary_wrapper_main)
.map(|function| function.display_name.clone())
.collect();
wrapper_entrypoints.sort();
let mut roots = flow::entry_points_by_kind(graph, EntryPointKind::Test);
roots.extend(flow::entry_points_by_kind(graph, EntryPointKind::Bench));
roots.sort();
roots.dedup();
let mut reachable_functions_set: HashSet<String> = HashSet::new();
let mut reachable_edges_set: HashSet<(String, String)> = HashSet::new();
let mut entries = Vec::new();
for root in &roots {
let (reachable, edges) = reachable_subgraph(graph, root);
let root_reachable_functions = reachable
.iter()
.filter(|function| production.contains(*function))
.count();
let root_reachable_edges = edges
.iter()
.filter(|(caller, callee)| selected.contains(caller) && selected.contains(callee))
.count();
reachable_functions_set.extend(
reachable
.iter()
.filter(|function| production.contains(*function))
.cloned(),
);
reachable_edges_set.extend(
edges
.iter()
.filter(|(caller, callee)| selected.contains(caller) && selected.contains(callee))
.cloned(),
);
let kind = graph
.entry_point_kinds
.get(root)
.map(|kind| kind.as_str().to_string())
.unwrap_or_else(|| "unknown".to_string());
entries.push(FileStaticReachabilityEntry {
entry: root.clone(),
kind,
reachable_functions: root_reachable_functions,
unique_call_steps: root_reachable_edges,
max_call_depth: max_depth_from(graph, root, &mut HashSet::new()),
});
}
entries.sort_by(|a, b| a.entry.cmp(&b.entry));
let mut reachable_functions: Vec<String> = reachable_functions_set.into_iter().collect();
reachable_functions.sort();
let mut unreachable_functions: Vec<String> = production
.iter()
.filter(|function| !reachable_functions.contains(function))
.cloned()
.collect();
unreachable_functions.sort();
let local_edge_count = graph
.adjacency
.iter()
.map(|(caller, callees)| {
callees
.iter()
.filter(|callee| selected.contains(caller) && selected.contains(*callee))
.count()
})
.sum();
let mut reachable_edges: Vec<FileStaticReachabilityEdge> = reachable_edges_set
.into_iter()
.map(|(caller, callee)| FileStaticReachabilityEdge { caller, callee })
.collect();
reachable_edges.sort_by(|a, b| a.caller.cmp(&b.caller).then(a.callee.cmp(&b.callee)));
FileStaticReachabilityReport {
file: file_display_path.to_string(),
test_benchmark_entry_count: entries.len(),
production_function_count: production.len(),
reachable_function_count: reachable_functions.len(),
unreachable_function_count: unreachable_functions.len(),
local_edge_count,
reachable_local_edge_count: reachable_edges.len(),
entries,
reachable_functions,
unreachable_functions,
reachable_edges,
wrapper_entrypoints,
}
}
fn reachable_subgraph(
graph: &CallGraph,
entry: &str,
@ -296,6 +447,147 @@ pub fn render_test_reachability_json(rs_files: &[PathBuf], project_path: &Path)
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn render_test_reachability_file(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
verbose: bool,
) {
let report = analyze_file_static_reachability_from_files(rs_files, project_path, file);
render_file_static_reachability_report(&report, verbose);
}
pub fn render_test_reachability_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
let report = analyze_file_static_reachability_from_files(rs_files, project_path, file);
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn analyze_file_static_reachability_from_files(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
) -> FileStaticReachabilityReport {
let symbols = ast_parser::parse_project(rs_files);
let graph = build_call_graph(rs_files, project_path);
let file_functions =
file_metrics::selected_file_functions(&symbols, &graph, project_path, file);
let file_display_path = relative_path_string(project_path, file);
analyze_file_static_reachability(&graph, &file_functions, &file_display_path)
}
pub fn render_file_static_reachability_report(
report: &FileStaticReachabilityReport,
verbose: bool,
) {
render::section_header("Static test/benchmark reachability");
if verbose {
render::verbose_block(&[
"Roots are tests and benchmarks discovered anywhere in the project.",
"Reachability is computed from the static call graph; it is not runtime coverage.",
"Production counts exclude tests, test-support helpers, benches, and selected binary wrapper main().",
]);
}
let pct = if report.production_function_count == 0 {
0.0
} else {
report.reachable_function_count as f64 / report.production_function_count as f64 * 100.0
};
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(", ")
);
}
let visible_entries: Vec<&FileStaticReachabilityEntry> = report
.entries
.iter()
.filter(|entry| entry.reachable_functions > 0 || entry.unique_call_steps > 0)
.collect();
println!();
println!(
" {:<48} {:<7} {:>9} {:>7} {:>8}",
"entry root".bold(),
"kind".bold(),
"functions".bold(),
"steps".bold(),
"depth".bold(),
);
if report.entries.is_empty() {
println!(" {}", "No project test or benchmark roots found.".yellow());
} else if visible_entries.is_empty() {
println!(
" {}",
"No project test or benchmark roots reach selected production functions.".yellow()
);
} else {
for entry in visible_entries {
println!(
" {:<48} {:<7} {:>9} {:>7} {:>8}",
truncate(&entry.entry, 48),
entry.kind,
entry.reachable_functions,
entry.unique_call_steps,
entry.max_call_depth,
);
}
}
println!();
println!(" {}", "reachable functions:".bold());
if report.reachable_functions.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for function in &report.reachable_functions {
println!(" {} {}", "•".green(), function);
}
}
println!();
println!(" {}", "not statically reachable:".bold().yellow());
if report.unreachable_functions.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for function in &report.unreachable_functions {
println!(" {} {}", "•".yellow(), function);
}
}
println!();
println!(" {}", "reachable local edges:".bold());
if report.reachable_edges.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for edge in &report.reachable_edges {
println!(" {} {} -> {}", "•".green(), edge.caller, edge.callee);
}
}
}
fn relative_path_string(project_path: &Path, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn truncate(value: &str, max: usize) -> String {
if value.chars().count() <= max {
value.to_string()
@ -691,10 +983,10 @@ fn render_cold_functions(graph: &CallGraph, visit_counts: &HashMap<String, usize
println!();
}
/// Compact summary data for embedding in the dashboard.
pub struct DatapathSummary {
/// Percentage of functions reachable from entry points.
pub coverage_pct: f64,
/// 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.
@ -703,8 +995,11 @@ pub struct DatapathSummary {
pub cold_count: usize,
}
/// Compute compact datapath coverage for dashboard use.
pub fn datapath_summary(rs_files: &[PathBuf], project_path: &Path) -> Option<DatapathSummary> {
/// 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;
@ -713,10 +1008,10 @@ pub fn datapath_summary(rs_files: &[PathBuf], project_path: &Path) -> Option<Dat
let reachable = reachable_from_entries(&graph);
let reachable_count = reachable.len();
let cold_count = total_functions.saturating_sub(reachable_count);
let coverage_pct = reachable_count as f64 / total_functions.max(1) as f64 * 100.0;
let reachable_pct = reachable_count as f64 / total_functions.max(1) as f64 * 100.0;
Some(DatapathSummary {
coverage_pct,
Some(EntrypointReachabilitySummary {
reachable_pct,
total_functions,
reachable_count,
cold_count,

View file

@ -1,9 +1,10 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::flow::{self, CallGraph};
use crate::render;
use crate::{ast_parser, file_metrics, render};
#[derive(Debug, Clone, Serialize)]
pub struct DeadCodeCandidate {
@ -21,6 +22,26 @@ pub struct DeadCodeReport {
pub analysis_note: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileDeadCodeFunction {
pub function: String,
pub reached_from_crate_roots: bool,
pub crate_fan_in: usize,
pub in_file_fan_in: usize,
pub candidate: bool,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileDeadCodeReport {
pub file: String,
pub crate_roots: Vec<String>,
pub file_function_count: usize,
pub reached_from_crate_count: usize,
pub candidate_count: usize,
pub functions: Vec<FileDeadCodeFunction>,
}
pub fn analyze_graph(graph: &CallGraph) -> DeadCodeReport {
let reachable = flow::reachable_from_entries(graph);
let mut candidates: Vec<DeadCodeCandidate> = graph
@ -49,6 +70,82 @@ pub fn analyze_files(rs_files: &[PathBuf], project_path: &Path) -> DeadCodeRepor
analyze_graph(&graph)
}
pub fn analyze_file_dead_code(
graph: &CallGraph,
file_functions: &file_metrics::FileFunctionIndex,
file_display_path: &str,
) -> FileDeadCodeReport {
let reachable = flow::reachable_from_entries(graph);
let selected: HashSet<String> = file_functions
.functions
.iter()
.map(|function| function.graph_name.clone())
.collect();
let mut functions = Vec::new();
for function in file_functions.functions.iter().filter(|function| {
!function.is_test
&& !function.is_bench
&& !function.is_test_support
&& !function.is_binary_wrapper_main
}) {
let mut in_file_fan_in = 0usize;
let mut crate_fan_in = 0usize;
for (caller, callees) in &graph.adjacency {
for callee in callees {
if callee != &function.graph_name {
continue;
}
if selected.contains(caller) {
in_file_fan_in += 1;
} else {
crate_fan_in += 1;
}
}
}
let reached_from_crate_roots = reachable.contains(&function.graph_name);
let candidate = !reached_from_crate_roots;
let reason = if !candidate {
"reached from crate roots"
} else if crate_fan_in == 0 && in_file_fan_in == 0 {
"not reached from crate roots; no direct, trait, drop, function-item, or closure edge was found"
} else if crate_fan_in == 0 && in_file_fan_in > 0 {
"only reached by cold in-file path; no test/benchmark root reaches that caller"
} else {
"not reached from crate roots; incoming edge source is cold"
};
functions.push(FileDeadCodeFunction {
function: function.display_name.clone(),
reached_from_crate_roots,
crate_fan_in,
in_file_fan_in,
candidate,
reason: reason.to_string(),
});
}
functions.sort_by(|a, b| a.function.cmp(&b.function));
let reached_from_crate_count = functions
.iter()
.filter(|function| function.reached_from_crate_roots)
.count();
let candidate_count = functions
.iter()
.filter(|function| function.candidate)
.count();
FileDeadCodeReport {
file: file_display_path.to_string(),
crate_roots: graph.entry_points.clone(),
file_function_count: functions.len(),
reached_from_crate_count,
candidate_count,
functions,
}
}
pub fn render_dead_code_report(report: &DeadCodeReport, verbose: bool) {
render::section_header("Dead code candidates");
@ -102,3 +199,98 @@ pub fn render_dead_code_json(rs_files: &[PathBuf], project_path: &Path) {
let report = analyze_files(rs_files, project_path);
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn render_dead_code_file(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
verbose: bool,
) {
let report = analyze_file_dead_code_from_files(rs_files, project_path, file);
render_file_dead_code_report(&report, verbose);
}
pub fn render_dead_code_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
let report = analyze_file_dead_code_from_files(rs_files, project_path, file);
println!("{}", serde_json::to_string(&report).unwrap());
}
fn analyze_file_dead_code_from_files(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
) -> FileDeadCodeReport {
let symbols = 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, &graph, project_path, file);
let file_display_path = relative_path_string(project_path, file);
analyze_file_dead_code(&graph, &file_functions, &file_display_path)
}
pub fn render_file_dead_code_report(report: &FileDeadCodeReport, verbose: bool) {
render::section_header("Dead code candidates");
if verbose {
render::verbose_block(&[
"Candidates are selected-file functions not reachable from crate roots.",
"Fan-in is split between callers in the selected file and callers elsewhere in the crate.",
"This is conservative static evidence, not compiler-confirmed deletion safety.",
]);
}
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(),
);
println!(
" {} {}",
"crate roots:".cyan(),
report.crate_roots.join(", ")
);
println!();
println!(
" {:<48} {:>6} {:>11} {:>13} {}",
"function".bold(),
"cold".bold(),
"crate fan-in".bold(),
"in-file fan-in".bold(),
"reason".bold(),
);
if report.functions.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for function in &report.functions {
println!(
" {:<48} {:>6} {:>11} {:>13} {}",
truncate(&function.function, 48),
if function.candidate { "yes" } else { "no" },
function.crate_fan_in,
function.in_file_fan_in,
function.reason,
);
}
}
}
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
}
}
fn relative_path_string(project_path: &Path, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}

View file

@ -2,6 +2,7 @@ mod render;
pub use render::{render_deps, render_deps_json};
use colored::Colorize;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
@ -36,6 +37,29 @@ pub struct DepAnalysis {
pub coupling_pairs: Vec<(String, String, usize)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileDependency {
pub raw: String,
pub resolved_module: Option<String>,
pub kind: FileDependencyKind,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileDependencyKind {
Internal,
External,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileDependencyReport {
pub file: String,
pub module: String,
pub imports: usize,
pub internal: Vec<FileDependency>,
pub external: Vec<FileDependency>,
}
/// Extract use targets from a Rust source file.
fn extract_use_targets(source: &str) -> Vec<String> {
let ast = match syn::parse_file(source) {
@ -213,6 +237,119 @@ pub fn analyze_deps(rs_files: &[PathBuf], project_path: &Path) -> DepAnalysis {
}
}
pub fn analyze_file_deps(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
) -> FileDependencyReport {
let mut module_names: Vec<String> = rs_files
.iter()
.map(|path| crate_render::module_name_from_path(path, project_path))
.collect();
module_names.sort();
module_names.dedup();
let known_modules: HashSet<String> = module_names.iter().cloned().collect();
let module = crate_render::module_name_from_path(file, project_path);
let file_display = relative_path_string(project_path, file);
let source = std::fs::read_to_string(file).unwrap_or_default();
let mut use_targets = extract_use_targets(&source);
use_targets.sort();
use_targets.dedup();
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) {
internal.push(FileDependency {
raw,
resolved_module: Some(resolved_module),
kind: FileDependencyKind::Internal,
});
} else {
external.push(FileDependency {
raw,
resolved_module: None,
kind: FileDependencyKind::External,
});
}
}
FileDependencyReport {
file: file_display,
module,
imports: internal.len() + external.len(),
internal,
external,
}
}
pub fn render_deps_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) {
let report = analyze_file_deps(rs_files, project_path, file);
render_file_dependency_report(&report, verbose);
}
pub fn render_deps_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
let report = analyze_file_deps(rs_files, project_path, file);
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn render_file_dependency_report(report: &FileDependencyReport, verbose: bool) {
crate_render::section_header("Dependencies");
if verbose {
crate_render::verbose_block(&[
"Imports are raw use/mod targets parsed from the selected file.",
"Internal imports resolve to known modules in the discovered crate files.",
"External imports do not resolve to a known local module.",
]);
}
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(),
);
println!();
println!(" {}", "internal imports:".bold());
if report.internal.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for dependency in &report.internal {
println!(
" {} {} -> {}",
"•".green(),
dependency.raw,
dependency.resolved_module.as_deref().unwrap_or("-")
);
}
}
println!();
println!(" {}", "external imports:".bold());
if report.external.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for dependency in &report.external {
println!(" {} {}", "•".yellow(), dependency.raw);
}
}
}
fn relative_path_string(project_path: &Path, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
/// Compute call cohesion: fraction of functions calling another function in the same module.
fn compute_call_cohesion(
module_functions: &HashMap<String, Vec<String>>,

View file

@ -1,6 +1,96 @@
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub enum TargetKind {
Project,
File { file_path: PathBuf },
}
#[derive(Debug, Clone)]
pub struct AnalysisTarget {
pub project_path: PathBuf,
pub kind: TargetKind,
pub project_rs_files: Vec<PathBuf>,
pub target_rs_files: Vec<PathBuf>,
}
pub fn selected_file(target: &AnalysisTarget) -> Option<&Path> {
match &target.kind {
TargetKind::Project => None,
TargetKind::File { file_path } => Some(file_path.as_path()),
}
}
pub fn files_for_project(target: &AnalysisTarget) -> &[PathBuf] {
&target.project_rs_files
}
pub fn files_for_target(target: &AnalysisTarget) -> &[PathBuf] {
&target.target_rs_files
}
pub fn resolve_target(path: &Path) -> Result<AnalysisTarget, String> {
let canonical_path = path
.canonicalize()
.map_err(|e| format!("Cannot resolve path '{}': {}", path.display(), e))?;
if canonical_path.is_dir() {
let rs_files = discover_rs_files(&canonical_path)?;
return Ok(AnalysisTarget {
project_path: canonical_path,
kind: TargetKind::Project,
project_rs_files: rs_files.clone(),
target_rs_files: rs_files,
});
}
if canonical_path.is_file() {
if canonical_path.extension().is_none_or(|ext| ext != "rs") {
return Err(format!(
"--path file must be a Rust source file: {}",
path.display()
));
}
let mut ancestor = canonical_path.parent();
let project_path = loop {
match ancestor {
Some(dir) if dir.join("Cargo.toml").exists() => break dir.to_path_buf(),
Some(dir) => ancestor = dir.parent(),
None => {
return Err(format!(
"No Cargo.toml found above '{}'",
canonical_path.display()
));
}
}
};
let project_rs_files = discover_rs_files(&project_path)?;
if !project_rs_files.contains(&canonical_path) {
return Err(format!(
"file is not under this crate's src, tests, or benches: {}",
canonical_path.display()
));
}
return Ok(AnalysisTarget {
project_path,
kind: TargetKind::File {
file_path: canonical_path.clone(),
},
project_rs_files,
target_rs_files: vec![canonical_path],
});
}
Err(format!(
"Cannot resolve path '{}': not a file or directory",
path.display()
))
}
/// Discover all `.rs` source files in a Rust project.
///
/// Given a project root directory, this function:

179
src/file_metrics.rs Normal file
View file

@ -0,0 +1,179 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::ast_parser;
use crate::flow::{self, EntryPointKind};
use crate::render;
pub struct FileFunction {
pub graph_name: String,
pub display_name: String,
pub line_start: usize,
pub line_end: usize,
pub is_test: bool,
pub is_bench: bool,
pub is_test_support: bool,
pub is_binary_wrapper_main: bool,
}
pub struct SourceSpanFilter {
pub file: PathBuf,
pub production_spans: Vec<(usize, usize)>,
pub excluded_spans: Vec<(usize, usize)>,
}
pub struct ProductionFilter {
pub files: Vec<SourceSpanFilter>,
}
impl ProductionFilter {
pub fn includes_line(&self, file: &Path, line: usize) -> bool {
self.files
.iter()
.find(|entry| same_path(&entry.file, file))
.is_some_and(|entry| {
entry
.production_spans
.iter()
.any(|(start, end)| line >= *start && line <= *end)
&& !entry
.excluded_spans
.iter()
.any(|(start, end)| line >= *start && line <= *end)
})
}
}
pub struct FileFunctionIndex {
pub functions: Vec<FileFunction>,
#[allow(dead_code)]
pub graph_name_to_index: HashMap<String, usize>,
}
pub fn selected_file_functions(
symbols: &ast_parser::ProjectSymbols,
graph: &flow::CallGraph,
project_path: &Path,
file: &Path,
) -> FileFunctionIndex {
let file_module = render::module_name_from_path(file, project_path);
let prefix = if file_module == "main" || file_module == "lib" {
None
} else {
Some(file_module)
};
let mut functions: Vec<FileFunction> = symbols
.files
.iter()
.filter(|symbols_file| symbols_file.path == file)
.flat_map(|symbols_file| symbols_file.functions.iter())
.map(|function| {
let graph_name = match &prefix {
Some(module) => format!("{}::{}", module, function.name),
None => function.name.clone(),
};
let entry_kind = graph.entry_point_kinds.get(&graph_name);
let node = graph.function_nodes.get(&graph_name);
let is_test = matches!(entry_kind, Some(EntryPointKind::Test))
|| node.is_some_and(|node| node.is_test_entry);
let is_bench = matches!(entry_kind, Some(EntryPointKind::Bench))
|| node.is_some_and(|node| node.is_bench_entry);
let is_test_support = node
.map(|node| node.is_test_support)
.unwrap_or(function.is_test_support);
let is_binary_wrapper_main = is_selected_bin_wrapper_main(file, &graph_name);
FileFunction {
display_name: graph_name.clone(),
graph_name,
line_start: function.line_start,
line_end: function.line_end,
is_test,
is_bench,
is_test_support,
is_binary_wrapper_main,
}
})
.collect();
functions.sort_by(|a, b| {
a.line_start
.cmp(&b.line_start)
.then(a.line_end.cmp(&b.line_end))
.then(a.graph_name.cmp(&b.graph_name))
});
let graph_name_to_index = functions
.iter()
.enumerate()
.map(|(index, function)| (function.graph_name.clone(), index))
.collect();
FileFunctionIndex {
functions,
graph_name_to_index,
}
}
pub fn production_filter_for_files(
symbols: &ast_parser::ProjectSymbols,
graph: &flow::CallGraph,
project_path: &Path,
files: &[PathBuf],
selected_file: Option<&Path>,
) -> ProductionFilter {
let mut filters = Vec::new();
for file in files {
if selected_file.is_some_and(|selected| !same_path(selected, file)) {
continue;
}
let index = selected_file_functions(symbols, graph, project_path, file);
let mut production_spans = Vec::new();
let mut excluded_spans = Vec::new();
for function in &index.functions {
let span = (function.line_start, function.line_end);
if function.is_test
|| function.is_bench
|| function.is_test_support
|| function.is_binary_wrapper_main
{
excluded_spans.push(span);
} else {
production_spans.push(span);
}
}
filters.push(SourceSpanFilter {
file: file.clone(),
production_spans,
excluded_spans,
});
}
ProductionFilter { files: filters }
}
fn is_selected_bin_wrapper_main(file: &Path, graph_name: &str) -> bool {
(graph_name == "main" || graph_name.ends_with("::main")) && path_has_src_bin(file)
}
fn path_has_src_bin(path: &Path) -> bool {
let mut saw_src = false;
for component in path.components() {
let name = component.as_os_str();
if saw_src && name == "bin" {
return true;
}
saw_src = name == "src";
}
false
}
fn same_path(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}

495
src/file_summary.rs Normal file
View file

@ -0,0 +1,495 @@
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(&[
"Counts are Rust AST items found in the selected file.",
"Functions includes free functions and impl methods.",
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(),
"const".cyan(),
report.consts.to_string().bold(),
"static".cyan(),
report.statics.to_string().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::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
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,11 @@
use colored::Colorize;
use serde::Serialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::render;
use crate::{ast_parser, datapaths, file_metrics, flow, render};
/// Per-file line count data.
#[derive(Debug, Clone, Serialize)]
#[allow(dead_code)]
pub struct FileLocStats {
pub path: PathBuf,
@ -55,6 +56,24 @@ struct LocJson {
directory_breakdown: Vec<DirBreakdownJson>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileLineSpan {
pub function: String,
pub line_start: usize,
pub line_end: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileProjectedLineReachabilityReport {
pub file: String,
pub total_lines: usize,
pub code_lines: usize,
pub projected_reachable_lines: usize,
pub projected_unreachable_lines: usize,
pub reachable_spans: Vec<FileLineSpan>,
pub unreachable_spans: Vec<FileLineSpan>,
}
/// Classify a single trimmed line as code or non-code, tracking block comment state.
/// Returns `(is_code, new_in_block_comment)`.
fn classify_line(trimmed: &str, in_block_comment: bool) -> (bool, bool) {
@ -90,7 +109,7 @@ fn classify_line(trimmed: &str, in_block_comment: bool) -> (bool, bool) {
}
/// Count lines in a single file. Code lines exclude blank lines and comment-only lines.
fn count_lines(path: &Path) -> Option<FileLocStats> {
pub fn count_file_lines(path: &Path) -> Option<FileLocStats> {
let content = std::fs::read_to_string(path).ok()?;
let total_lines = content.lines().count();
let mut code_lines = 0usize;
@ -111,6 +130,93 @@ fn count_lines(path: &Path) -> Option<FileLocStats> {
})
}
fn count_lines(path: &Path) -> Option<FileLocStats> {
count_file_lines(path)
}
fn code_lines_in_spans(path: &Path, spans: &[(usize, usize)]) -> HashSet<usize> {
if spans.is_empty() {
return HashSet::new();
}
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(_) => return HashSet::new(),
};
let mut covered = HashSet::new();
let mut in_block_comment = false;
for (index, line) in content.lines().enumerate() {
let line_number = index + 1;
let (is_code, new_in_block) = classify_line(line.trim(), in_block_comment);
in_block_comment = new_in_block;
if !is_code {
continue;
}
if spans
.iter()
.any(|(start, end)| line_number >= *start && line_number <= *end)
{
covered.insert(line_number);
}
}
covered
}
pub fn analyze_file_projected_line_reachability(
file: &Path,
file_functions: &file_metrics::FileFunctionIndex,
reachable_functions: &HashSet<String>,
file_display_path: &str,
) -> Option<FileProjectedLineReachabilityReport> {
let file_stats = count_file_lines(file)?;
let mut reachable_spans = Vec::new();
let mut unreachable_spans = Vec::new();
let mut production_ranges = Vec::new();
let mut reachable_ranges = Vec::new();
for function in &file_functions.functions {
if function.is_test
|| function.is_bench
|| function.is_test_support
|| function.is_binary_wrapper_main
{
continue;
}
production_ranges.push((function.line_start, function.line_end));
let span = FileLineSpan {
function: function.display_name.clone(),
line_start: function.line_start,
line_end: function.line_end,
};
if reachable_functions.contains(&function.graph_name) {
reachable_ranges.push((function.line_start, function.line_end));
reachable_spans.push(span);
} else {
unreachable_spans.push(span);
}
}
let code_lines = code_lines_in_spans(file, &production_ranges).len();
let projected_reachable_lines = code_lines_in_spans(file, &reachable_ranges).len();
let projected_unreachable_lines = code_lines.saturating_sub(projected_reachable_lines);
Some(FileProjectedLineReachabilityReport {
file: file_display_path.to_string(),
total_lines: file_stats.total_lines,
code_lines,
projected_reachable_lines,
projected_unreachable_lines,
reachable_spans,
unreachable_spans,
})
}
/// Count lines for all files and compute aggregate statistics.
pub fn analyze_files(files: &[PathBuf]) -> (Vec<FileLocStats>, Option<AggregateStats>) {
let mut file_stats: Vec<FileLocStats> = files.iter().filter_map(|f| count_lines(f)).collect();
@ -356,6 +462,117 @@ pub fn render_loc_json(files: &[PathBuf], project_path: &Path) {
println!("{}", serde_json::to_string(&output).unwrap());
}
pub fn render_loc_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) {
let report = analyze_file_projected_line_reachability_from_files(rs_files, project_path, file);
render_file_projected_line_reachability_report(&report, verbose);
}
pub fn render_loc_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
if let Some(report) =
analyze_file_projected_line_reachability_from_files(rs_files, project_path, file)
{
println!("{}", serde_json::to_string(&report).unwrap());
} else {
println!("null");
}
}
pub fn analyze_file_projected_line_reachability_from_files(
rs_files: &[PathBuf],
project_path: &Path,
file: &Path,
) -> Option<FileProjectedLineReachabilityReport> {
let symbols = 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, &graph, project_path, file);
let file_display_path = relative_path_string(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();
analyze_file_projected_line_reachability(
file,
&file_functions,
&reachable_functions,
&file_display_path,
)
}
pub fn render_file_projected_line_reachability_report(
report: &Option<FileProjectedLineReachabilityReport>,
verbose: bool,
) {
render::section_header("Projected line reachability");
if verbose {
render::verbose_block(&[
"Physical lines count every source line in the selected file.",
"Code lines exclude blank/comment-only lines and test/support/wrapper spans for selected-file production accounting.",
"Projected reachable lines are production code lines inside functions statically reached from project tests/benches.",
]);
}
let Some(report) = report else {
println!(" {}", "Unable to read selected file.".yellow());
return;
};
let pct = if report.code_lines == 0 {
0.0
} else {
report.projected_reachable_lines as f64 / report.code_lines as f64 * 100.0
};
println!(" file {}", report.file.bold());
println!(" physical lines {}", report.total_lines);
println!(" production code lines {}", report.code_lines);
println!(
" statically reachable lines {} / {} ({:.1}%)",
report.projected_reachable_lines, report.code_lines, pct
);
println!(
" not statically reachable lines {}",
report.projected_unreachable_lines
);
println!();
println!(" {}", "reachable function spans:".bold());
if report.reachable_spans.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for span in &report.reachable_spans {
println!(
" {} lines {}-{}",
span.function, span.line_start, span.line_end
);
}
}
println!();
println!(" {}", "unreachable function spans:".bold().yellow());
if report.unreachable_spans.is_empty() {
println!(" {}", "<none>".dimmed());
} else {
for span in &report.unreachable_spans {
println!(
" {} lines {}-{}",
span.function, span.line_start, span.line_end
);
}
}
}
fn relative_path_string(project_path: &Path, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
// ── Treemap Visualization ────────────────────────────────────────────
/// A node in the directory tree used for treemap rendering.

View file

@ -1,7 +1,9 @@
mod ast_parser;
mod call_trace;
mod circuits;
mod cluster;
mod complexity;
mod coverage;
mod datapaths;
mod dead_code;
mod deps;
@ -9,6 +11,8 @@ mod diagnostics;
mod discovery;
mod dist;
mod dump;
mod file_metrics;
mod file_summary;
mod flow;
mod graph;
mod guide;
@ -78,6 +82,14 @@ enum Commands {
#[arg(long, name = "PATH::FUNCTION")]
entry: Option<String>,
},
/// Dynamic line and branch coverage from Rust source-based coverage data.
Coverage {
/// Parse an existing llvm-cov export JSON from CSTAT_LLVM_COV_EXPORT_JSON instead of running tests.
#[arg(long)]
no_run: bool,
},
/// Value-cluster transcript from AST def-use structure.
Cluster,
/// Opt-in legacy and exploratory analyses
Advanced {
#[command(subcommand)]
@ -150,10 +162,8 @@ fn main() {
}
let verbose = cli.verbose;
let project_path = &cli.path;
let rs_files = match discovery::discover_rs_files(project_path) {
Ok(files) => files,
let target = match discovery::resolve_target(&cli.path) {
Ok(target) => target,
Err(e) => {
if json {
let err = serde_json::json!({"error": format!("{}", e)});
@ -164,12 +174,130 @@ fn main() {
std::process::exit(1);
}
};
let project_path = &target.project_path;
let rs_files = discovery::files_for_project(&target);
let command = cli.command.unwrap_or(Commands::Summary);
let project_path = project_path
.canonicalize()
.unwrap_or_else(|_| project_path.to_path_buf());
if let Some(file) = discovery::selected_file(&target) {
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);
} else {
loc::render_loc_file(project_rs_files, project_path, file, verbose);
}
}
Commands::Symbols => {
if json {
symbols::render_symbols_file_json(project_rs_files, project_path, file);
} else {
symbols::render_symbols_file(project_rs_files, project_path, file, verbose);
}
}
Commands::Complexity => {
complexity::run(target_rs_files, project_path, json, verbose);
}
Commands::Cluster => {
if json {
cluster::render_cluster_file_json(project_rs_files, project_path, file);
} else {
cluster::render_cluster_file(project_rs_files, project_path, file, verbose);
}
}
Commands::Deps { .. } => {
if json {
deps::render_deps_file_json(project_rs_files, project_path, file);
} else {
deps::render_deps_file(project_rs_files, project_path, file, verbose);
}
}
Commands::DeadCode => {
if json {
dead_code::render_dead_code_file_json(project_rs_files, project_path, file);
} else {
dead_code::render_dead_code_file(project_rs_files, project_path, file, verbose);
}
}
Commands::TestReachability => {
if json {
datapaths::render_test_reachability_file_json(
project_rs_files,
project_path,
file,
);
} else {
datapaths::render_test_reachability_file(
project_rs_files,
project_path,
file,
verbose,
);
}
}
Commands::CallTrace { entry } => {
if let Err(message) = call_trace::run_file(
project_rs_files,
project_path,
file,
entry.as_deref(),
json,
verbose,
) {
if json {
let err = serde_json::json!({"error": message});
println!("{}", serde_json::to_string(&err).unwrap());
} else {
eprintln!("Error: {}", message);
}
std::process::exit(1);
}
}
Commands::Coverage { no_run } => {
if let Err(message) = coverage::render_coverage(
project_rs_files,
project_path,
Some(file),
no_run,
json,
) {
if json {
let err = serde_json::json!({"error": message});
println!("{}", serde_json::to_string(&err).unwrap());
} else {
eprintln!("Error: {}", message);
}
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 => {
@ -196,6 +324,9 @@ fn main() {
Commands::Complexity => {
complexity::run(&rs_files, &project_path, json, verbose);
}
Commands::Cluster => {
cluster::render_cluster(&rs_files, &project_path, json, verbose);
}
Commands::Deps { top } => {
if json {
deps::render_deps_json(&rs_files, &project_path);
@ -230,6 +361,19 @@ fn main() {
std::process::exit(1);
}
}
Commands::Coverage { no_run } => {
if let Err(message) =
coverage::render_coverage(&rs_files, &project_path, None, no_run, json)
{
if json {
let err = serde_json::json!({"error": message});
println!("{}", serde_json::to_string(&err).unwrap());
} else {
eprintln!("Error: {}", message);
}
std::process::exit(1);
}
}
Commands::Advanced { command } => match command {
AdvancedCommands::Summary => {
if json {

View file

@ -55,7 +55,7 @@ pub fn render_summary(rs_files: &[PathBuf], project_path: &Path, verbose: bool)
render_connectome_section(&dep_analysis, term_w, verbose);
render_distributions_section(&func_complexities, verbose);
render_centrality_section(rs_files, &project_path_canon, verbose);
render_datapath_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,
@ -385,22 +385,22 @@ fn render_centrality_section(rs_files: &[PathBuf], project_path_canon: &Path, ve
}
}
/// Render the datapath coverage section.
fn render_datapath_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) {
render::section_header("Datapath Coverage");
/// 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 call graph traversal.",
"Entry points are main() and #[test] functions. Reachability is computed via BFS.",
"Coverage = reachable / total. Cold functions = unreachable = dead code candidates.",
"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::datapath_summary(rs_files, project_path_canon) {
Some(ds) => render_datapath_coverage(&ds),
None => println!(" {}", "No datapath data available.".dimmed()),
match datapaths::entrypoint_reachability_summary(rs_files, project_path_canon) {
Some(ds) => render_entrypoint_reachability(&ds),
None => println!(" {}", "No reachability data available.".dimmed()),
}
}
@ -513,8 +513,8 @@ struct SummaryGraphJson {
}
#[derive(Serialize)]
struct SummaryDatapathJson {
coverage_pct: f64,
struct SummaryReachabilityJson {
reachable_pct: f64,
total_functions: usize,
reachable_count: usize,
cold_count: usize,
@ -535,7 +535,7 @@ struct SummaryJson {
complexity: Option<SummaryComplexityJson>,
deps: SummaryDepsJson,
graph: Option<SummaryGraphJson>,
datapaths: Option<SummaryDatapathJson>,
reachability: Option<SummaryReachabilityJson>,
circuits: Option<SummaryCircuitsJson>,
}
@ -610,12 +610,14 @@ pub fn render_summary_json(rs_files: &[PathBuf], project_path: &Path) {
top_clustered: gs.top_clustered,
});
let datapaths_json =
datapaths::datapath_summary(rs_files, &project_path_canon).map(|ds| SummaryDatapathJson {
coverage_pct: ds.coverage_pct,
total_functions: ds.total_functions,
reachable_count: ds.reachable_count,
cold_count: ds.cold_count,
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 =
@ -632,7 +634,7 @@ pub fn render_summary_json(rs_files: &[PathBuf], project_path: &Path) {
complexity: complexity_json,
deps: deps_json,
graph: graph_json,
datapaths: datapaths_json,
reachability: reachability_json,
circuits: circuits_json,
};

View file

@ -119,25 +119,25 @@ pub(super) fn render_graph_centrality(gs: &graph::GraphSummary) {
}
}
/// Render datapath coverage section.
pub(super) fn render_datapath_coverage(ds: &datapaths::DatapathSummary) {
let coverage_color = if ds.coverage_pct > 80.0 {
format!("{:.1}%", ds.coverage_pct)
/// 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.coverage_pct > 50.0 {
format!("{:.1}%", ds.coverage_pct)
} else if ds.reachable_pct > 50.0 {
format!("{:.1}%", ds.reachable_pct)
.yellow()
.bold()
.to_string()
} else {
format!("{:.1}%", ds.coverage_pct).red().bold().to_string()
format!("{:.1}%", ds.reachable_pct).red().bold().to_string()
};
println!(
" {} {} reachable from entry points",
"Coverage:".bold(),
coverage_color,
"Reachability:".bold(),
reachability_color,
);
println!(
" {} reachable, {} cold (of {} total)",

View file

@ -34,6 +34,38 @@ pub struct SymbolReport {
pub files: Vec<FileSymbolCounts>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FileSymbolKind {
Fn,
Struct,
Enum,
Trait,
Const,
Static,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileSymbolRow {
pub kind: FileSymbolKind,
pub symbol: String,
pub line_start: Option<usize>,
pub line_end: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileSymbolReport {
pub file: String,
pub total: usize,
pub functions: usize,
pub structs: usize,
pub enums: usize,
pub traits: usize,
pub consts: usize,
pub statics: usize,
pub symbols: Vec<FileSymbolRow>,
}
pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolReport {
let mut files: Vec<FileSymbolCounts> = symbols
.files
@ -82,6 +114,97 @@ pub fn analyze_files(rs_files: &[PathBuf], project_path: &Path) -> SymbolReport
analyze_symbols(&symbols, project_path)
}
pub fn analyze_symbols_file(
symbols: &ast_parser::ProjectSymbols,
project_path: &Path,
file: &Path,
) -> FileSymbolReport {
let file_display = relative_path_string(project_path, file);
let Some(file_symbols) = symbols.files.iter().find(|entry| entry.path == file) else {
return FileSymbolReport {
file: file_display,
total: 0,
functions: 0,
structs: 0,
enums: 0,
traits: 0,
consts: 0,
statics: 0,
symbols: Vec::new(),
};
};
let mut rows = Vec::new();
for function in &file_symbols.functions {
rows.push(FileSymbolRow {
kind: FileSymbolKind::Fn,
symbol: function.name.clone(),
line_start: Some(function.line_start),
line_end: Some(function.line_end),
});
}
for item in &file_symbols.structs {
rows.push(FileSymbolRow {
kind: FileSymbolKind::Struct,
symbol: item.name.clone(),
line_start: None,
line_end: None,
});
}
for item in &file_symbols.enums {
rows.push(FileSymbolRow {
kind: FileSymbolKind::Enum,
symbol: item.name.clone(),
line_start: None,
line_end: None,
});
}
for item in &file_symbols.traits {
rows.push(FileSymbolRow {
kind: FileSymbolKind::Trait,
symbol: item.name.clone(),
line_start: None,
line_end: None,
});
}
for item in &file_symbols.consts {
rows.push(FileSymbolRow {
kind: FileSymbolKind::Const,
symbol: item.name.clone(),
line_start: None,
line_end: None,
});
}
for item in &file_symbols.statics {
rows.push(FileSymbolRow {
kind: FileSymbolKind::Static,
symbol: item.name.clone(),
line_start: None,
line_end: None,
});
}
let functions = file_symbols.functions.len();
let structs = file_symbols.structs.len();
let enums = file_symbols.enums.len();
let traits = file_symbols.traits.len();
let consts = file_symbols.consts.len();
let statics = file_symbols.statics.len();
let total = functions + structs + enums + traits + consts + statics;
FileSymbolReport {
file: file_display,
total,
functions,
structs,
enums,
traits,
consts,
statics,
symbols: rows,
}
}
pub fn render_symbol_report(report: &SymbolReport, verbose: bool) {
render::section_header("Symbols");
@ -153,6 +276,86 @@ pub fn render_symbols_json(rs_files: &[PathBuf], project_path: &Path) {
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn render_symbols_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) {
let symbols = ast_parser::parse_project(rs_files);
let report = analyze_symbols_file(&symbols, project_path, file);
render_file_symbol_report(&report, verbose);
}
pub fn render_symbols_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) {
let symbols = ast_parser::parse_project(rs_files);
let report = analyze_symbols_file(&symbols, project_path, file);
println!("{}", serde_json::to_string(&report).unwrap());
}
pub fn render_file_symbol_report(report: &FileSymbolReport, verbose: bool) {
render::section_header("Symbols");
if verbose {
render::verbose_block(&[
"Counts are Rust AST items found in the selected file.",
"Functions includes free functions and impl methods.",
"Line spans are available for functions; other symbol kinds show lines -.",
]);
}
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(),
"const".cyan(),
report.consts.to_string().bold(),
"static".cyan(),
report.statics.to_string().bold(),
);
println!();
println!(
" {:<10} {:<48} {}",
"kind".bold(),
"symbol".bold(),
"lines".bold()
);
for row in &report.symbols {
let lines = match (row.line_start, row.line_end) {
(Some(start), Some(end)) => format!("{start}-{end}"),
_ => "-".to_string(),
};
println!(
" {:<10} {:<48} {}",
file_symbol_kind_label(&row.kind),
truncate(&row.symbol, 48),
lines,
);
}
}
fn file_symbol_kind_label(kind: &FileSymbolKind) -> &'static str {
match kind {
FileSymbolKind::Fn => "fn",
FileSymbolKind::Struct => "struct",
FileSymbolKind::Enum => "enum",
FileSymbolKind::Trait => "trait",
FileSymbolKind::Const => "const",
FileSymbolKind::Static => "static",
}
}
fn relative_path_string(project_path: &Path, path: &Path) -> String {
path.strip_prefix(project_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn strip_prefix<'a>(path: &'a Path, base: &Path) -> &'a Path {
path.strip_prefix(base).unwrap_or(path)
}

166
tests/cluster_cli.rs Normal file
View file

@ -0,0 +1,166 @@
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-cluster-{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"),
r#"
struct Config;
struct Worker;
impl Config {
fn new(_args: Vec<String>) -> Self { Self }
}
impl Worker {
fn new(_config: &Config) -> Self { Self }
fn wait(&self, _config: &Config) -> bool { true }
}
fn serve(_worker: Worker, ready: bool) -> usize {
if ready { 1 } else { 0 }
}
fn cleanup(value: usize) -> usize { value }
fn run(args: Vec<String>) -> usize {
let config = Config::new(args);
let worker = Worker::new(&config);
let ready = worker.wait(&config);
let result = serve(worker, ready);
let mut total = 0usize;
total = cleanup(result);
total += 1;
total
}
"#,
)
.unwrap();
root
}
fn run_cstat(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.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 cluster_human_verbose_explains_value_clusters() {
let root = temp_project("human");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["cluster", "-v"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Value clusters"), "stdout={stdout}");
assert!(
stdout.contains(
"value = a function parameter or local let-binding collected from the Rust AST."
),
"missing value explanation: {stdout}"
);
assert!(stdout.contains("references"), "stdout={stdout}");
assert!(stdout.contains("mutations"), "stdout={stdout}");
assert!(stdout.contains("connected values"), "stdout={stdout}");
assert!(stdout.contains("fn run:"), "stdout={stdout}");
assert!(stdout.contains("config"), "stdout={stdout}");
assert!(stdout.contains("worker"), "stdout={stdout}");
assert!(stdout.contains("total"), "stdout={stdout}");
assert!(
!stdout.contains("def live uses nbrs pressure"),
"old header still present: {stdout}"
);
assert!(
!stdout.contains("pressure"),
"pressure still present: {stdout}"
);
assert!(
!stdout.contains("Top signal"),
"old signal text still present: {stdout}"
);
assert!(
!stdout.contains("Use lines"),
"old use-line text still present: {stdout}"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn cluster_json_reports_def_use_clusters() {
let root = temp_project("json");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["--json", "cluster"]);
assert_success(&output);
let json: Value = serde_json::from_slice(&output.stdout).expect("cluster json");
let functions = json
.get("functions")
.and_then(Value::as_array)
.expect("functions array");
let run = functions
.iter()
.find(|function| function.get("name").and_then(Value::as_str) == Some("run"))
.expect("run function");
let clusters = run
.get("clusters")
.and_then(Value::as_array)
.expect("clusters array");
let names = clusters
.iter()
.filter_map(|cluster| cluster.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
assert!(names.contains(&"config"), "names={names:?}");
assert!(names.contains(&"worker"), "names={names:?}");
let total = clusters
.iter()
.find(|cluster| cluster.get("name").and_then(Value::as_str) == Some("total"))
.expect("total cluster");
let mutation_lines = total
.get("mutation_lines")
.and_then(Value::as_array)
.expect("mutation_lines array");
assert_eq!(mutation_lines.len(), 2, "total={total}");
for removed_key in [
"pressure",
"defined_line",
"live_start",
"live_end",
"live_span",
] {
assert!(
total.get(removed_key).is_none(),
"{removed_key} should not be present in total={total}"
);
}
fs::remove_dir_all(root).unwrap();
}

139
tests/coverage_cli.rs Normal file
View file

@ -0,0 +1,139 @@
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-coverage-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"),
r#"pub fn target(flag: bool) -> u8 {
if flag {
1
} else {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn covers_true_arm() {
assert_eq!(target(true), 1);
}
}
"#,
)
.unwrap();
root
}
fn export_json(root: &Path) -> PathBuf {
let source = root.join("src/lib.rs");
let escaped_source = source.to_string_lossy().replace('\\', "\\\\");
let content = format!(
r#"{{"type":"llvm.coverage.json.export","data":[{{"files":[{{"filename":"{}","segments":[[1,1,1,true,true,false],[2,5,1,true,true,false],[3,9,1,true,true,false],[5,9,0,true,true,false]],"branches":[[2,8,2,12,1,0,0,0,0]],"summary":{{}}}}]}}]}}"#,
escaped_source
);
let export = root.join("coverage.json");
fs::write(&export, content).unwrap();
export
}
fn run_cstat(path: &Path, export: &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("CSTAT_LLVM_COV_EXPORT_JSON", export);
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 coverage_no_run_reports_dynamic_lines_and_branches_from_export_json() {
let root = temp_project("json");
let file = root.join("src/lib.rs");
let export = export_json(&root);
let output = run_cstat(&file, &export, &["--json", "coverage", "--no-run"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse coverage JSON");
assert_eq!(value["target"], "src/lib.rs");
assert!(value.get("static_reachability").is_some(), "json={stdout}");
assert!(
value.get("projected_line_reachability").is_some(),
"json={stdout}"
);
assert_eq!(value["dynamic_line_coverage"]["coverable_lines"], 4);
assert_eq!(value["dynamic_line_coverage"]["executed_lines"], 3);
assert_eq!(
value["dynamic_line_coverage"]["missing_line_numbers"],
serde_json::json!([5])
);
assert_eq!(value["branch_coverage"]["total_branch_outcomes"], 2);
assert_eq!(value["branch_coverage"]["covered_branch_outcomes"], 1);
assert_eq!(value["branch_coverage"]["missing_branch_outcomes"], 1);
assert_eq!(
value["branch_coverage"]["sites"][0]["taken"],
serde_json::json!(["true"])
);
assert_eq!(
value["branch_coverage"]["sites"][0]["missing"],
serde_json::json!(["false"])
);
assert_eq!(value["tool_status"]["ran_tests"], false);
fs::remove_dir_all(root).unwrap();
}
#[test]
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"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for section in [
"Static test/benchmark reachability",
"Projected line reachability",
"Dynamic line coverage",
"Branch coverage",
] {
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();
}

326
tests/file_summary_cli.rs Normal file
View file

@ -0,0 +1,326 @@
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-file-summary-{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"),
r#"
mod other;
use crate::other::external_helper;
use std::fmt::Debug;
const LIMIT: usize = 10;
static NAME: &str = "fixture";
struct Parser;
enum Mode {
Fast,
Slow,
}
trait Parse {
fn parse(&self);
}
fn live() {
helper();
external_helper();
}
fn helper() {}
fn orphan() {}
fn dead_parent() {
dead_child();
}
fn dead_child() {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn covers_live() {
live();
}
}
"#,
)
.unwrap();
fs::write(
root.join("src/other.rs"),
r#"
pub fn external_helper() {}
pub fn other_dead() {}
"#,
)
.unwrap();
root
}
fn run_cstat(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.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 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");
let file = root.join("src/lib.rs");
for command in [
"loc",
"symbols",
"deps",
"dead-code",
"test-reachability",
"call-trace",
] {
let output = run_cstat(&file, &["-v", command]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.is_empty(), "empty output for {command}");
if command == "symbols" {
for expected in [
"fn 6", "struct 1", "enum 1", "trait 1", "const 1", "static 1",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
}
if command == "call-trace" {
for expected in ["live", "helper", "other::external_helper"] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
}
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn file_path_errors_are_clear() {
let root = temp_project("errors");
let notes = root.join("notes.rs");
fs::write(&notes, "fn scratch() {}\n").unwrap();
let output = run_cstat(&notes, &["summary"]);
assert!(
!output.status.success(),
"unexpected success: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("file is not under this crate's src, tests, or benches"),
"stderr={stderr}",
);
let output = run_cstat(&notes, &["--json", "summary"]);
assert!(
!output.status.success(),
"unexpected json success: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse error JSON");
assert!(
value["error"]
.as_str()
.unwrap()
.contains("file is not under this crate's src, tests, or benches"),
"json={value}",
);
fs::remove_dir_all(root).unwrap();
}

View file

@ -60,6 +60,30 @@ fn assert_success(output: &Output) {
);
}
fn json_array_contains_suffix(array: &Value, suffix: &str) -> bool {
array
.as_array()
.expect("json array")
.iter()
.filter_map(Value::as_str)
.any(|value| value.ends_with(suffix))
}
fn json_edges_contain_suffix(array: &Value, caller_suffix: &str, callee_suffix: &str) -> bool {
array
.as_array()
.expect("json edge array")
.iter()
.any(|edge| {
edge["caller"]
.as_str()
.is_some_and(|caller| caller.ends_with(caller_suffix))
&& edge["callee"]
.as_str()
.is_some_and(|callee| callee.ends_with(callee_suffix))
})
}
#[test]
fn test_reachability_human_output_uses_static_reachability_terms() {
let root = temp_project("human");
@ -101,6 +125,7 @@ fn test_reachability_human_output_uses_static_reachability_terms() {
fn test_reachability_json_uses_reachability_fields() {
let root = temp_project("json");
let output = run_cstat(&root, &["--json", "test-reachability"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
@ -271,3 +296,271 @@ fn help_lists_test_reachability_and_advanced_flow_heatmap() {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_file_reachability_covers_mvp_chat_false_negative_patterns() {
let root = temp_project("mvp-chat-patterns");
let bin_dir = root.join("src/bin");
fs::create_dir_all(&bin_dir).unwrap();
let file = bin_dir.join("mvp_chat.rs");
fs::write(
&file,
r#"
struct RuntimeConfigProfile;
struct RelayMode;
struct Config {
profile: RuntimeConfigProfile,
relay_mode: RelayMode,
model_path: Option<&'static str>,
}
impl RuntimeConfigProfile {
fn as_str(&self) -> &'static str { "docker" }
fn default_provider(&self) -> &'static str { "vastai" }
}
impl RelayMode {
fn as_str(&self) -> &'static str { "auto" }
}
fn relay_mode_env_value(_mode: &str) -> &'static str { "1" }
fn default_cached_model_path() -> &'static str { "/tmp/model" }
fn run_status() -> bool { true }
fn request_stop() {}
impl Config {
fn orchestrator_cli_args(&self) {
let mut args = Vec::new();
args.extend([
"--relay-mode".to_owned(),
relay_mode_env_value(self.relay_mode.as_str()).to_owned(),
]);
self.profile.as_str();
self.model_path.unwrap_or_else(default_cached_model_path);
}
}
struct ParsedArgs { provider: Option<&'static str> }
impl ParsedArgs {
fn apply_profile(&mut self, config_profile: RuntimeConfigProfile) {
self.set_provider_selector(config_profile.default_provider());
}
fn set_provider_selector(&mut self, provider: &'static str) {
self.provider = Some(provider);
}
}
trait VastAiApproval {
fn ask(&mut self) -> bool;
}
struct StdinVastAiApproval;
impl VastAiApproval for StdinVastAiApproval {
fn ask(&mut self) -> bool { true }
}
fn confirm_vastai_approval<P: VastAiApproval>(approval: &mut P) -> bool {
approval.ask()
}
struct OfferPreviewer;
struct Offer {
previewer: OfferPreviewer,
}
impl OfferPreviewer {
fn preview(&self) {}
}
impl Offer {
fn show(&self) {
self.previewer.preview();
}
}
struct Command;
struct OrchChild(Command);
impl OrchChild {
fn shutdown(&mut self, _wait: bool) {}
}
impl Drop for OrchChild {
fn drop(&mut self) {
self.shutdown(false);
}
}
fn consume_child(_child: OrchChild) {}
fn ensure_orch_binary() -> bool {
ensure_orch_binary_with("config", "root", "default-orch", run_status)
}
fn ensure_orch_binary_with(
_config: &str,
_root: &str,
_default_orch: &str,
run_status_fn: fn() -> bool,
) -> bool {
run_status_fn()
}
fn install_signal_handler() {
unsafe {
libc::signal(15, request_stop as *const () as usize);
}
}
struct MutRunner;
impl MutRunner {
fn run(&mut self, value: &'static str) {
nested_method_leaf(value);
}
}
fn nested_arg() -> &'static str {
nested_free_leaf();
"x"
}
fn nested_free_leaf() {}
fn nested_method_leaf(_value: &'static str) {}
fn main() {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn covers_selected_file_patterns() {
let config = Config {
profile: RuntimeConfigProfile,
relay_mode: RelayMode,
model_path: None,
};
config.orchestrator_cli_args();
let mut parsed = ParsedArgs { provider: None };
parsed.apply_profile(RuntimeConfigProfile);
ensure_orch_binary();
let mut runner = MutRunner;
runner.run(nested_arg());
let mut approval = StdinVastAiApproval;
confirm_vastai_approval(&mut approval);
let offer = Offer { previewer: OfferPreviewer };
offer.show();
let child = OrchChild(Command);
consume_child(child);
install_signal_handler();
}
}
"#,
)
.unwrap();
let output = run_cstat(&file, &["--json", "test-reachability"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse selected reachability JSON");
assert_eq!(value["file"], "src/bin/mvp_chat.rs");
assert!(
json_array_contains_suffix(&value["wrapper_entrypoints"], "main"),
"wrapper_entrypoints={:?}",
value["wrapper_entrypoints"]
);
assert!(
!json_array_contains_suffix(&value["unreachable_functions"], "main"),
"main should be classified as wrapper, not uncovered production: {stdout}"
);
let expected_suffixes = [
"ParsedArgs::apply_profile",
"ParsedArgs::set_provider_selector",
"RuntimeConfigProfile::as_str",
"RuntimeConfigProfile::default_provider",
"relay_mode_env_value",
"default_cached_model_path",
"ensure_orch_binary",
"ensure_orch_binary_with",
"run_status",
"request_stop",
"StdinVastAiApproval::ask",
"OfferPreviewer::preview",
"MutRunner::run",
"nested_arg",
"nested_free_leaf",
"nested_method_leaf",
"OrchChild::drop",
"OrchChild::shutdown",
];
for suffix in expected_suffixes {
assert!(
json_array_contains_suffix(&value["reachable_functions"], suffix),
"reachable_functions missing {suffix}: {stdout}"
);
assert!(
!json_array_contains_suffix(&value["unreachable_functions"], suffix),
"unreachable_functions still contains {suffix}: {stdout}"
);
}
assert_eq!(
value["unreachable_function_count"], 0,
"selected-file production functions should all be reachable: {stdout}"
);
for (caller, callee) in [
(
"Config::orchestrator_cli_args",
"RuntimeConfigProfile::as_str",
),
("Config::orchestrator_cli_args", "RelayMode::as_str"),
("Config::orchestrator_cli_args", "relay_mode_env_value"),
("Config::orchestrator_cli_args", "default_cached_model_path"),
(
"ParsedArgs::apply_profile",
"ParsedArgs::set_provider_selector",
),
(
"ParsedArgs::apply_profile",
"RuntimeConfigProfile::default_provider",
),
("ensure_orch_binary", "ensure_orch_binary_with"),
("ensure_orch_binary_with", "run_status"),
("install_signal_handler", "request_stop"),
("confirm_vastai_approval", "StdinVastAiApproval::ask"),
("Offer::show", "OfferPreviewer::preview"),
("MutRunner::run", "nested_method_leaf"),
("tests::covers_selected_file_patterns", "nested_arg"),
("nested_arg", "nested_free_leaf"),
("OrchChild::drop", "OrchChild::shutdown"),
] {
assert!(
json_edges_contain_suffix(&value["reachable_edges"], caller, callee),
"reachable_edges missing {caller} -> {callee}: {stdout}"
);
}
let dead_output = run_cstat(&file, &["--json", "dead-code"]);
assert_success(&dead_output);
let dead_stdout = String::from_utf8(dead_output.stdout).unwrap();
let dead_value: Value = serde_json::from_str(&dead_stdout).expect("parse dead-code JSON");
let dead_functions = dead_value["functions"]
.as_array()
.expect("dead-code functions");
for suffix in expected_suffixes {
let row = dead_functions
.iter()
.find(|entry| {
entry["function"]
.as_str()
.is_some_and(|function| function.ends_with(suffix))
})
.unwrap_or_else(|| panic!("dead-code row missing {suffix}: {dead_stdout}"));
assert_eq!(
row["candidate"], false,
"dead-code still marks {suffix} as candidate: {dead_stdout}"
);
}
fs::remove_dir_all(root).unwrap();
}