use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use syn::spanned::Spanned;
use syn::visit::Visit;
use crate::advisory::clippy_import::ClippyBoolParamsHit;
use crate::finding::{Finding, FindingId};
use crate::ingest::{CrateInfo, Workspace};
pub const STRINGLY_ERROR_BOUNDARY_RULE: &str = "stringly-error-boundary";
pub const PRIMITIVE_DOMAIN_VALUE_RULE: &str = "primitive-domain-value";
pub const BOOLEAN_STATE_CLUSTER_RULE: &str = "boolean-state-cluster";
pub const PUBLIC_INVARIANT_BYPASS_RULE: &str = "public-invariant-bypass";
pub const MANUAL_RESOURCE_LIFECYCLE_RULE: &str = "manual-resource-lifecycle";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RustPattern {
ValidatedNewtype,
SmartConstructor,
StateEnum,
TypeState,
Builder,
OptionsStruct,
RaiiGuard,
DomainError,
FunctionalCore,
EncapsulatedAggregate,
}
impl RustPattern {
pub const fn slug(self) -> &'static str {
match self {
Self::ValidatedNewtype => "validated-newtype",
Self::SmartConstructor => "smart-constructor",
Self::StateEnum => "state-enum",
Self::TypeState => "type-state",
Self::Builder => "builder",
Self::OptionsStruct => "options-struct",
Self::RaiiGuard => "raii-guard",
Self::DomainError => "domain-error",
Self::FunctionalCore => "functional-core",
Self::EncapsulatedAggregate => "encapsulated-aggregate",
}
}
}
impl std::fmt::Display for RustPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.slug())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CodeScope {
pub krate: String,
pub modules: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EvidenceLocation {
pub file: PathBuf,
pub item_path: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Evidence {
pub description: String,
pub locations: Vec<EvidenceLocation>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CorroboratedEvidence {
pub primary: Evidence,
pub independent: Evidence,
pub additional: Vec<Evidence>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Contraindication {
pub description: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Precondition {
pub description: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct MigrationStep {
pub step: u32,
pub description: String,
pub affected_paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PatternCandidateId(String);
impl PatternCandidateId {
pub fn as_str(&self) -> &str {
&self.0
}
fn compute(pattern: RustPattern, scope: &CodeScope, evidence_identities: &[String]) -> Self {
let mut modules = scope.modules.clone();
modules.sort();
let mut identities = evidence_identities.to_vec();
identities.sort();
identities.dedup();
let normalized = format!(
"{}|{}|{}|{}",
pattern.slug(),
scope.krate,
modules.join(","),
identities.join(",")
);
Self(format!(
"pattern:{}:{}",
pattern.slug(),
crate::finding::fnv1a_hex(&normalized)
))
}
}
impl std::fmt::Display for PatternCandidateId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PatternCandidate {
pub id: PatternCandidateId,
pub pattern: RustPattern,
pub scope: CodeScope,
pub evidence: CorroboratedEvidence,
pub preconditions: Vec<Precondition>,
pub contraindications: Vec<Contraindication>,
pub migration: Vec<MigrationStep>,
pub related_findings: Vec<FindingId>,
}
macro_rules! pattern_candidate {
(
pattern: $pattern:expr,
$scope:expr,
$evidence_identities:expr,
{
evidence: $evidence:expr,
preconditions: $preconditions:expr,
contraindications: $contraindications:expr,
migration: $migration:expr,
related_findings: $related_findings:expr $(,)?
}
) => {{
let pattern = $pattern;
let scope = $scope;
let evidence_identities = $evidence_identities;
PatternCandidate {
id: PatternCandidateId::compute(pattern, &scope, &evidence_identities),
pattern,
scope,
evidence: $evidence,
preconditions: $preconditions,
contraindications: $contraindications,
migration: $migration,
related_findings: $related_findings,
}
}};
}
pub fn analyze_workspace(workspace: &Workspace, findings: &[Finding]) -> Vec<PatternCandidate> {
analyze_workspace_with_clippy(workspace, findings, &[])
}
pub fn analyze_workspace_with_clippy(
workspace: &Workspace,
findings: &[Finding],
clippy_hits: &[ClippyBoolParamsHit],
) -> Vec<PatternCandidate> {
let mut candidates = stringly_error_boundary_candidates(workspace, findings);
candidates.extend(primitive_domain_value_candidates(workspace));
candidates.extend(boolean_state_cluster_candidates(workspace, clippy_hits));
candidates.extend(public_invariant_bypass_candidates(workspace));
candidates.extend(manual_resource_lifecycle_candidates(workspace));
candidates
}
fn stringly_error_boundary_candidates(
workspace: &Workspace,
findings: &[Finding],
) -> Vec<PatternCandidate> {
let mut by_crate: BTreeMap<&str, Vec<&Finding>> = BTreeMap::new();
for finding in findings {
if finding.rule.as_str() != crate::rules::slop::CATCH_ALL_ERROR_RULE {
continue;
}
let Some(krate) = crate_for_file(workspace, &finding.location.file) else {
continue;
};
by_crate
.entry(krate.name.as_str())
.or_default()
.push(finding);
}
let mut candidates = Vec::new();
for (krate_name, crate_findings) in by_crate {
if crate_findings.len() < 2 {
continue;
}
let Some(krate) = workspace.crates.iter().find(|k| k.name == krate_name) else {
continue;
};
let Some(independent) = crate_defines_typed_error(krate) else {
continue;
};
candidates.push(build_candidate(krate, &crate_findings, independent));
}
candidates
}
fn crate_for_file<'a>(workspace: &'a Workspace, file: &Path) -> Option<&'a CrateInfo> {
workspace
.crates
.iter()
.find(|krate| krate.source_files.iter().any(|source| source.path == file))
}
fn for_each_parsed_source(krate: &CrateInfo, mut visit: impl FnMut(&Path, &syn::File)) {
for source in &krate.source_files {
let Ok(text) = std::fs::read_to_string(&source.path) else {
continue;
};
let Ok(ast) = syn::parse_file(&text) else {
continue;
};
visit(&source.path, &ast);
}
}
impl EvidenceLocation {
fn new(file: PathBuf, item_path: impl Into<String>) -> Self {
Self {
file,
item_path: Some(item_path.into()),
}
}
}
fn sort_evidence_locations(locations: &mut [EvidenceLocation]) {
locations.sort_by(|a, b| (&a.file, &a.item_path).cmp(&(&b.file, &b.item_path)));
}
fn crate_scope(krate: &CrateInfo, mut modules: Vec<String>) -> CodeScope {
modules.sort();
modules.dedup();
CodeScope {
krate: krate.name.clone(),
modules,
}
}
impl CorroboratedEvidence {
fn new(primary: Evidence, independent: Evidence) -> Self {
Self {
primary,
independent,
additional: Vec::new(),
}
}
}
fn location_identities(locations: &[EvidenceLocation]) -> Vec<String> {
locations
.iter()
.map(|location| {
format!(
"{}:{}",
location.file.display(),
location.item_path.as_deref().unwrap_or("")
)
})
.collect()
}
fn qualified_item_path(self_type: Option<&str>, name: &str) -> String {
match self_type {
Some(self_type) => format!("{self_type}::{name}"),
None => name.to_string(),
}
}
fn typed_ident_arg(input: &syn::FnArg) -> Option<(String, &syn::Type)> {
let syn::FnArg::Typed(pat_type) = input else {
return None;
};
let syn::Pat::Ident(pat_ident) = pat_type.pat.as_ref() else {
return None;
};
Some((pat_ident.ident.to_string(), &pat_type.ty))
}
fn impl_trait_is(node: &syn::ItemImpl, ident: &str) -> bool {
node.trait_.as_ref().is_some_and(|(_, path, _)| {
path.segments
.last()
.is_some_and(|segment| segment.ident == ident)
})
}
fn build_candidate(
krate: &CrateInfo,
crate_findings: &[&Finding],
independent: Evidence,
) -> PatternCandidate {
let mut related_findings: Vec<FindingId> = crate_findings
.iter()
.map(|finding| finding.id.clone())
.collect();
related_findings.sort_by(|a, b| a.as_str().cmp(b.as_str()));
let modules: Vec<String> = crate_findings
.iter()
.map(|finding| finding.location.item_path.clone())
.collect();
let scope = crate_scope(krate, modules);
let mut primary_locations: Vec<EvidenceLocation> = crate_findings
.iter()
.map(|finding| {
EvidenceLocation::new(
finding.location.file.clone(),
finding.location.item_path.clone(),
)
})
.collect();
sort_evidence_locations(&mut primary_locations);
let mut affected_paths: Vec<PathBuf> = crate_findings
.iter()
.map(|finding| finding.location.file.clone())
.collect();
affected_paths.sort();
affected_paths.dedup();
let primary = Evidence {
description: format!(
"{} `catch-all-error` finding(s) in crate `{}` convert concrete errors to \
`String`/`Box<dyn Error>`/context-free collectors at public boundaries.",
crate_findings.len(),
krate.name
),
locations: primary_locations,
};
let evidence_identities: Vec<String> = related_findings
.iter()
.map(|id| id.as_str().to_string())
.collect();
pattern_candidate! {
pattern: RustPattern::DomainError,
scope,
evidence_identities,
{
evidence: CorroboratedEvidence::new(primary, independent),
preconditions: vec![Precondition {
description: format!(
"Mehrere Boundary-Funktionen in Crate `{}` wandeln unterschiedliche \
Fehlerquellen an derselben Grenze in `anyhow`/`Box<dyn Error>`/`String` um.",
krate.name
),
}],
contraindications: vec![
Contraindication {
description: "Die Grenze kann bewusst ein Kompatibilitäts-Shim sein, der \
verschiedene Fehlerquellen absichtlich vereinheitlicht."
.to_string(),
},
Contraindication {
description: "Ein zusätzliches Domain-Error-Enum kann bei sehr wenigen \
Aufrufstellen mehr Boilerplate als Nutzen erzeugen."
.to_string(),
},
],
migration: vec![
MigrationStep {
step: 1,
description: "Gemeinsame Fehlerquellen an dieser Grenze identifizieren."
.to_string(),
affected_paths: affected_paths.clone(),
},
MigrationStep {
step: 2,
description: "Domain-Error-Enum mit einer Variante pro Quelle entwerfen."
.to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 3,
description: "`From`-Impls für die Quellfehler ergänzen.".to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 4,
description: "Boundary-Funktionen auf das neue Enum umstellen und `?` statt \
manueller Konvertierung nutzen."
.to_string(),
affected_paths,
},
],
related_findings: related_findings,
}
}
}
fn crate_defines_typed_error(krate: &CrateInfo) -> Option<Evidence> {
let mut hits = Vec::new();
for_each_parsed_source(krate, |file, ast| {
let mut visitor = TypedErrorVisitor {
file,
path: Vec::new(),
hits: Vec::new(),
};
visitor.visit_file(ast);
hits.append(&mut visitor.hits);
});
if hits.is_empty() {
return None;
}
hits.sort_by(|a, b| (&a.file, &a.item_path).cmp(&(&b.file, &b.item_path)));
Some(Evidence {
description: format!(
"Crate `{}` already defines {} typed error item(s) in its own source (an enum with \
`Error` in its name, an `Error`-deriving item, or an `impl ... Error for ...`) — \
the raw material for a domain error already exists in this crate.",
krate.name,
hits.len()
),
locations: hits,
})
}
struct TypedErrorVisitor<'a> {
file: &'a Path,
path: Vec<String>,
hits: Vec<EvidenceLocation>,
}
impl TypedErrorVisitor<'_> {
fn current_item_path(&self) -> String {
crate::functions::qualified_item_path(self.file, &self.path)
}
fn record(&mut self) {
self.hits.push(EvidenceLocation::new(
self.file.to_path_buf(),
self.current_item_path(),
));
}
}
impl<'ast> Visit<'ast> for TypedErrorVisitor<'_> {
fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
self.path.push(node.ident.to_string());
if node.ident.to_string().contains("Error") || has_derive_ending_in(&node.attrs, "Error") {
self.record();
}
syn::visit::visit_item_enum(self, node);
self.path.pop();
}
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
self.path.push(node.ident.to_string());
if has_derive_ending_in(&node.attrs, "Error") {
self.record();
}
syn::visit::visit_item_struct(self, node);
self.path.pop();
}
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
use quote::ToTokens;
self.path.push(node.self_ty.to_token_stream().to_string());
if impl_trait_is(node, "Error") {
self.record();
}
syn::visit::visit_item_impl(self, node);
self.path.pop();
}
}
fn has_derive_ending_in(attrs: &[syn::Attribute], ident: &str) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("derive") {
return false;
}
let syn::Meta::List(list) = &attr.meta else {
return false;
};
list.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
)
.is_ok_and(|paths| {
paths.iter().any(|path| {
path.segments
.last()
.is_some_and(|segment| segment.ident == ident)
})
})
})
}
fn primitive_domain_value_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
let mut candidates = Vec::new();
for krate in &workspace.crates {
let mut facts: Vec<SignatureParamFact> = Vec::new();
for_each_parsed_source(krate, |file, ast| {
let mut visitor = PrimitiveDomainValueVisitor {
file,
self_type: None,
facts: Vec::new(),
};
visitor.visit_file(ast);
facts.append(&mut visitor.facts);
});
let mut by_param: BTreeMap<(String, String), Vec<SignatureParamFact>> = BTreeMap::new();
for fact in facts {
by_param
.entry((fact.param.clone(), fact.type_name.clone()))
.or_default()
.push(fact);
}
for ((param, type_name), group) in by_param {
if group.len() < 2 {
continue;
}
if !group.iter().any(|fact| fact.has_guard) {
continue;
}
candidates.push(build_primitive_domain_value_candidate(
krate, ¶m, &type_name, &group,
));
}
}
candidates
}
struct SignatureParamFact {
file: PathBuf,
item_path: String,
param: String,
type_name: String,
has_guard: bool,
}
fn build_primitive_domain_value_candidate(
krate: &CrateInfo,
param: &str,
type_name: &str,
group: &[SignatureParamFact],
) -> PatternCandidate {
let modules: Vec<String> = group.iter().map(|fact| fact.item_path.clone()).collect();
let scope = crate_scope(krate, modules);
let mut primary_locations: Vec<EvidenceLocation> = group
.iter()
.map(|fact| EvidenceLocation::new(fact.file.clone(), fact.item_path.clone()))
.collect();
sort_evidence_locations(&mut primary_locations);
let mut guard_locations: Vec<EvidenceLocation> = group
.iter()
.filter(|fact| fact.has_guard)
.map(|fact| EvidenceLocation::new(fact.file.clone(), fact.item_path.clone()))
.collect();
sort_evidence_locations(&mut guard_locations);
let primary = Evidence {
description: format!(
"Parameter `{param}: {type_name}` appears with the same name and type in {} `pub \
fn` signature(s) in crate `{}`.",
group.len(),
krate.name
),
locations: primary_locations,
};
let independent = Evidence {
description: format!(
"At least one of these signatures guards `{param}` with an early error/panic path \
referencing the parameter (`if` + `return Err(...)`, `if` + `panic!(...)`, or \
`assert!(...)`)."
),
locations: guard_locations,
};
let evidence_identities: Vec<String> = location_identities(&primary.locations);
let mut affected_paths: Vec<PathBuf> = group.iter().map(|fact| fact.file.clone()).collect();
affected_paths.sort();
affected_paths.dedup();
pattern_candidate! {
pattern: RustPattern::ValidatedNewtype,
scope,
evidence_identities,
{
evidence: CorroboratedEvidence::new(primary, independent),
preconditions: vec![Precondition {
description: format!(
"Crate `{}` verwendet `{param}: {type_name}` wiederholt als Parametername/-typ, \
und mindestens eine Fundstelle validiert den Wertebereich explizit.",
krate.name
),
}],
contraindications: vec![
Contraindication {
description: "Der Parametername kann in verschiedenen Funktionen tatsächlich \
unterschiedliche Bedeutungen haben, auch wenn Name und Typ übereinstimmen."
.to_string(),
},
Contraindication {
description: "Bei nur einer Validierungsstelle könnte ein Newtype mehr \
Boilerplate als Nutzen erzeugen, falls die übrigen Aufrufstellen den Wert nie \
direkt validieren müssen."
.to_string(),
},
],
migration: vec![
MigrationStep {
step: 1,
description: "Newtype für den Wertebereich definieren.".to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 2,
description: "`TryFrom<...>` mit der gefundenen Validierungslogik implementieren."
.to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 3,
description: "Betroffene Signaturen schrittweise auf den Newtype umstellen."
.to_string(),
affected_paths: affected_paths.clone(),
},
MigrationStep {
step: 4,
description: "Call-Sites anpassen.".to_string(),
affected_paths,
},
],
related_findings: Vec::new(),
}
}
}
fn primitive_type_name(ty: &syn::Type) -> Option<String> {
const NUMERIC: &[&str] = &[
"u8", "u16", "u32", "u64", "usize", "i8", "i16", "i32", "i64", "isize", "f32", "f64",
];
match ty {
syn::Type::Path(type_path) if type_path.qself.is_none() => {
let segment = type_path.path.segments.last()?;
if !matches!(segment.arguments, syn::PathArguments::None) {
return None;
}
let name = segment.ident.to_string();
if NUMERIC.contains(&name.as_str()) || name == "String" {
Some(name)
} else {
None
}
}
syn::Type::Reference(type_ref) => match &*type_ref.elem {
syn::Type::Path(type_path) if type_path.qself.is_none() => {
let segment = type_path.path.segments.last()?;
if matches!(segment.arguments, syn::PathArguments::None) && segment.ident == "str" {
Some("&str".to_string())
} else {
None
}
}
_ => None,
},
_ => None,
}
}
struct PrimitiveDomainValueVisitor<'a> {
file: &'a Path,
self_type: Option<String>,
facts: Vec<SignatureParamFact>,
}
macro_rules! visit_item_impl_with_self_type {
() => {
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
use quote::ToTokens;
let previous = self
.self_type
.replace(node.self_ty.to_token_stream().to_string());
syn::visit::visit_item_impl(self, node);
self.self_type = previous;
}
};
}
macro_rules! visit_pub_fns_via_record_fn {
() => {
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
if matches!(node.vis, syn::Visibility::Public(_)) {
self.record_fn(&node.sig.ident.to_string(), &node.sig, &node.block);
}
syn::visit::visit_item_fn(self, node);
}
fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
if matches!(node.vis, syn::Visibility::Public(_)) {
self.record_fn(&node.sig.ident.to_string(), &node.sig, &node.block);
}
syn::visit::visit_impl_item_fn(self, node);
}
};
}
impl PrimitiveDomainValueVisitor<'_> {
fn record_fn(&mut self, name: &str, sig: &syn::Signature, block: &syn::Block) {
let item_path = qualified_item_path(self.self_type.as_deref(), name);
for input in &sig.inputs {
let Some((param, ty)) = typed_ident_arg(input) else {
continue;
};
let Some(type_name) = primitive_type_name(ty) else {
continue;
};
let has_guard = body_has_validation_guard_for(block, ¶m);
self.facts.push(SignatureParamFact {
file: self.file.to_path_buf(),
item_path: item_path.clone(),
param,
type_name,
has_guard,
});
}
}
}
impl<'ast> Visit<'ast> for PrimitiveDomainValueVisitor<'_> {
visit_pub_fns_via_record_fn!();
visit_item_impl_with_self_type!();
}
fn expr_references_ident(expr: &syn::Expr, ident: &str) -> bool {
struct Finder<'a> {
ident: &'a str,
found: bool,
}
impl<'ast> Visit<'ast> for Finder<'_> {
fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
if node.path.is_ident(self.ident) {
self.found = true;
}
syn::visit::visit_expr_path(self, node);
}
}
let mut finder = Finder {
ident,
found: false,
};
finder.visit_expr(expr);
finder.found
}
fn tokens_reference_ident(tokens: &proc_macro2::TokenStream, ident: &str) -> bool {
tokens.clone().into_iter().any(|tree| match tree {
proc_macro2::TokenTree::Ident(node) => node == ident,
proc_macro2::TokenTree::Group(group) => tokens_reference_ident(&group.stream(), ident),
_ => false,
})
}
fn block_leads_to_error_path(block: &syn::Block) -> bool {
struct Finder {
found: bool,
}
impl<'ast> Visit<'ast> for Finder {
fn visit_expr_return(&mut self, node: &'ast syn::ExprReturn) {
if node.expr.as_deref().is_some_and(is_err_call) {
self.found = true;
}
syn::visit::visit_expr_return(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if node.path.is_ident("panic") {
self.found = true;
}
syn::visit::visit_macro(self, node);
}
}
let mut finder = Finder { found: false };
finder.visit_block(block);
finder.found
}
fn is_err_call(expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Call(call) => matches!(
call.func.as_ref(),
syn::Expr::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "Err")
),
_ => false,
}
}
fn body_has_validation_guard_for(block: &syn::Block, param: &str) -> bool {
struct GuardVisitor<'a> {
param: &'a str,
found: bool,
}
impl<'ast> Visit<'ast> for GuardVisitor<'_> {
fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
if expr_references_ident(&node.cond, self.param)
&& block_leads_to_error_path(&node.then_branch)
{
self.found = true;
}
syn::visit::visit_expr_if(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if node.path.is_ident("assert") && tokens_reference_ident(&node.tokens, self.param) {
self.found = true;
}
syn::visit::visit_macro(self, node);
}
}
let mut visitor = GuardVisitor {
param,
found: false,
};
visitor.visit_block(block);
visitor.found
}
fn boolean_state_cluster_candidates(
workspace: &Workspace,
clippy_hits: &[ClippyBoolParamsHit],
) -> Vec<PatternCandidate> {
let mut candidates = Vec::new();
for krate in &workspace.crates {
for_each_parsed_source(krate, |file, ast| {
let mut visitor = BooleanStateClusterVisitor {
file,
self_type: None,
facts: Vec::new(),
};
visitor.visit_file(ast);
for fact in visitor.facts {
let mut candidate = build_boolean_state_cluster_candidate(krate, &fact);
if let Some(hit) = clippy_hits
.iter()
.find(|hit| clippy_hit_matches_fact(&workspace.root, hit, &fact))
{
candidate.evidence.additional.push(Evidence {
description: format!(
"`clippy::fn_params_excessive_bools` independently flagged \
`{}`'s parameter list (lines {}-{}), corroborating this from a \
separate tool.",
fact.item_path, hit.line_start, hit.line_end
),
locations: vec![EvidenceLocation::new(
fact.file.clone(),
fact.item_path.clone(),
)],
});
}
candidates.push(candidate);
}
});
}
candidates
}
fn clippy_hit_matches_fact(
workspace_root: &Path,
hit: &ClippyBoolParamsHit,
fact: &BoolClusterFact,
) -> bool {
let fact_relative = fact.file.strip_prefix(workspace_root).unwrap_or(&fact.file);
let hit_normalized: PathBuf = hit
.file
.components()
.filter(|component| !matches!(component, std::path::Component::CurDir))
.collect();
fact_relative == hit_normalized
&& fact.line_start <= hit.line_end
&& hit.line_start <= fact.line_end
}
struct BoolClusterFact {
file: PathBuf,
item_path: String,
bool_params: BTreeSet<String>,
combo_hits: Vec<String>,
line_start: usize,
line_end: usize,
}
fn build_boolean_state_cluster_candidate(
krate: &CrateInfo,
fact: &BoolClusterFact,
) -> PatternCandidate {
let scope = crate_scope(krate, vec![fact.item_path.clone()]);
let location = EvidenceLocation::new(fact.file.clone(), fact.item_path.clone());
let bool_params: Vec<&String> = fact.bool_params.iter().collect();
let primary = Evidence {
description: format!(
"`{}` has {} `bool`-typed parameters: {}.",
fact.item_path,
fact.bool_params.len(),
bool_params
.iter()
.map(|name| name.as_str())
.collect::<Vec<_>>()
.join(", ")
),
locations: vec![location.clone()],
};
let independent = Evidence {
description: format!(
"The function body combines at least two of these bool parameters together in a \
condition, e.g. `{}`.",
fact.combo_hits.join("`, `")
),
locations: vec![location],
};
let evidence_identities: Vec<String> = std::iter::once(fact.item_path.clone())
.chain(fact.bool_params.iter().cloned())
.chain(fact.combo_hits.iter().cloned())
.collect();
pattern_candidate! {
pattern: RustPattern::OptionsStruct,
scope,
evidence_identities,
{
evidence: CorroboratedEvidence::new(primary, independent),
preconditions: vec![Precondition {
description: format!(
"`{}` nimmt mehrere Bool-Parameter entgegen und prüft mindestens eine \
Kombination davon gemeinsam im Funktionskörper.",
fact.item_path
),
}],
contraindications: vec![
Contraindication {
description: "Wenige, klar benannte, unabhängig verwendete Bool-Flags können \
lesbarer sein als ein zusätzlicher Enum-/Options-Typ."
.to_string(),
},
Contraindication {
description: "Wenn die Kombinationsprüfung nur eine einmalige \
Eingabevalidierung ist (kein wiederholtes Muster), kann ein zusätzlicher Typ \
Overkill sein."
.to_string(),
},
],
migration: vec![
MigrationStep {
step: 1,
description: "Gültige Optionen/Zustände benennen (Options-Struct vs. \
Zustands-Enum, je nach Anzahl gültiger Kombinationen)."
.to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 2,
description: "Den gewählten Typ definieren.".to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 3,
description: "Konstruktor-/Funktionsparameterliste ersetzen.".to_string(),
affected_paths: vec![fact.file.clone()],
},
MigrationStep {
step: 4,
description: "Call-Sites aktualisieren.".to_string(),
affected_paths: vec![fact.file.clone()],
},
],
related_findings: Vec::new(),
}
}
}
fn is_bool_type(ty: &syn::Type) -> bool {
matches!(
ty,
syn::Type::Path(type_path)
if type_path.qself.is_none()
&& type_path.path.segments.last().is_some_and(|segment| {
segment.ident == "bool" && matches!(segment.arguments, syn::PathArguments::None)
})
)
}
struct BooleanStateClusterVisitor<'a> {
file: &'a Path,
self_type: Option<String>,
facts: Vec<BoolClusterFact>,
}
impl BooleanStateClusterVisitor<'_> {
fn record_fn(
&mut self,
name: &str,
sig: &syn::Signature,
block: &syn::Block,
span: proc_macro2::Span,
) {
let item_path = qualified_item_path(self.self_type.as_deref(), name);
let bool_params: BTreeSet<String> = sig
.inputs
.iter()
.filter_map(|input| {
let (name, ty) = typed_ident_arg(input)?;
is_bool_type(ty).then_some(name)
})
.collect();
if bool_params.len() < 3 {
return;
}
let combo_hits = body_boolean_combo_hits(block, &bool_params);
if combo_hits.is_empty() {
return;
}
self.facts.push(BoolClusterFact {
file: self.file.to_path_buf(),
item_path,
bool_params,
combo_hits,
line_start: span.start().line,
line_end: span.end().line,
});
}
}
impl<'ast> Visit<'ast> for BooleanStateClusterVisitor<'_> {
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
self.record_fn(
&node.sig.ident.to_string(),
&node.sig,
&node.block,
node.span(),
);
syn::visit::visit_item_fn(self, node);
}
visit_item_impl_with_self_type!();
fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
self.record_fn(
&node.sig.ident.to_string(),
&node.sig,
&node.block,
node.span(),
);
syn::visit::visit_impl_item_fn(self, node);
}
}
fn referenced_params_in_expr(expr: &syn::Expr, params: &BTreeSet<String>) -> BTreeSet<String> {
struct Collector<'a> {
params: &'a BTreeSet<String>,
found: BTreeSet<String>,
}
impl<'ast> Visit<'ast> for Collector<'_> {
fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
if let Some(ident) = node.path.get_ident() {
let name = ident.to_string();
if self.params.contains(&name) {
self.found.insert(name);
}
}
syn::visit::visit_expr_path(self, node);
}
}
let mut collector = Collector {
params,
found: BTreeSet::new(),
};
collector.visit_expr(expr);
collector.found
}
fn body_boolean_combo_hits(block: &syn::Block, bool_params: &BTreeSet<String>) -> Vec<String> {
use quote::ToTokens;
struct ComboVisitor<'a> {
bool_params: &'a BTreeSet<String>,
hits: Vec<String>,
}
impl<'ast> Visit<'ast> for ComboVisitor<'_> {
fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
if referenced_params_in_expr(&node.cond, self.bool_params).len() >= 2 {
self.hits.push(node.cond.to_token_stream().to_string());
}
syn::visit::visit_expr_if(self, node);
}
fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
if referenced_params_in_expr(&node.expr, self.bool_params).len() >= 2 {
self.hits.push(node.expr.to_token_stream().to_string());
}
syn::visit::visit_expr_match(self, node);
}
}
let mut visitor = ComboVisitor {
bool_params,
hits: Vec::new(),
};
visitor.visit_block(block);
visitor.hits
}
fn public_invariant_bypass_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
let mut candidates = Vec::new();
for krate in &workspace.crates {
let mut structs: BTreeMap<String, PubStructFact> = BTreeMap::new();
for_each_parsed_source(krate, |file, ast| {
let mut visitor = PubStructVisitor {
file,
structs: BTreeMap::new(),
};
visitor.visit_file(ast);
structs.extend(visitor.structs);
});
if structs.is_empty() {
continue;
}
let mut constructor_hits: BTreeMap<String, Vec<ConstructorFact>> = BTreeMap::new();
for_each_parsed_source(krate, |file, ast| {
let mut visitor = ConstructorVisitor {
file,
self_type: None,
structs: &structs,
hits: BTreeMap::new(),
};
visitor.visit_file(ast);
for (name, mut facts) in visitor.hits {
constructor_hits.entry(name).or_default().append(&mut facts);
}
});
for (name, fact) in &structs {
let Some(ctor_facts) = constructor_hits.get(name) else {
continue;
};
if ctor_facts.is_empty() {
continue;
}
candidates.push(build_public_invariant_bypass_candidate(
krate, fact, ctor_facts,
));
}
}
candidates
}
struct PubStructFact {
file: PathBuf,
name: String,
fields: BTreeSet<String>,
}
fn has_non_exhaustive_attr(attrs: &[syn::Attribute]) -> bool {
attrs
.iter()
.any(|attr| attr.path().is_ident("non_exhaustive"))
}
struct PubStructVisitor<'a> {
file: &'a Path,
structs: BTreeMap<String, PubStructFact>,
}
impl<'ast> Visit<'ast> for PubStructVisitor<'_> {
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
if matches!(node.vis, syn::Visibility::Public(_)) && !has_non_exhaustive_attr(&node.attrs) {
let fields: BTreeSet<String> = node
.fields
.iter()
.filter(|field| matches!(field.vis, syn::Visibility::Public(_)))
.filter_map(|field| field.ident.as_ref().map(ToString::to_string))
.collect();
if fields.len() >= 2 {
let name = node.ident.to_string();
self.structs.insert(
name.clone(),
PubStructFact {
file: self.file.to_path_buf(),
name,
fields,
},
);
}
}
syn::visit::visit_item_struct(self, node);
}
}
struct ConstructorFact {
file: PathBuf,
item_path: String,
hits: Vec<String>,
}
struct ConstructorVisitor<'a> {
file: &'a Path,
self_type: Option<String>,
structs: &'a BTreeMap<String, PubStructFact>,
hits: BTreeMap<String, Vec<ConstructorFact>>,
}
impl ConstructorVisitor<'_> {
fn record_fn(&mut self, name: &str, sig: &syn::Signature, block: &syn::Block) {
let syn::ReturnType::Type(_, ty) = &sig.output else {
return;
};
let Some(struct_name) = resolved_struct_name(ty, self.self_type.as_deref()) else {
return;
};
let Some(fact) = self.structs.get(&struct_name) else {
return;
};
let param_names: BTreeSet<String> = sig
.inputs
.iter()
.filter_map(|input| typed_ident_arg(input).map(|(name, _)| name))
.collect();
let matching_params: BTreeSet<String> =
param_names.intersection(&fact.fields).cloned().collect();
if matching_params.len() < 2 {
return;
}
let hits = constructor_combo_hits(block, &matching_params);
if hits.is_empty() {
return;
}
let item_path = qualified_item_path(self.self_type.as_deref(), name);
self.hits
.entry(struct_name)
.or_default()
.push(ConstructorFact {
file: self.file.to_path_buf(),
item_path,
hits,
});
}
}
impl<'ast> Visit<'ast> for ConstructorVisitor<'_> {
visit_pub_fns_via_record_fn!();
visit_item_impl_with_self_type!();
}
fn resolved_struct_name(ty: &syn::Type, self_type: Option<&str>) -> Option<String> {
let syn::Type::Path(type_path) = ty else {
return None;
};
let segment = type_path.path.segments.last()?;
let name = segment.ident.to_string();
if name == "Self" {
return self_type.map(str::to_string);
}
if name == "Result"
&& let syn::PathArguments::AngleBracketed(generics) = &segment.arguments
&& let Some(syn::GenericArgument::Type(inner)) = generics.args.first()
{
return resolved_struct_name(inner, self_type);
}
Some(name)
}
fn constructor_combo_hits(block: &syn::Block, matching_params: &BTreeSet<String>) -> Vec<String> {
use quote::ToTokens;
struct ComboVisitor<'a> {
matching_params: &'a BTreeSet<String>,
hits: Vec<String>,
}
impl<'ast> Visit<'ast> for ComboVisitor<'_> {
fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
if referenced_params_in_expr(&node.cond, self.matching_params).len() >= 2
&& block_leads_to_error_path(&node.then_branch)
{
self.hits.push(node.cond.to_token_stream().to_string());
}
syn::visit::visit_expr_if(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
if node.path.is_ident("assert")
&& tokens_reference_at_least_two_idents(&node.tokens, self.matching_params)
{
self.hits.push(node.tokens.to_string());
}
syn::visit::visit_macro(self, node);
}
}
let mut visitor = ComboVisitor {
matching_params,
hits: Vec::new(),
};
visitor.visit_block(block);
visitor.hits
}
fn tokens_reference_at_least_two_idents(
tokens: &proc_macro2::TokenStream,
idents: &BTreeSet<String>,
) -> bool {
fn collect(
tokens: proc_macro2::TokenStream,
idents: &BTreeSet<String>,
found: &mut BTreeSet<String>,
) {
for tree in tokens {
match tree {
proc_macro2::TokenTree::Ident(node) => {
let name = node.to_string();
if idents.contains(&name) {
found.insert(name);
}
}
proc_macro2::TokenTree::Group(group) => collect(group.stream(), idents, found),
_ => {}
}
}
}
let mut found = BTreeSet::new();
collect(tokens.clone(), idents, &mut found);
found.len() >= 2
}
fn build_public_invariant_bypass_candidate(
krate: &CrateInfo,
fact: &PubStructFact,
ctor_facts: &[ConstructorFact],
) -> PatternCandidate {
let scope = crate_scope(krate, vec![fact.name.clone()]);
let primary_locations: Vec<EvidenceLocation> = fact
.fields
.iter()
.map(|field| EvidenceLocation::new(fact.file.clone(), format!("{}::{field}", fact.name)))
.collect();
let field_list: Vec<&str> = fact.fields.iter().map(String::as_str).collect();
let primary = Evidence {
description: format!(
"`pub struct {}` in crate `{}` has {} `pub` field(s) ({}) and carries no \
`#[non_exhaustive]` attribute.",
fact.name,
krate.name,
fact.fields.len(),
field_list.join(", ")
),
locations: primary_locations,
};
let mut independent_locations: Vec<EvidenceLocation> = ctor_facts
.iter()
.map(|ctor| EvidenceLocation::new(ctor.file.clone(), ctor.item_path.clone()))
.collect();
sort_evidence_locations(&mut independent_locations);
let combo_texts: Vec<&str> = ctor_facts
.iter()
.flat_map(|ctor| ctor.hits.iter())
.map(String::as_str)
.collect();
let independent = Evidence {
description: format!(
"At least one constructor for `{}` already validates a combination of ≥2 of these \
`pub` fields together, e.g. `{}`.",
fact.name,
combo_texts.join("`, `")
),
locations: independent_locations,
};
let evidence_identities: Vec<String> = std::iter::once(fact.name.clone())
.chain(fact.fields.iter().cloned())
.chain(ctor_facts.iter().map(|ctor| ctor.item_path.clone()))
.collect();
let mut affected_paths: Vec<PathBuf> = std::iter::once(fact.file.clone())
.chain(ctor_facts.iter().map(|ctor| ctor.file.clone()))
.collect();
affected_paths.sort();
affected_paths.dedup();
pattern_candidate! {
pattern: RustPattern::SmartConstructor,
scope,
evidence_identities,
{
evidence: CorroboratedEvidence::new(primary, independent),
preconditions: vec![Precondition {
description: format!(
"`{}` hat mindestens zwei öffentliche Felder und mindestens ein Konstruktor \
validiert bereits eine Kombination davon.",
fact.name
),
}],
contraindications: vec![
Contraindication {
description: "Wenn der Struct primär als reine Datenhülle ohne Invarianten \
außerhalb des Konstruktors gedacht ist, kann öffentlicher Feldzugriff bewusst \
sein."
.to_string(),
},
Contraindication {
description: "Private Felder erzwingen Getter-/Setter-Boilerplate, was bei \
internen/Test-only-Structs mehr kostet als nützt."
.to_string(),
},
],
migration: vec![
MigrationStep {
step: 1,
description: "Felder privat machen.".to_string(),
affected_paths: vec![fact.file.clone()],
},
MigrationStep {
step: 2,
description:
"Bestehenden Konstruktor als einzigen Erzeugungsweg belassen/ausbauen."
.to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 3,
description: "Falls Änderungen nach Konstruktion nötig sind, validierte Setter \
statt direkter Feldzuweisung ergänzen."
.to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 4,
description: "Call-Sites, die Struct-Update-Syntax nutzen, anpassen.".to_string(),
affected_paths,
},
],
related_findings: Vec::new(),
}
}
}
fn manual_resource_lifecycle_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
let mut candidates = Vec::new();
for krate in &workspace.crates {
let mut has_drop_impl = false;
let mut hits: Vec<EvidenceLocation> = Vec::new();
for_each_parsed_source(krate, |file, ast| {
if file_has_drop_impl(ast) {
has_drop_impl = true;
}
let mut visitor = ResourceLifecycleVisitor {
file,
self_type: None,
hits: Vec::new(),
};
visitor.visit_file(ast);
hits.append(&mut visitor.hits);
});
if has_drop_impl || hits.is_empty() {
continue;
}
candidates.push(build_manual_resource_lifecycle_candidate(krate, &hits));
}
candidates
}
const ACQUIRE_CALL_NAMES: &[&str] = &[
"register",
"acquire",
"open",
"lock",
"begin",
"start",
"connect",
"subscribe",
];
const RELEASE_CALL_NAMES: &[&str] = &[
"unregister",
"release",
"close",
"unlock",
"end",
"stop",
"disconnect",
"unsubscribe",
];
fn file_has_drop_impl(ast: &syn::File) -> bool {
struct DropFinder {
found: bool,
}
impl<'ast> Visit<'ast> for DropFinder {
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
if impl_trait_is(node, "Drop") {
self.found = true;
}
syn::visit::visit_item_impl(self, node);
}
}
let mut finder = DropFinder { found: false };
finder.visit_file(ast);
finder.found
}
fn acquire_and_release_calls(block: &syn::Block) -> (bool, bool) {
struct Finder {
acquire: bool,
release: bool,
}
impl Finder {
fn observe(&mut self, name: &str) {
if ACQUIRE_CALL_NAMES.contains(&name) {
self.acquire = true;
}
if RELEASE_CALL_NAMES.contains(&name) {
self.release = true;
}
}
}
impl<'ast> Visit<'ast> for Finder {
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
self.observe(&node.method.to_string());
syn::visit::visit_expr_method_call(self, node);
}
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
if let syn::Expr::Path(path) = node.func.as_ref()
&& let Some(segment) = path.path.segments.last()
{
self.observe(&segment.ident.to_string());
}
syn::visit::visit_expr_call(self, node);
}
}
let mut finder = Finder {
acquire: false,
release: false,
};
finder.visit_block(block);
(finder.acquire, finder.release)
}
struct ResourceLifecycleVisitor<'a> {
file: &'a Path,
self_type: Option<String>,
hits: Vec<EvidenceLocation>,
}
impl ResourceLifecycleVisitor<'_> {
fn record_fn(&mut self, name: &str, block: &syn::Block) {
let (has_acquire, has_release) = acquire_and_release_calls(block);
if !has_acquire || !has_release {
return;
}
let item_path = qualified_item_path(self.self_type.as_deref(), name);
self.hits
.push(EvidenceLocation::new(self.file.to_path_buf(), item_path));
}
}
impl<'ast> Visit<'ast> for ResourceLifecycleVisitor<'_> {
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
self.record_fn(&node.sig.ident.to_string(), &node.block);
syn::visit::visit_item_fn(self, node);
}
visit_item_impl_with_self_type!();
fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
self.record_fn(&node.sig.ident.to_string(), &node.block);
syn::visit::visit_impl_item_fn(self, node);
}
}
fn build_manual_resource_lifecycle_candidate(
krate: &CrateInfo,
hits: &[EvidenceLocation],
) -> PatternCandidate {
let modules: Vec<String> = hits
.iter()
.filter_map(|hit| hit.item_path.clone())
.collect();
let scope = crate_scope(krate, modules);
let mut primary_locations = hits.to_vec();
sort_evidence_locations(&mut primary_locations);
let primary = Evidence {
description: format!(
"{} function(s) in crate `{}` call both an acquire-shaped operation (e.g. \
`register`/`acquire`/`open`/`lock`/`begin`/`start`/`connect`/`subscribe`) and a \
release-shaped counterpart (e.g. \
`unregister`/`release`/`close`/`unlock`/`end`/`stop`/`disconnect`/`unsubscribe`) by \
call name.",
hits.len(),
krate.name
),
locations: primary_locations,
};
let independent = Evidence {
description: format!(
"Crate `{}` contains no `impl Drop for ...` block anywhere — no evidence this \
codebase already uses RAII guards as a pattern.",
krate.name
),
locations: Vec::new(),
};
let evidence_identities: Vec<String> = location_identities(hits);
let mut affected_paths: Vec<PathBuf> = hits.iter().map(|hit| hit.file.clone()).collect();
affected_paths.sort();
affected_paths.dedup();
pattern_candidate! {
pattern: RustPattern::RaiiGuard,
scope,
evidence_identities,
{
evidence: CorroboratedEvidence::new(primary, independent),
preconditions: vec![Precondition {
description: format!(
"Crate `{}` enthält mindestens ein Acquire-/Release-Aufrufpaar innerhalb einer \
Funktion, aber keine `Drop`-Implementierung.",
krate.name
),
}],
contraindications: vec![
Contraindication {
description: "Diese Heuristik kann nicht belegen, dass Besitz und Lebensdauer \
der Ressource eindeutig an einen einzelnen Guard gebunden werden können — das \
ist Voraussetzung für einen sinnvollen RAII-Guard, nicht nur Namensähnlichkeit."
.to_string(),
},
Contraindication {
description: "Acquire/Release könnten unabhängige, zufällig gleich benannte \
Operationen auf unterschiedlichen Objekten sein."
.to_string(),
},
Contraindication {
description: "Bei seltener, einmaliger Nutzung kann der Boilerplate eines \
eigenen Guard-Typs mehr kosten als eine sorgfältige manuelle Passung."
.to_string(),
},
],
migration: vec![
MigrationStep {
step: 1,
description: "Ressourcentyp und Lebensdauer-Bindung manuell bestätigen (nicht \
automatisierbar)."
.to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 2,
description: "Guard-Struct mit dem Handle als Feld definieren.".to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 3,
description: "`Drop::drop` mit der Release-Logik implementieren.".to_string(),
affected_paths: Vec::new(),
},
MigrationStep {
step: 4,
description: "Acquire-Stelle so umbauen, dass sie den Guard statt des rohen \
Handles zurückgibt."
.to_string(),
affected_paths,
},
],
related_findings: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::{EvidenceClass, Location, OneBasedLine, Origin, Severity};
use crate::ingest::{SourceFile, SourceKind};
use crate::test_util::TempDir;
fn workspace_with_crate(root: PathBuf, files: Vec<PathBuf>) -> Workspace {
Workspace {
root: root.clone(),
crates: vec![CrateInfo {
name: "fixture".to_string(),
version: "0.1.0".to_string(),
manifest_path: root.join("Cargo.toml"),
root,
source_files: files
.into_iter()
.map(|path| SourceFile {
path,
kind: SourceKind::Authored,
})
.collect(),
entry_points: Vec::new(),
dependencies: Vec::new(),
}],
}
}
fn catch_all_error_finding(file: &Path, item_path: &str, line: usize) -> Finding {
Finding::new(
format!("catch-all-error:{}:{line}:1", file.display()),
crate::rules::slop::CATCH_ALL_ERROR_RULE,
Severity::Warn,
Location {
file: file.to_path_buf(),
line: OneBasedLine::new(line).unwrap(),
item_path: item_path.to_string(),
},
EvidenceClass::DerivedFact,
Origin::Code,
None,
)
}
#[test]
fn two_symptoms_plus_a_typed_error_produce_one_candidate() {
let dir = TempDir::new("pattern-corroborated");
let boundary = dir.join("boundary.rs");
std::fs::write(
&boundary,
"pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
)
.unwrap();
let errors = dir.join("errors.rs");
std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
let findings = vec![
catch_all_error_finding(&boundary, "a", 1),
catch_all_error_finding(&boundary, "b", 2),
];
let candidates = analyze_workspace(&workspace, &findings);
assert_eq!(candidates.len(), 1);
let candidate = &candidates[0];
assert_eq!(candidate.pattern, RustPattern::DomainError);
assert_eq!(candidate.scope.krate, "fixture");
assert_eq!(candidate.related_findings.len(), 2);
assert!(!candidate.evidence.primary.locations.is_empty());
assert!(!candidate.evidence.independent.locations.is_empty());
assert!(candidate.evidence.primary.description.contains('2'));
assert!(!candidate.contraindications.is_empty());
assert!(candidate.migration.len() >= 2);
}
#[test]
fn a_single_finding_is_below_threshold() {
let dir = TempDir::new("pattern-single-finding");
let boundary = dir.join("boundary.rs");
std::fs::write(
&boundary,
"pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
)
.unwrap();
let errors = dir.join("errors.rs");
std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
let findings = vec![catch_all_error_finding(&boundary, "a", 1)];
assert!(analyze_workspace(&workspace, &findings).is_empty());
}
#[test]
fn two_findings_without_a_typed_error_are_not_corroborated() {
let dir = TempDir::new("pattern-uncorroborated");
let boundary = dir.join("boundary.rs");
std::fs::write(
&boundary,
"pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone()]);
let findings = vec![
catch_all_error_finding(&boundary, "a", 1),
catch_all_error_finding(&boundary, "b", 2),
];
assert!(analyze_workspace(&workspace, &findings).is_empty());
}
#[test]
fn a_manual_error_trait_impl_counts_as_the_independent_signal() {
let dir = TempDir::new("pattern-manual-impl");
let boundary = dir.join("boundary.rs");
std::fs::write(
&boundary,
"pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
)
.unwrap();
let errors = dir.join("errors.rs");
std::fs::write(
&errors,
"struct Oops;\n\
impl std::fmt::Display for Oops { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\n\
impl std::error::Error for Oops {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
let findings = vec![
catch_all_error_finding(&boundary, "a", 1),
catch_all_error_finding(&boundary, "b", 2),
];
assert_eq!(analyze_workspace(&workspace, &findings).len(), 1);
}
#[test]
fn candidate_id_is_deterministic() {
let dir = TempDir::new("pattern-deterministic-id");
let boundary = dir.join("boundary.rs");
std::fs::write(
&boundary,
"pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
)
.unwrap();
let errors = dir.join("errors.rs");
std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
let findings = vec![
catch_all_error_finding(&boundary, "a", 1),
catch_all_error_finding(&boundary, "b", 2),
];
let first = analyze_workspace(&workspace, &findings);
let second = analyze_workspace(&workspace, &findings);
assert_eq!(first[0].id, second[0].id);
}
#[test]
fn primitive_domain_value_two_signatures_plus_a_guard_produce_one_candidate() {
let dir = TempDir::new("pattern-primitive-corroborated");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn set_a(threshold: u32) {}\n\
pub fn set_b(threshold: u32) -> Result<(), String> {\n\
\x20 if threshold > 100 {\n\
\x20 return Err(\"too big\".to_string());\n\
\x20 }\n\
\x20 Ok(())\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
let candidate = &candidates[0];
assert_eq!(candidate.pattern, RustPattern::ValidatedNewtype);
assert_eq!(candidate.scope.krate, "fixture");
assert_eq!(candidate.evidence.primary.locations.len(), 2);
assert_eq!(candidate.evidence.independent.locations.len(), 1);
assert_eq!(
candidate.evidence.independent.locations[0]
.item_path
.as_deref(),
Some("set_b")
);
assert!(!candidate.contraindications.is_empty());
assert!(candidate.migration.len() >= 2);
}
#[test]
fn primitive_domain_value_single_signature_is_below_threshold() {
let dir = TempDir::new("pattern-primitive-single");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn set_a(threshold: u32) -> Result<(), String> {\n\
\x20 if threshold > 100 {\n\
\x20 return Err(\"too big\".to_string());\n\
\x20 }\n\
\x20 Ok(())\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn primitive_domain_value_without_any_guard_is_not_corroborated() {
let dir = TempDir::new("pattern-primitive-unguarded");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn set_a(threshold: u32) {}\n\
pub fn set_b(threshold: u32) {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn boolean_cluster_three_bools_plus_a_combined_condition_produce_one_candidate() {
let dir = TempDir::new("pattern-bool-corroborated");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
\x20 if verbose && strict {\n\
\x20 do_thing();\n\
\x20 }\n\
\x20 let _ = dry_run;\n\
}\n\
fn do_thing() {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
let candidate = &candidates[0];
assert_eq!(candidate.pattern, RustPattern::OptionsStruct);
assert_eq!(candidate.scope.krate, "fixture");
assert_eq!(candidate.scope.modules, vec!["configure".to_string()]);
assert!(!candidate.contraindications.is_empty());
}
#[test]
fn boolean_cluster_without_a_combined_condition_is_not_corroborated() {
let dir = TempDir::new("pattern-bool-independent-checks");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
\x20 if verbose {\n\
\x20 do_thing();\n\
\x20 }\n\
\x20 if strict {\n\
\x20 do_thing();\n\
\x20 }\n\
\x20 if dry_run {\n\
\x20 do_thing();\n\
\x20 }\n\
}\n\
fn do_thing() {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn boolean_cluster_with_only_two_bools_is_below_threshold() {
let dir = TempDir::new("pattern-bool-below-threshold");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn configure(verbose: bool, strict: bool) {\n\
\x20 if verbose && strict {\n\
\x20 do_thing();\n\
\x20 }\n\
}\n\
fn do_thing() {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn boolean_cluster_with_matching_clippy_hit_gains_a_third_evidence_entry() {
let dir = TempDir::new("pattern-bool-clippy-corroborated");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
\x20 if verbose && strict {\n\
\x20 do_thing();\n\
\x20 }\n\
\x20 let _ = dry_run;\n\
}\n\
fn do_thing() {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let without_clippy = analyze_workspace(&workspace, &[]);
assert_eq!(without_clippy.len(), 1);
assert!(without_clippy[0].evidence.additional.is_empty());
let clippy_hits = vec![ClippyBoolParamsHit {
file: PathBuf::from("lib.rs"),
line_start: 1,
line_end: 1,
}];
let with_clippy = analyze_workspace_with_clippy(&workspace, &[], &clippy_hits);
assert_eq!(with_clippy.len(), 1);
assert_eq!(with_clippy[0].evidence.additional.len(), 1);
assert!(
with_clippy[0].evidence.additional[0]
.description
.contains("fn_params_excessive_bools")
);
}
#[test]
fn boolean_cluster_clippy_hit_alone_does_not_create_a_candidate() {
let dir = TempDir::new("pattern-bool-clippy-alone");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
\x20 if verbose {\n\
\x20 do_thing();\n\
\x20 }\n\
\x20 if strict {\n\
\x20 do_thing();\n\
\x20 }\n\
\x20 if dry_run {\n\
\x20 do_thing();\n\
\x20 }\n\
}\n\
fn do_thing() {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let clippy_hits = vec![ClippyBoolParamsHit {
file: PathBuf::from("lib.rs"),
line_start: 1,
line_end: 1,
}];
assert!(analyze_workspace_with_clippy(&workspace, &[], &clippy_hits).is_empty());
}
#[test]
fn public_invariant_bypass_struct_plus_combo_validating_constructor_produce_one_candidate() {
let dir = TempDir::new("pattern-invariant-corroborated");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Range {\n\
\x20 pub low: u32,\n\
\x20 pub high: u32,\n\
}\n\
impl Range {\n\
\x20 pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
\x20 if low >= high {\n\
\x20 return Err(\"low must be less than high\".to_string());\n\
\x20 }\n\
\x20 Ok(Self { low, high })\n\
\x20 }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
let candidate = &candidates[0];
assert_eq!(candidate.pattern, RustPattern::SmartConstructor);
assert_eq!(candidate.scope.krate, "fixture");
assert_eq!(candidate.evidence.primary.locations.len(), 2);
assert!(!candidate.evidence.independent.locations.is_empty());
assert!(!candidate.contraindications.is_empty());
assert!(candidate.migration.len() >= 2);
}
#[test]
fn public_invariant_bypass_non_exhaustive_struct_produces_no_candidate() {
let dir = TempDir::new("pattern-invariant-non-exhaustive");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"#[non_exhaustive]\n\
pub struct Range {\n\
\x20 pub low: u32,\n\
\x20 pub high: u32,\n\
}\n\
impl Range {\n\
\x20 pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
\x20 if low >= high {\n\
\x20 return Err(\"low must be less than high\".to_string());\n\
\x20 }\n\
\x20 Ok(Self { low, high })\n\
\x20 }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn public_invariant_bypass_single_field_validation_is_not_corroborated() {
let dir = TempDir::new("pattern-invariant-single-field");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub struct Range {\n\
\x20 pub low: u32,\n\
\x20 pub high: u32,\n\
}\n\
impl Range {\n\
\x20 pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
\x20 if low > 1000 {\n\
\x20 return Err(\"too big\".to_string());\n\
\x20 }\n\
\x20 Ok(Self { low, high })\n\
\x20 }\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn manual_resource_lifecycle_register_unregister_without_drop_produces_one_candidate() {
let dir = TempDir::new("pattern-resource-corroborated");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn manage(handle: u32) {\n\
\x20 register(handle);\n\
\x20 unregister(handle);\n\
}\n\
fn register(_handle: u32) {}\n\
fn unregister(_handle: u32) {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
let candidate = &candidates[0];
assert_eq!(candidate.pattern, RustPattern::RaiiGuard);
assert_eq!(candidate.scope.krate, "fixture");
assert!(!candidate.evidence.primary.locations.is_empty());
assert_eq!(candidate.contraindications.len(), 3);
assert!(candidate.migration.len() >= 2);
}
#[test]
fn manual_resource_lifecycle_with_an_existing_drop_impl_is_not_corroborated() {
let dir = TempDir::new("pattern-resource-has-drop");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn manage(handle: u32) {\n\
\x20 register(handle);\n\
\x20 unregister(handle);\n\
}\n\
fn register(_handle: u32) {}\n\
fn unregister(_handle: u32) {}\n\
struct X;\n\
impl Drop for X {\n\
\x20 fn drop(&mut self) {}\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn manual_resource_lifecycle_without_a_matching_release_call_produces_no_candidate() {
let dir = TempDir::new("pattern-resource-unmatched");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn manage(handle: u32) {\n\
\x20 register(handle);\n\
}\n\
fn register(_handle: u32) {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn stringly_error_boundary_cfg_gated_typed_error_still_corroborates() {
let dir = TempDir::new("pattern-stringly-cfg-gated");
let boundary = dir.join("boundary.rs");
std::fs::write(
&boundary,
"pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
)
.unwrap();
let errors = dir.join("errors.rs");
std::fs::write(
&errors,
"#[cfg(feature = \"not-enabled-by-default\")]\n\
enum FooError { Bad }\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
let findings = vec![
catch_all_error_finding(&boundary, "a", 1),
catch_all_error_finding(&boundary, "b", 2),
];
let candidates = analyze_workspace(&workspace, &findings);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::DomainError);
}
#[test]
fn primitive_domain_value_cfg_gated_guard_still_corroborates() {
let dir = TempDir::new("pattern-primitive-cfg-gated");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"pub fn set_a(threshold: u32) {}\n\
#[cfg(feature = \"not-enabled-by-default\")]\n\
pub fn set_b(threshold: u32) -> Result<(), String> {\n\
\x20 if threshold > 100 {\n\
\x20 return Err(\"too big\".to_string());\n\
\x20 }\n\
\x20 Ok(())\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::ValidatedNewtype);
}
#[test]
fn boolean_cluster_macro_generated_function_produces_no_candidate() {
let dir = TempDir::new("pattern-bool-macro-generated");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"macro_rules! configure_impl {\n\
\x20 () => {\n\
\x20 pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
\x20 if verbose && strict {\n\
\x20 do_thing();\n\
\x20 }\n\
\x20 let _ = dry_run;\n\
\x20 }\n\
\x20 };\n\
}\n\
configure_impl!();\n\
fn do_thing() {}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn public_invariant_bypass_derive_macro_constructor_produces_no_candidate() {
let dir = TempDir::new("pattern-invariant-derive-macro");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"#[derive(Builder)]\n\
pub struct Range {\n\
\x20 pub low: u32,\n\
\x20 pub high: u32,\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
assert!(analyze_workspace(&workspace, &[]).is_empty());
}
#[test]
fn manual_resource_lifecycle_unrelated_types_sharing_call_names_still_fires() {
let dir = TempDir::new("pattern-resource-coincidental-names");
let file = dir.join("lib.rs");
std::fs::write(
&file,
"struct MetricRegistry;\n\
impl MetricRegistry {\n\
\x20 fn register(&self, _id: u32) {}\n\
}\n\
struct ListSubscription;\n\
impl ListSubscription {\n\
\x20 fn unregister(&self) {}\n\
}\n\
pub fn unrelated_operations(id: u32) {\n\
\x20 let registry = MetricRegistry;\n\
\x20 let subscription = ListSubscription;\n\
\x20 registry.register(id);\n\
\x20 subscription.unregister();\n\
}\n",
)
.unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::RaiiGuard);
}
#[test]
fn stringly_error_boundary_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(STRINGLY_ERROR_BOUNDARY_RULE)
.expect("stringly-error-boundary has a registry entry")
.example
.expect("stringly-error-boundary has a curated example")
.before;
let dir = TempDir::new("pattern-registry-example-stringly");
let file = dir.join("lib.rs");
std::fs::write(&file, example).unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file.clone()]);
let findings = vec![
catch_all_error_finding(&file, "fetch_user", 1),
catch_all_error_finding(&file, "fetch_order", 5),
];
let candidates = analyze_workspace(&workspace, &findings);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::DomainError);
}
#[test]
fn primitive_domain_value_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(PRIMITIVE_DOMAIN_VALUE_RULE)
.expect("primitive-domain-value has a registry entry")
.example
.expect("primitive-domain-value has a curated example")
.before;
let dir = TempDir::new("pattern-registry-example-primitive");
let file = dir.join("lib.rs");
std::fs::write(&file, example).unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::ValidatedNewtype);
}
#[test]
fn boolean_state_cluster_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(BOOLEAN_STATE_CLUSTER_RULE)
.expect("boolean-state-cluster has a registry entry")
.example
.expect("boolean-state-cluster has a curated example")
.before;
let dir = TempDir::new("pattern-registry-example-boolean");
let file = dir.join("lib.rs");
std::fs::write(&file, example).unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::OptionsStruct);
}
#[test]
fn public_invariant_bypass_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(PUBLIC_INVARIANT_BYPASS_RULE)
.expect("public-invariant-bypass has a registry entry")
.example
.expect("public-invariant-bypass has a curated example")
.before;
let dir = TempDir::new("pattern-registry-example-invariant");
let file = dir.join("lib.rs");
std::fs::write(&file, example).unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::SmartConstructor);
}
#[test]
fn manual_resource_lifecycle_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(MANUAL_RESOURCE_LIFECYCLE_RULE)
.expect("manual-resource-lifecycle has a registry entry")
.example
.expect("manual-resource-lifecycle has a curated example")
.before;
let dir = TempDir::new("pattern-registry-example-resource");
let file = dir.join("lib.rs");
std::fs::write(&file, example).unwrap();
let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
let candidates = analyze_workspace(&workspace, &[]);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].pattern, RustPattern::RaiiGuard);
}
}