use std::collections::HashMap;
use crate::analyzer::FunctionAnalysis;
use crate::config::Config;
use crate::findings::Suppression;
use crate::report::Summary;
pub(super) fn compute_coupling(
parsed: &[(String, String, syn::File)],
config: &Config,
) -> Option<crate::coupling::CouplingAnalysis> {
if !config.coupling.enabled {
return None;
}
Some(crate::coupling::analyze_coupling(parsed))
}
pub(super) fn mark_coupling_suppressions(
analysis: Option<&mut crate::coupling::CouplingAnalysis>,
suppression_lines: &std::collections::HashMap<String, Vec<Suppression>>,
) {
let Some(analysis) = analysis else { return };
let suppressed_modules: std::collections::HashSet<String> = suppression_lines
.iter()
.filter(|(_, sups)| {
sups.iter()
.any(|s| s.covers(crate::findings::Dimension::Coupling))
})
.map(|(path, _)| crate::coupling::file_to_module(path))
.collect();
for m in &mut analysis.metrics {
if suppressed_modules.contains(&m.module_name) {
m.suppressed = true;
}
}
}
pub(super) fn count_coupling_warnings(
analysis: Option<&mut crate::coupling::CouplingAnalysis>,
config: &crate::config::sections::CouplingConfig,
summary: &mut Summary,
) {
let Some(analysis) = analysis else { return };
for m in &mut analysis.metrics {
m.warning = false;
if m.suppressed {
continue;
}
let instability_exceeded = m.afferent > 0 && m.instability > config.max_instability;
if instability_exceeded || m.afferent > config.max_fan_in || m.efferent > config.max_fan_out
{
m.warning = true;
summary.coupling_warnings += 1;
}
}
summary.coupling_cycles = analysis.cycles.len();
}
pub(super) fn run_guarded_detection<T>(
enabled: bool,
detect: impl FnOnce(&[(String, String, syn::File)], &Config) -> Vec<T>,
parsed: &[(String, String, syn::File)],
config: &Config,
) -> Vec<T> {
if !enabled {
return vec![];
}
detect(parsed, config)
}
pub(super) struct DryResults {
pub(super) duplicates: Vec<crate::dry::functions::DuplicateGroup>,
pub(super) dead_code: Vec<crate::dry::dead_code::DeadCodeWarning>,
pub(super) fragments: Vec<crate::dry::fragments::FragmentGroup>,
pub(super) boilerplate: Vec<crate::dry::boilerplate::BoilerplateFind>,
pub(super) wildcard_warnings: Vec<crate::dry::wildcards::WildcardImportWarning>,
}
pub(super) fn run_dry_detection(
parsed: &[(String, String, syn::File)],
config: &Config,
suppression_lines: &std::collections::HashMap<String, Vec<Suppression>>,
api_lines: &std::collections::HashMap<String, std::collections::HashSet<usize>>,
summary: &mut Summary,
) -> DryResults {
let duplicates = run_guarded_detection(
config.duplicates.enabled,
|p, c| crate::dry::functions::detect_duplicates(p, &c.duplicates),
parsed,
config,
);
let dead_code = run_guarded_detection(
config.duplicates.enabled,
|p, c| {
if !c.duplicates.detect_dead_code {
return vec![];
}
crate::dry::dead_code::detect_dead_code(p, c, api_lines)
},
parsed,
config,
);
summary.dead_code_warnings = dead_code.len();
let fragments = run_guarded_detection(
config.duplicates.enabled,
|p, c| crate::dry::fragments::detect_fragments(p, &c.duplicates),
parsed,
config,
);
let boilerplate = run_guarded_detection(
config.boilerplate.enabled,
|p, c| crate::dry::boilerplate::detect_boilerplate(p, &c.boilerplate),
parsed,
config,
);
let mut wildcard_warnings = run_guarded_detection(
config.duplicates.detect_wildcard_imports,
|p, c| {
if !c.duplicates.enabled {
return vec![];
}
crate::dry::wildcards::detect_wildcard_imports(p)
},
parsed,
config,
);
mark_wildcard_suppressions(&mut wildcard_warnings, suppression_lines);
summary.wildcard_import_warnings = wildcard_warnings.iter().filter(|w| !w.suppressed).count();
DryResults {
duplicates,
dead_code,
fragments,
boilerplate,
wildcard_warnings,
}
}
pub(super) fn count_dry_findings(
dry: &DryResults,
repeated_matches: &[crate::dry::match_patterns::RepeatedMatchGroup],
summary: &mut Summary,
) {
summary.duplicate_groups = dry
.duplicates
.iter()
.filter(|g| !g.suppressed)
.map(|g| g.entries.len())
.sum();
summary.fragment_groups = dry
.fragments
.iter()
.filter(|g| !g.suppressed)
.map(|g| g.entries.len())
.sum();
summary.boilerplate_warnings = dry.boilerplate.iter().filter(|b| !b.suppressed).count();
summary.repeated_match_groups = repeated_matches
.iter()
.filter(|g| !g.suppressed)
.map(|g| g.entries.len())
.sum();
}
pub(super) fn mark_srp_suppressions(
srp: Option<&mut crate::srp::SrpAnalysis>,
suppression_lines: &std::collections::HashMap<String, Vec<Suppression>>,
) {
let Some(srp) = srp else { return };
const SRP_STRUCT_SUPPRESSION_WINDOW: usize = 5;
let srp_dim = crate::findings::Dimension::Srp;
srp.struct_warnings.iter_mut().for_each(|w| {
if let Some(sups) = suppression_lines.get(&w.file) {
w.suppressed = sups.iter().any(|sup| {
let in_window =
sup.line <= w.line && w.line - sup.line <= SRP_STRUCT_SUPPRESSION_WINDOW;
in_window && sup.covers(srp_dim)
});
}
});
srp.module_warnings.iter_mut().for_each(|w| {
if let Some(sups) = suppression_lines.get(&w.file) {
w.suppressed = sups.iter().any(|sup| sup.covers(srp_dim));
}
});
srp.param_warnings.iter_mut().for_each(|w| {
if let Some(sups) = suppression_lines.get(&w.file) {
w.suppressed = sups.iter().any(|sup| {
let in_window =
sup.line <= w.line && w.line - sup.line <= SRP_STRUCT_SUPPRESSION_WINDOW;
in_window && sup.covers(srp_dim)
});
}
});
}
pub(super) fn mark_wildcard_suppressions(
warnings: &mut [crate::dry::wildcards::WildcardImportWarning],
suppression_lines: &std::collections::HashMap<String, Vec<Suppression>>,
) {
let dry_dim = crate::findings::Dimension::Dry;
warnings.iter_mut().for_each(|w| {
if let Some(sups) = suppression_lines.get(&w.file) {
w.suppressed = sups.iter().any(|sup| {
(sup.line == w.line || (w.line > 0 && sup.line == w.line.saturating_sub(1)))
&& sup.covers(dry_dim)
});
}
});
}
pub(super) fn mark_sdp_suppressions(coupling: Option<&mut crate::coupling::CouplingAnalysis>) {
let Some(coupling) = coupling else { return };
let suppressed_modules: std::collections::HashSet<&str> = coupling
.metrics
.iter()
.filter(|m| m.suppressed)
.map(|m| m.module_name.as_str())
.collect();
coupling.sdp_violations.iter_mut().for_each(|v| {
if suppressed_modules.contains(v.from_module.as_str())
|| suppressed_modules.contains(v.to_module.as_str())
{
v.suppressed = true;
}
});
}
pub(super) fn count_sdp_violations(
coupling: Option<&crate::coupling::CouplingAnalysis>,
config: &crate::config::sections::CouplingConfig,
summary: &mut Summary,
) {
let Some(coupling) = coupling else { return };
if !config.check_sdp {
return;
}
summary.sdp_violations = coupling
.sdp_violations
.iter()
.filter(|v| !v.suppressed)
.count();
}
pub(super) fn build_file_call_graph(
results: &[FunctionAnalysis],
) -> HashMap<String, Vec<(String, Vec<String>)>> {
let mut map: HashMap<String, Vec<(String, Vec<String>)>> = HashMap::new();
for fa in results {
map.entry(fa.file.clone())
.or_default()
.push((fa.name.clone(), fa.own_calls.clone()));
}
map
}
pub(super) fn compute_srp(
parsed: &[(String, String, syn::File)],
config: &Config,
file_call_graph: &HashMap<String, Vec<(String, Vec<String>)>>,
) -> Option<crate::srp::SrpAnalysis> {
if !config.srp.enabled {
return None;
}
Some(crate::srp::analyze_srp(
parsed,
&config.srp,
file_call_graph,
))
}
pub(super) fn count_srp_warnings(srp: Option<&crate::srp::SrpAnalysis>, summary: &mut Summary) {
let Some(srp) = srp else { return };
summary.srp_struct_warnings = srp.struct_warnings.iter().filter(|w| !w.suppressed).count();
summary.srp_module_warnings = srp.module_warnings.iter().filter(|w| !w.suppressed).count();
summary.srp_param_warnings = srp.param_warnings.iter().filter(|w| !w.suppressed).count();
}
pub(super) fn apply_parameter_warnings(
results: &[crate::analyzer::FunctionAnalysis],
srp: Option<&mut crate::srp::SrpAnalysis>,
config: &crate::config::sections::SrpConfig,
) {
let Some(srp) = srp else { return };
let max = config.max_parameters;
srp.param_warnings = results
.iter()
.filter(|fa| !fa.suppressed && !fa.is_trait_impl && fa.parameter_count > max)
.map(|fa| crate::srp::ParamSrpWarning {
function_name: fa.qualified_name.clone(),
file: fa.file.clone(),
line: fa.line,
parameter_count: fa.parameter_count,
suppressed: false,
})
.collect();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyzer::{compute_severity, Classification};
use crate::config::sections::SrpConfig;
use crate::pipeline::dry_suppressions::{mark_dry_suppressions, mark_inverse_suppressions};
fn make_func(name: &str, param_count: usize, trait_impl: bool) -> FunctionAnalysis {
let severity = compute_severity(&Classification::Operation);
FunctionAnalysis {
name: name.to_string(),
file: "test.rs".to_string(),
line: 1,
classification: Classification::Operation,
parent_type: None,
suppressed: false,
complexity: None,
qualified_name: name.to_string(),
severity,
cognitive_warning: false,
cyclomatic_warning: false,
nesting_depth_warning: false,
function_length_warning: false,
unsafe_warning: false,
error_handling_warning: false,
complexity_suppressed: false,
own_calls: vec![],
parameter_count: param_count,
is_trait_impl: trait_impl,
is_test: false,
effort_score: None,
}
}
fn make_srp() -> crate::srp::SrpAnalysis {
crate::srp::SrpAnalysis {
struct_warnings: vec![],
module_warnings: vec![],
param_warnings: vec![],
}
}
#[test]
fn test_param_warning_exceeds_threshold() {
let config = SrpConfig::default();
let results = vec![make_func("many_params", 7, false)];
let mut srp = make_srp();
apply_parameter_warnings(&results, Some(&mut srp), &config);
assert_eq!(srp.param_warnings.len(), 1);
assert_eq!(srp.param_warnings[0].parameter_count, 7);
assert_eq!(srp.param_warnings[0].function_name, "many_params");
}
#[test]
fn test_param_warning_at_threshold_no_warning() {
let config = SrpConfig::default();
let results = vec![make_func("ok_params", 5, false)];
let mut srp = make_srp();
apply_parameter_warnings(&results, Some(&mut srp), &config);
assert!(srp.param_warnings.is_empty(), "5 == threshold, no warning");
}
#[test]
fn test_param_warning_trait_impl_excluded() {
let config = SrpConfig::default();
let results = vec![make_func("trait_fn", 10, true)];
let mut srp = make_srp();
apply_parameter_warnings(&results, Some(&mut srp), &config);
assert!(
srp.param_warnings.is_empty(),
"trait impl should be excluded"
);
}
#[test]
fn test_param_warning_suppressed_fn_excluded() {
let config = SrpConfig::default();
let mut func = make_func("suppressed_fn", 10, false);
func.suppressed = true;
let results = vec![func];
let mut srp = make_srp();
apply_parameter_warnings(&results, Some(&mut srp), &config);
assert!(
srp.param_warnings.is_empty(),
"suppressed fn should be excluded"
);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_param_warning_custom_threshold() {
let mut config = SrpConfig::default();
config.max_parameters = 3;
let results = vec![make_func("four_params", 4, false)];
let mut srp = make_srp();
apply_parameter_warnings(&results, Some(&mut srp), &config);
assert_eq!(srp.param_warnings.len(), 1, "4 > custom threshold 3");
}
#[test]
fn test_param_warning_srp_none() {
let config = SrpConfig::default();
let results = vec![make_func("fn", 10, false)];
apply_parameter_warnings(&results, None, &config);
}
fn make_sdp_violation(from: &str, to: &str) -> crate::coupling::sdp::SdpViolation {
crate::coupling::sdp::SdpViolation {
from_module: from.to_string(),
to_module: to.to_string(),
from_instability: 0.2,
to_instability: 0.8,
suppressed: false,
}
}
fn make_coupling_metric(name: &str, suppressed: bool) -> crate::coupling::CouplingMetrics {
crate::coupling::CouplingMetrics {
module_name: name.to_string(),
afferent: 1,
efferent: 1,
instability: 0.5,
incoming: vec![],
outgoing: vec![],
suppressed,
warning: false,
}
}
#[test]
fn test_mark_sdp_suppressions_from_module_suppressed() {
let mut analysis = crate::coupling::CouplingAnalysis {
metrics: vec![
make_coupling_metric("a", true),
make_coupling_metric("b", false),
],
cycles: vec![],
sdp_violations: vec![make_sdp_violation("a", "b")],
};
mark_sdp_suppressions(Some(&mut analysis));
assert!(
analysis.sdp_violations[0].suppressed,
"from_module suppressed → violation suppressed"
);
}
#[test]
fn test_mark_sdp_suppressions_to_module_suppressed() {
let mut analysis = crate::coupling::CouplingAnalysis {
metrics: vec![
make_coupling_metric("a", false),
make_coupling_metric("b", true),
],
cycles: vec![],
sdp_violations: vec![make_sdp_violation("a", "b")],
};
mark_sdp_suppressions(Some(&mut analysis));
assert!(
analysis.sdp_violations[0].suppressed,
"to_module suppressed → violation suppressed"
);
}
#[test]
fn test_mark_sdp_suppressions_neither_suppressed() {
let mut analysis = crate::coupling::CouplingAnalysis {
metrics: vec![
make_coupling_metric("a", false),
make_coupling_metric("b", false),
],
cycles: vec![],
sdp_violations: vec![make_sdp_violation("a", "b")],
};
mark_sdp_suppressions(Some(&mut analysis));
assert!(
!analysis.sdp_violations[0].suppressed,
"neither suppressed → violation not suppressed"
);
}
#[test]
fn test_mark_sdp_suppressions_none_coupling() {
mark_sdp_suppressions(None);
}
#[test]
fn test_count_sdp_violations_excludes_suppressed() {
let analysis = crate::coupling::CouplingAnalysis {
metrics: vec![],
cycles: vec![],
sdp_violations: vec![
crate::coupling::sdp::SdpViolation {
from_module: "a".into(),
to_module: "b".into(),
from_instability: 0.2,
to_instability: 0.8,
suppressed: true,
},
crate::coupling::sdp::SdpViolation {
from_module: "c".into(),
to_module: "d".into(),
from_instability: 0.3,
to_instability: 0.9,
suppressed: false,
},
],
};
let config = crate::config::sections::CouplingConfig::default();
let mut summary = Summary::from_results(&[]);
count_sdp_violations(Some(&analysis), &config, &mut summary);
assert_eq!(
summary.sdp_violations, 1,
"Only unsuppressed violations counted"
);
}
#[test]
fn test_mark_dry_suppressions() {
use crate::dry::functions::{DuplicateEntry, DuplicateGroup, DuplicateKind};
let mut groups = vec![DuplicateGroup {
entries: vec![
DuplicateEntry {
name: "as_str".to_string(),
qualified_name: "Foo::as_str".to_string(),
file: "test.rs".to_string(),
line: 5,
},
DuplicateEntry {
name: "parse".to_string(),
qualified_name: "Foo::parse".to_string(),
file: "test.rs".to_string(),
line: 15,
},
],
kind: DuplicateKind::NearDuplicate { similarity: 0.91 },
suppressed: false,
}];
let sup = Suppression {
line: 4,
dimensions: vec![crate::findings::Dimension::Dry],
reason: None,
};
let suppression_lines: std::collections::HashMap<String, Vec<Suppression>> =
[("test.rs".to_string(), vec![sup])].into();
mark_dry_suppressions(&mut groups, &suppression_lines);
assert!(
groups[0].suppressed,
"Group should be suppressed when any member has qual:allow(dry)"
);
}
#[test]
fn test_duplicate_without_suppression_not_marked() {
use crate::dry::functions::{DuplicateEntry, DuplicateGroup, DuplicateKind};
let mut groups = vec![DuplicateGroup {
entries: vec![
DuplicateEntry {
name: "foo".to_string(),
qualified_name: "foo".to_string(),
file: "test.rs".to_string(),
line: 5,
},
DuplicateEntry {
name: "bar".to_string(),
qualified_name: "bar".to_string(),
file: "test.rs".to_string(),
line: 15,
},
],
kind: DuplicateKind::Exact,
suppressed: false,
}];
let suppression_lines: std::collections::HashMap<String, Vec<Suppression>> =
std::collections::HashMap::new();
mark_dry_suppressions(&mut groups, &suppression_lines);
assert!(
!groups[0].suppressed,
"Group without suppression should not be marked"
);
}
#[test]
fn test_inverse_annotation_suppresses_duplicate() {
use crate::dry::functions::{DuplicateEntry, DuplicateGroup, DuplicateKind};
let mut groups = vec![DuplicateGroup {
entries: vec![
DuplicateEntry {
name: "as_str".to_string(),
qualified_name: "Foo::as_str".to_string(),
file: "test.rs".to_string(),
line: 5,
},
DuplicateEntry {
name: "parse".to_string(),
qualified_name: "Foo::parse".to_string(),
file: "test.rs".to_string(),
line: 15,
},
],
kind: DuplicateKind::NearDuplicate { similarity: 0.91 },
suppressed: false,
}];
let inverse_lines: std::collections::HashMap<String, Vec<(usize, String)>> =
[("test.rs".to_string(), vec![(4, "parse".to_string())])].into();
mark_inverse_suppressions(&mut groups, &inverse_lines);
assert!(
groups[0].suppressed,
"Inverse-annotated pair should be suppressed"
);
}
#[test]
fn test_inverse_annotation_must_target_group_member() {
use crate::dry::functions::{DuplicateEntry, DuplicateGroup, DuplicateKind};
let mut groups = vec![DuplicateGroup {
entries: vec![
DuplicateEntry {
name: "foo".to_string(),
qualified_name: "foo".to_string(),
file: "test.rs".to_string(),
line: 5,
},
DuplicateEntry {
name: "bar".to_string(),
qualified_name: "bar".to_string(),
file: "test.rs".to_string(),
line: 15,
},
],
kind: DuplicateKind::Exact,
suppressed: false,
}];
let inverse_lines: std::collections::HashMap<String, Vec<(usize, String)>> =
[("test.rs".to_string(), vec![(4, "baz".to_string())])].into();
mark_inverse_suppressions(&mut groups, &inverse_lines);
assert!(
!groups[0].suppressed,
"Inverse targeting non-member should not suppress"
);
}
#[test]
fn test_repeated_match_suppression() {
use crate::dry::match_patterns::{RepeatedMatchEntry, RepeatedMatchGroup};
let mut groups = vec![RepeatedMatchGroup {
enum_name: "MyEnum".to_string(),
entries: vec![RepeatedMatchEntry {
file: "test.rs".to_string(),
line: 10,
function_name: "handle_a".to_string(),
arm_count: 5,
}],
suppressed: false,
}];
let sup = Suppression {
line: 9,
dimensions: vec![crate::findings::Dimension::Dry],
reason: None,
};
let suppression_lines: std::collections::HashMap<String, Vec<Suppression>> =
[("test.rs".to_string(), vec![sup])].into();
mark_dry_suppressions(&mut groups, &suppression_lines);
assert!(
groups[0].suppressed,
"RepeatedMatchGroup should be suppressed by qual:allow(dry)"
);
}
#[test]
fn test_fragment_suppression() {
use crate::dry::fragments::{FragmentEntry, FragmentGroup};
let mut groups = vec![FragmentGroup {
entries: vec![FragmentEntry {
function_name: "foo".to_string(),
qualified_name: "foo".to_string(),
file: "test.rs".to_string(),
start_line: 5,
end_line: 10,
}],
statement_count: 3,
suppressed: false,
}];
let sup = Suppression {
line: 4,
dimensions: vec![crate::findings::Dimension::Dry],
reason: None,
};
let suppression_lines: std::collections::HashMap<String, Vec<Suppression>> =
[("test.rs".to_string(), vec![sup])].into();
mark_dry_suppressions(&mut groups, &suppression_lines);
assert!(
groups[0].suppressed,
"FragmentGroup should be suppressed by qual:allow(dry)"
);
}
#[test]
fn test_boilerplate_suppression() {
use crate::dry::boilerplate::BoilerplateFind;
let mut findings = vec![BoilerplateFind {
pattern_id: "BP-003".to_string(),
file: "test.rs".to_string(),
line: 10,
struct_name: Some("MyStruct".to_string()),
description: "3 trivial getters".to_string(),
suggestion: "Consider derive macro".to_string(),
suppressed: false,
}];
let sup = Suppression {
line: 9,
dimensions: vec![crate::findings::Dimension::Dry],
reason: None,
};
let suppression_lines: std::collections::HashMap<String, Vec<Suppression>> =
[("test.rs".to_string(), vec![sup])].into();
mark_dry_suppressions(&mut findings, &suppression_lines);
assert!(
findings[0].suppressed,
"BoilerplateFind should be suppressed by qual:allow(dry)"
);
}
}