1use std::collections::{BTreeMap, BTreeSet};
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26use syn::spanned::Spanned;
27use syn::visit::Visit;
28
29use crate::advisory::clippy_import::ClippyBoolParamsHit;
30use crate::finding::{Finding, FindingId};
31use crate::ingest::{CrateInfo, Workspace};
32
33pub const STRINGLY_ERROR_BOUNDARY_RULE: &str = "stringly-error-boundary";
38
39pub const PRIMITIVE_DOMAIN_VALUE_RULE: &str = "primitive-domain-value";
42
43pub const BOOLEAN_STATE_CLUSTER_RULE: &str = "boolean-state-cluster";
46
47pub const PUBLIC_INVARIANT_BYPASS_RULE: &str = "public-invariant-bypass";
50
51pub const MANUAL_RESOURCE_LIFECYCLE_RULE: &str = "manual-resource-lifecycle";
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum RustPattern {
64 ValidatedNewtype,
65 SmartConstructor,
66 StateEnum,
67 TypeState,
68 Builder,
69 OptionsStruct,
70 RaiiGuard,
71 DomainError,
72 FunctionalCore,
73 EncapsulatedAggregate,
74}
75
76impl RustPattern {
77 pub const fn slug(self) -> &'static str {
80 match self {
81 Self::ValidatedNewtype => "validated-newtype",
82 Self::SmartConstructor => "smart-constructor",
83 Self::StateEnum => "state-enum",
84 Self::TypeState => "type-state",
85 Self::Builder => "builder",
86 Self::OptionsStruct => "options-struct",
87 Self::RaiiGuard => "raii-guard",
88 Self::DomainError => "domain-error",
89 Self::FunctionalCore => "functional-core",
90 Self::EncapsulatedAggregate => "encapsulated-aggregate",
91 }
92 }
93}
94
95impl std::fmt::Display for RustPattern {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.write_str(self.slug())
98 }
99}
100
101#[derive(Debug, Clone, Serialize)]
109pub struct CodeScope {
110 pub krate: String,
112 pub modules: Vec<String>,
116}
117
118#[derive(Debug, Clone, Serialize)]
122pub struct EvidenceLocation {
123 pub file: PathBuf,
124 pub item_path: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize)]
132pub struct Evidence {
133 pub description: String,
134 pub locations: Vec<EvidenceLocation>,
135}
136
137#[derive(Debug, Clone, Serialize)]
144pub struct CorroboratedEvidence {
145 pub primary: Evidence,
146 pub independent: Evidence,
147 pub additional: Vec<Evidence>,
148}
149
150#[derive(Debug, Clone, Serialize)]
154pub struct Contraindication {
155 pub description: String,
156}
157
158#[derive(Debug, Clone, Serialize)]
161pub struct Precondition {
162 pub description: String,
163}
164
165#[derive(Debug, Clone, Serialize)]
170pub struct MigrationStep {
171 pub step: u32,
172 pub description: String,
173 pub affected_paths: Vec<PathBuf>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
198#[serde(transparent)]
199pub struct PatternCandidateId(String);
200
201impl PatternCandidateId {
202 pub fn as_str(&self) -> &str {
203 &self.0
204 }
205
206 fn compute(pattern: RustPattern, scope: &CodeScope, evidence_identities: &[String]) -> Self {
207 let mut modules = scope.modules.clone();
208 modules.sort();
209 let mut identities = evidence_identities.to_vec();
210 identities.sort();
211 identities.dedup();
212 let normalized = format!(
213 "{}|{}|{}|{}",
214 pattern.slug(),
215 scope.krate,
216 modules.join(","),
217 identities.join(",")
218 );
219 Self(format!(
220 "pattern:{}:{}",
221 pattern.slug(),
222 crate::finding::fnv1a_hex(&normalized)
223 ))
224 }
225}
226
227impl std::fmt::Display for PatternCandidateId {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 f.write_str(&self.0)
230 }
231}
232
233#[derive(Debug, Clone, Serialize)]
242pub struct PatternCandidate {
243 pub id: PatternCandidateId,
244 pub pattern: RustPattern,
245 pub scope: CodeScope,
246 pub evidence: CorroboratedEvidence,
247 pub preconditions: Vec<Precondition>,
248 pub contraindications: Vec<Contraindication>,
249 pub migration: Vec<MigrationStep>,
250 pub related_findings: Vec<FindingId>,
251}
252
253macro_rules! pattern_candidate {
256 (
257 pattern: $pattern:expr,
258 $scope:expr,
259 $evidence_identities:expr,
260 {
261 evidence: $evidence:expr,
262 preconditions: $preconditions:expr,
263 contraindications: $contraindications:expr,
264 migration: $migration:expr,
265 related_findings: $related_findings:expr $(,)?
266 }
267 ) => {{
268 let pattern = $pattern;
269 let scope = $scope;
270 let evidence_identities = $evidence_identities;
271 PatternCandidate {
272 id: PatternCandidateId::compute(pattern, &scope, &evidence_identities),
273 pattern,
274 scope,
275 evidence: $evidence,
276 preconditions: $preconditions,
277 contraindications: $contraindications,
278 migration: $migration,
279 related_findings: $related_findings,
280 }
281 }};
282}
283
284pub fn analyze_workspace(workspace: &Workspace, findings: &[Finding]) -> Vec<PatternCandidate> {
291 analyze_workspace_with_clippy(workspace, findings, &[])
292}
293
294pub fn analyze_workspace_with_clippy(
301 workspace: &Workspace,
302 findings: &[Finding],
303 clippy_hits: &[ClippyBoolParamsHit],
304) -> Vec<PatternCandidate> {
305 let mut candidates = stringly_error_boundary_candidates(workspace, findings);
306 candidates.extend(primitive_domain_value_candidates(workspace));
307 candidates.extend(boolean_state_cluster_candidates(workspace, clippy_hits));
308 candidates.extend(public_invariant_bypass_candidates(workspace));
309 candidates.extend(manual_resource_lifecycle_candidates(workspace));
310 candidates
311}
312
313fn stringly_error_boundary_candidates(
332 workspace: &Workspace,
333 findings: &[Finding],
334) -> Vec<PatternCandidate> {
335 let mut by_crate: BTreeMap<&str, Vec<&Finding>> = BTreeMap::new();
336 for finding in findings {
337 if finding.rule.as_str() != crate::rules::slop::CATCH_ALL_ERROR_RULE {
338 continue;
339 }
340 let Some(krate) = crate_for_file(workspace, &finding.location.file) else {
341 continue;
342 };
343 by_crate
344 .entry(krate.name.as_str())
345 .or_default()
346 .push(finding);
347 }
348
349 let mut candidates = Vec::new();
350 for (krate_name, crate_findings) in by_crate {
351 if crate_findings.len() < 2 {
352 continue;
353 }
354 let Some(krate) = workspace.crates.iter().find(|k| k.name == krate_name) else {
355 continue;
356 };
357 let Some(independent) = crate_defines_typed_error(krate) else {
358 continue;
359 };
360 candidates.push(build_candidate(krate, &crate_findings, independent));
361 }
362 candidates
363}
364
365fn crate_for_file<'a>(workspace: &'a Workspace, file: &Path) -> Option<&'a CrateInfo> {
368 workspace
369 .crates
370 .iter()
371 .find(|krate| krate.source_files.iter().any(|source| source.path == file))
372}
373
374fn for_each_parsed_source(krate: &CrateInfo, mut visit: impl FnMut(&Path, &syn::File)) {
383 for source in &krate.source_files {
384 let Ok(text) = std::fs::read_to_string(&source.path) else {
385 continue;
386 };
387 let Ok(ast) = syn::parse_file(&text) else {
388 continue;
389 };
390 visit(&source.path, &ast);
391 }
392}
393
394impl EvidenceLocation {
395 fn new(file: PathBuf, item_path: impl Into<String>) -> Self {
401 Self {
402 file,
403 item_path: Some(item_path.into()),
404 }
405 }
406}
407
408fn sort_evidence_locations(locations: &mut [EvidenceLocation]) {
412 locations.sort_by(|a, b| (&a.file, &a.item_path).cmp(&(&b.file, &b.item_path)));
413}
414
415fn crate_scope(krate: &CrateInfo, mut modules: Vec<String>) -> CodeScope {
419 modules.sort();
420 modules.dedup();
421 CodeScope {
422 krate: krate.name.clone(),
423 modules,
424 }
425}
426
427impl CorroboratedEvidence {
428 fn new(primary: Evidence, independent: Evidence) -> Self {
433 Self {
434 primary,
435 independent,
436 additional: Vec::new(),
437 }
438 }
439}
440
441fn location_identities(locations: &[EvidenceLocation]) -> Vec<String> {
444 locations
445 .iter()
446 .map(|location| {
447 format!(
448 "{}:{}",
449 location.file.display(),
450 location.item_path.as_deref().unwrap_or("")
451 )
452 })
453 .collect()
454}
455
456fn qualified_item_path(self_type: Option<&str>, name: &str) -> String {
462 match self_type {
463 Some(self_type) => format!("{self_type}::{name}"),
464 None => name.to_string(),
465 }
466}
467
468fn typed_ident_arg(input: &syn::FnArg) -> Option<(String, &syn::Type)> {
474 let syn::FnArg::Typed(pat_type) = input else {
475 return None;
476 };
477 let syn::Pat::Ident(pat_ident) = pat_type.pat.as_ref() else {
478 return None;
479 };
480 Some((pat_ident.ident.to_string(), &pat_type.ty))
481}
482
483fn impl_trait_is(node: &syn::ItemImpl, ident: &str) -> bool {
487 node.trait_.as_ref().is_some_and(|(_, path, _)| {
488 path.segments
489 .last()
490 .is_some_and(|segment| segment.ident == ident)
491 })
492}
493
494fn build_candidate(
495 krate: &CrateInfo,
496 crate_findings: &[&Finding],
497 independent: Evidence,
498) -> PatternCandidate {
499 let mut related_findings: Vec<FindingId> = crate_findings
500 .iter()
501 .map(|finding| finding.id.clone())
502 .collect();
503 related_findings.sort_by(|a, b| a.as_str().cmp(b.as_str()));
504
505 let modules: Vec<String> = crate_findings
506 .iter()
507 .map(|finding| finding.location.item_path.clone())
508 .collect();
509 let scope = crate_scope(krate, modules);
510
511 let mut primary_locations: Vec<EvidenceLocation> = crate_findings
512 .iter()
513 .map(|finding| {
514 EvidenceLocation::new(
515 finding.location.file.clone(),
516 finding.location.item_path.clone(),
517 )
518 })
519 .collect();
520 sort_evidence_locations(&mut primary_locations);
521
522 let mut affected_paths: Vec<PathBuf> = crate_findings
523 .iter()
524 .map(|finding| finding.location.file.clone())
525 .collect();
526 affected_paths.sort();
527 affected_paths.dedup();
528
529 let primary = Evidence {
530 description: format!(
531 "{} `catch-all-error` finding(s) in crate `{}` convert concrete errors to \
532 `String`/`Box<dyn Error>`/context-free collectors at public boundaries.",
533 crate_findings.len(),
534 krate.name
535 ),
536 locations: primary_locations,
537 };
538
539 let evidence_identities: Vec<String> = related_findings
540 .iter()
541 .map(|id| id.as_str().to_string())
542 .collect();
543
544 pattern_candidate! {
545 pattern: RustPattern::DomainError,
546 scope,
547 evidence_identities,
548 {
549 evidence: CorroboratedEvidence::new(primary, independent),
550 preconditions: vec![Precondition {
551 description: format!(
552 "Mehrere Boundary-Funktionen in Crate `{}` wandeln unterschiedliche \
553 Fehlerquellen an derselben Grenze in `anyhow`/`Box<dyn Error>`/`String` um.",
554 krate.name
555 ),
556 }],
557 contraindications: vec![
558 Contraindication {
559 description: "Die Grenze kann bewusst ein Kompatibilitäts-Shim sein, der \
560 verschiedene Fehlerquellen absichtlich vereinheitlicht."
561 .to_string(),
562 },
563 Contraindication {
564 description: "Ein zusätzliches Domain-Error-Enum kann bei sehr wenigen \
565 Aufrufstellen mehr Boilerplate als Nutzen erzeugen."
566 .to_string(),
567 },
568 ],
569 migration: vec![
570 MigrationStep {
571 step: 1,
572 description: "Gemeinsame Fehlerquellen an dieser Grenze identifizieren."
573 .to_string(),
574 affected_paths: affected_paths.clone(),
575 },
576 MigrationStep {
577 step: 2,
578 description: "Domain-Error-Enum mit einer Variante pro Quelle entwerfen."
579 .to_string(),
580 affected_paths: Vec::new(),
581 },
582 MigrationStep {
583 step: 3,
584 description: "`From`-Impls für die Quellfehler ergänzen.".to_string(),
585 affected_paths: Vec::new(),
586 },
587 MigrationStep {
588 step: 4,
589 description: "Boundary-Funktionen auf das neue Enum umstellen und `?` statt \
590 manueller Konvertierung nutzen."
591 .to_string(),
592 affected_paths,
593 },
594 ],
595 related_findings: related_findings,
596 }
597 }
598}
599
600fn crate_defines_typed_error(krate: &CrateInfo) -> Option<Evidence> {
608 let mut hits = Vec::new();
609 for_each_parsed_source(krate, |file, ast| {
610 let mut visitor = TypedErrorVisitor {
611 file,
612 path: Vec::new(),
613 hits: Vec::new(),
614 };
615 visitor.visit_file(ast);
616 hits.append(&mut visitor.hits);
617 });
618 if hits.is_empty() {
619 return None;
620 }
621 hits.sort_by(|a, b| (&a.file, &a.item_path).cmp(&(&b.file, &b.item_path)));
622 Some(Evidence {
623 description: format!(
624 "Crate `{}` already defines {} typed error item(s) in its own source (an enum with \
625 `Error` in its name, an `Error`-deriving item, or an `impl ... Error for ...`) — \
626 the raw material for a domain error already exists in this crate.",
627 krate.name,
628 hits.len()
629 ),
630 locations: hits,
631 })
632}
633
634struct TypedErrorVisitor<'a> {
639 file: &'a Path,
640 path: Vec<String>,
641 hits: Vec<EvidenceLocation>,
642}
643
644impl TypedErrorVisitor<'_> {
645 fn current_item_path(&self) -> String {
646 crate::functions::qualified_item_path(self.file, &self.path)
647 }
648
649 fn record(&mut self) {
650 self.hits.push(EvidenceLocation::new(
651 self.file.to_path_buf(),
652 self.current_item_path(),
653 ));
654 }
655}
656
657impl<'ast> Visit<'ast> for TypedErrorVisitor<'_> {
658 fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
659 self.path.push(node.ident.to_string());
660 if node.ident.to_string().contains("Error") || has_derive_ending_in(&node.attrs, "Error") {
661 self.record();
662 }
663 syn::visit::visit_item_enum(self, node);
664 self.path.pop();
665 }
666
667 fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
668 self.path.push(node.ident.to_string());
669 if has_derive_ending_in(&node.attrs, "Error") {
670 self.record();
671 }
672 syn::visit::visit_item_struct(self, node);
673 self.path.pop();
674 }
675
676 fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
677 use quote::ToTokens;
678 self.path.push(node.self_ty.to_token_stream().to_string());
679 if impl_trait_is(node, "Error") {
680 self.record();
681 }
682 syn::visit::visit_item_impl(self, node);
683 self.path.pop();
684 }
685}
686
687fn has_derive_ending_in(attrs: &[syn::Attribute], ident: &str) -> bool {
690 attrs.iter().any(|attr| {
691 if !attr.path().is_ident("derive") {
692 return false;
693 }
694 let syn::Meta::List(list) = &attr.meta else {
695 return false;
696 };
697 list.parse_args_with(
698 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
699 )
700 .is_ok_and(|paths| {
701 paths.iter().any(|path| {
702 path.segments
703 .last()
704 .is_some_and(|segment| segment.ident == ident)
705 })
706 })
707 })
708}
709
710fn primitive_domain_value_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
736 let mut candidates = Vec::new();
737 for krate in &workspace.crates {
738 let mut facts: Vec<SignatureParamFact> = Vec::new();
739 for_each_parsed_source(krate, |file, ast| {
740 let mut visitor = PrimitiveDomainValueVisitor {
741 file,
742 self_type: None,
743 facts: Vec::new(),
744 };
745 visitor.visit_file(ast);
746 facts.append(&mut visitor.facts);
747 });
748
749 let mut by_param: BTreeMap<(String, String), Vec<SignatureParamFact>> = BTreeMap::new();
750 for fact in facts {
751 by_param
752 .entry((fact.param.clone(), fact.type_name.clone()))
753 .or_default()
754 .push(fact);
755 }
756
757 for ((param, type_name), group) in by_param {
758 if group.len() < 2 {
759 continue;
760 }
761 if !group.iter().any(|fact| fact.has_guard) {
762 continue;
763 }
764 candidates.push(build_primitive_domain_value_candidate(
765 krate, ¶m, &type_name, &group,
766 ));
767 }
768 }
769 candidates
770}
771
772struct SignatureParamFact {
775 file: PathBuf,
776 item_path: String,
777 param: String,
778 type_name: String,
779 has_guard: bool,
780}
781
782fn build_primitive_domain_value_candidate(
783 krate: &CrateInfo,
784 param: &str,
785 type_name: &str,
786 group: &[SignatureParamFact],
787) -> PatternCandidate {
788 let modules: Vec<String> = group.iter().map(|fact| fact.item_path.clone()).collect();
789 let scope = crate_scope(krate, modules);
790
791 let mut primary_locations: Vec<EvidenceLocation> = group
792 .iter()
793 .map(|fact| EvidenceLocation::new(fact.file.clone(), fact.item_path.clone()))
794 .collect();
795 sort_evidence_locations(&mut primary_locations);
796
797 let mut guard_locations: Vec<EvidenceLocation> = group
798 .iter()
799 .filter(|fact| fact.has_guard)
800 .map(|fact| EvidenceLocation::new(fact.file.clone(), fact.item_path.clone()))
801 .collect();
802 sort_evidence_locations(&mut guard_locations);
803
804 let primary = Evidence {
805 description: format!(
806 "Parameter `{param}: {type_name}` appears with the same name and type in {} `pub \
807 fn` signature(s) in crate `{}`.",
808 group.len(),
809 krate.name
810 ),
811 locations: primary_locations,
812 };
813 let independent = Evidence {
814 description: format!(
815 "At least one of these signatures guards `{param}` with an early error/panic path \
816 referencing the parameter (`if` + `return Err(...)`, `if` + `panic!(...)`, or \
817 `assert!(...)`)."
818 ),
819 locations: guard_locations,
820 };
821
822 let evidence_identities: Vec<String> = location_identities(&primary.locations);
823 let mut affected_paths: Vec<PathBuf> = group.iter().map(|fact| fact.file.clone()).collect();
824 affected_paths.sort();
825 affected_paths.dedup();
826
827 pattern_candidate! {
828 pattern: RustPattern::ValidatedNewtype,
829 scope,
830 evidence_identities,
831 {
832 evidence: CorroboratedEvidence::new(primary, independent),
833 preconditions: vec![Precondition {
834 description: format!(
835 "Crate `{}` verwendet `{param}: {type_name}` wiederholt als Parametername/-typ, \
836 und mindestens eine Fundstelle validiert den Wertebereich explizit.",
837 krate.name
838 ),
839 }],
840 contraindications: vec![
841 Contraindication {
842 description: "Der Parametername kann in verschiedenen Funktionen tatsächlich \
843 unterschiedliche Bedeutungen haben, auch wenn Name und Typ übereinstimmen."
844 .to_string(),
845 },
846 Contraindication {
847 description: "Bei nur einer Validierungsstelle könnte ein Newtype mehr \
848 Boilerplate als Nutzen erzeugen, falls die übrigen Aufrufstellen den Wert nie \
849 direkt validieren müssen."
850 .to_string(),
851 },
852 ],
853 migration: vec![
854 MigrationStep {
855 step: 1,
856 description: "Newtype für den Wertebereich definieren.".to_string(),
857 affected_paths: Vec::new(),
858 },
859 MigrationStep {
860 step: 2,
861 description: "`TryFrom<...>` mit der gefundenen Validierungslogik implementieren."
862 .to_string(),
863 affected_paths: Vec::new(),
864 },
865 MigrationStep {
866 step: 3,
867 description: "Betroffene Signaturen schrittweise auf den Newtype umstellen."
868 .to_string(),
869 affected_paths: affected_paths.clone(),
870 },
871 MigrationStep {
872 step: 4,
873 description: "Call-Sites anpassen.".to_string(),
874 affected_paths,
875 },
876 ],
877 related_findings: Vec::new(),
878 }
879 }
880}
881
882fn primitive_type_name(ty: &syn::Type) -> Option<String> {
889 const NUMERIC: &[&str] = &[
890 "u8", "u16", "u32", "u64", "usize", "i8", "i16", "i32", "i64", "isize", "f32", "f64",
891 ];
892 match ty {
893 syn::Type::Path(type_path) if type_path.qself.is_none() => {
894 let segment = type_path.path.segments.last()?;
895 if !matches!(segment.arguments, syn::PathArguments::None) {
896 return None;
897 }
898 let name = segment.ident.to_string();
899 if NUMERIC.contains(&name.as_str()) || name == "String" {
900 Some(name)
901 } else {
902 None
903 }
904 }
905 syn::Type::Reference(type_ref) => match &*type_ref.elem {
906 syn::Type::Path(type_path) if type_path.qself.is_none() => {
907 let segment = type_path.path.segments.last()?;
908 if matches!(segment.arguments, syn::PathArguments::None) && segment.ident == "str" {
909 Some("&str".to_string())
910 } else {
911 None
912 }
913 }
914 _ => None,
915 },
916 _ => None,
917 }
918}
919
920struct PrimitiveDomainValueVisitor<'a> {
923 file: &'a Path,
924 self_type: Option<String>,
925 facts: Vec<SignatureParamFact>,
926}
927
928macro_rules! visit_item_impl_with_self_type {
931 () => {
932 fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
933 use quote::ToTokens;
934 let previous = self
935 .self_type
936 .replace(node.self_ty.to_token_stream().to_string());
937 syn::visit::visit_item_impl(self, node);
938 self.self_type = previous;
939 }
940 };
941}
942
943macro_rules! visit_pub_fns_via_record_fn {
948 () => {
949 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
950 if matches!(node.vis, syn::Visibility::Public(_)) {
951 self.record_fn(&node.sig.ident.to_string(), &node.sig, &node.block);
952 }
953 syn::visit::visit_item_fn(self, node);
954 }
955
956 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
957 if matches!(node.vis, syn::Visibility::Public(_)) {
958 self.record_fn(&node.sig.ident.to_string(), &node.sig, &node.block);
959 }
960 syn::visit::visit_impl_item_fn(self, node);
961 }
962 };
963}
964
965impl PrimitiveDomainValueVisitor<'_> {
966 fn record_fn(&mut self, name: &str, sig: &syn::Signature, block: &syn::Block) {
967 let item_path = qualified_item_path(self.self_type.as_deref(), name);
968 for input in &sig.inputs {
969 let Some((param, ty)) = typed_ident_arg(input) else {
970 continue;
971 };
972 let Some(type_name) = primitive_type_name(ty) else {
973 continue;
974 };
975 let has_guard = body_has_validation_guard_for(block, ¶m);
976 self.facts.push(SignatureParamFact {
977 file: self.file.to_path_buf(),
978 item_path: item_path.clone(),
979 param,
980 type_name,
981 has_guard,
982 });
983 }
984 }
985}
986
987impl<'ast> Visit<'ast> for PrimitiveDomainValueVisitor<'_> {
988 visit_pub_fns_via_record_fn!();
989 visit_item_impl_with_self_type!();
990}
991
992fn expr_references_ident(expr: &syn::Expr, ident: &str) -> bool {
996 struct Finder<'a> {
997 ident: &'a str,
998 found: bool,
999 }
1000 impl<'ast> Visit<'ast> for Finder<'_> {
1001 fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
1002 if node.path.is_ident(self.ident) {
1003 self.found = true;
1004 }
1005 syn::visit::visit_expr_path(self, node);
1006 }
1007 }
1008 let mut finder = Finder {
1009 ident,
1010 found: false,
1011 };
1012 finder.visit_expr(expr);
1013 finder.found
1014}
1015
1016fn tokens_reference_ident(tokens: &proc_macro2::TokenStream, ident: &str) -> bool {
1020 tokens.clone().into_iter().any(|tree| match tree {
1021 proc_macro2::TokenTree::Ident(node) => node == ident,
1022 proc_macro2::TokenTree::Group(group) => tokens_reference_ident(&group.stream(), ident),
1023 _ => false,
1024 })
1025}
1026
1027fn block_leads_to_error_path(block: &syn::Block) -> bool {
1031 struct Finder {
1032 found: bool,
1033 }
1034 impl<'ast> Visit<'ast> for Finder {
1035 fn visit_expr_return(&mut self, node: &'ast syn::ExprReturn) {
1036 if node.expr.as_deref().is_some_and(is_err_call) {
1037 self.found = true;
1038 }
1039 syn::visit::visit_expr_return(self, node);
1040 }
1041
1042 fn visit_macro(&mut self, node: &'ast syn::Macro) {
1043 if node.path.is_ident("panic") {
1044 self.found = true;
1045 }
1046 syn::visit::visit_macro(self, node);
1047 }
1048 }
1049 let mut finder = Finder { found: false };
1050 finder.visit_block(block);
1051 finder.found
1052}
1053
1054fn is_err_call(expr: &syn::Expr) -> bool {
1057 match expr {
1058 syn::Expr::Call(call) => matches!(
1059 call.func.as_ref(),
1060 syn::Expr::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "Err")
1061 ),
1062 _ => false,
1063 }
1064}
1065
1066fn body_has_validation_guard_for(block: &syn::Block, param: &str) -> bool {
1069 struct GuardVisitor<'a> {
1070 param: &'a str,
1071 found: bool,
1072 }
1073 impl<'ast> Visit<'ast> for GuardVisitor<'_> {
1074 fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
1075 if expr_references_ident(&node.cond, self.param)
1076 && block_leads_to_error_path(&node.then_branch)
1077 {
1078 self.found = true;
1079 }
1080 syn::visit::visit_expr_if(self, node);
1081 }
1082
1083 fn visit_macro(&mut self, node: &'ast syn::Macro) {
1084 if node.path.is_ident("assert") && tokens_reference_ident(&node.tokens, self.param) {
1085 self.found = true;
1086 }
1087 syn::visit::visit_macro(self, node);
1088 }
1089 }
1090 let mut visitor = GuardVisitor {
1091 param,
1092 found: false,
1093 };
1094 visitor.visit_block(block);
1095 visitor.found
1096}
1097
1098fn boolean_state_cluster_candidates(
1131 workspace: &Workspace,
1132 clippy_hits: &[ClippyBoolParamsHit],
1133) -> Vec<PatternCandidate> {
1134 let mut candidates = Vec::new();
1135 for krate in &workspace.crates {
1136 for_each_parsed_source(krate, |file, ast| {
1137 let mut visitor = BooleanStateClusterVisitor {
1138 file,
1139 self_type: None,
1140 facts: Vec::new(),
1141 };
1142 visitor.visit_file(ast);
1143 for fact in visitor.facts {
1144 let mut candidate = build_boolean_state_cluster_candidate(krate, &fact);
1145 if let Some(hit) = clippy_hits
1146 .iter()
1147 .find(|hit| clippy_hit_matches_fact(&workspace.root, hit, &fact))
1148 {
1149 candidate.evidence.additional.push(Evidence {
1150 description: format!(
1151 "`clippy::fn_params_excessive_bools` independently flagged \
1152 `{}`'s parameter list (lines {}-{}), corroborating this from a \
1153 separate tool.",
1154 fact.item_path, hit.line_start, hit.line_end
1155 ),
1156 locations: vec![EvidenceLocation::new(
1157 fact.file.clone(),
1158 fact.item_path.clone(),
1159 )],
1160 });
1161 }
1162 candidates.push(candidate);
1163 }
1164 });
1165 }
1166 candidates
1167}
1168
1169fn clippy_hit_matches_fact(
1178 workspace_root: &Path,
1179 hit: &ClippyBoolParamsHit,
1180 fact: &BoolClusterFact,
1181) -> bool {
1182 let fact_relative = fact.file.strip_prefix(workspace_root).unwrap_or(&fact.file);
1183 let hit_normalized: PathBuf = hit
1184 .file
1185 .components()
1186 .filter(|component| !matches!(component, std::path::Component::CurDir))
1187 .collect();
1188 fact_relative == hit_normalized
1189 && fact.line_start <= hit.line_end
1190 && hit.line_start <= fact.line_end
1191}
1192
1193struct BoolClusterFact {
1196 file: PathBuf,
1197 item_path: String,
1198 bool_params: BTreeSet<String>,
1199 combo_hits: Vec<String>,
1200 line_start: usize,
1201 line_end: usize,
1202}
1203
1204fn build_boolean_state_cluster_candidate(
1205 krate: &CrateInfo,
1206 fact: &BoolClusterFact,
1207) -> PatternCandidate {
1208 let scope = crate_scope(krate, vec![fact.item_path.clone()]);
1209
1210 let location = EvidenceLocation::new(fact.file.clone(), fact.item_path.clone());
1211
1212 let bool_params: Vec<&String> = fact.bool_params.iter().collect();
1213 let primary = Evidence {
1214 description: format!(
1215 "`{}` has {} `bool`-typed parameters: {}.",
1216 fact.item_path,
1217 fact.bool_params.len(),
1218 bool_params
1219 .iter()
1220 .map(|name| name.as_str())
1221 .collect::<Vec<_>>()
1222 .join(", ")
1223 ),
1224 locations: vec![location.clone()],
1225 };
1226 let independent = Evidence {
1227 description: format!(
1228 "The function body combines at least two of these bool parameters together in a \
1229 condition, e.g. `{}`.",
1230 fact.combo_hits.join("`, `")
1231 ),
1232 locations: vec![location],
1233 };
1234
1235 let evidence_identities: Vec<String> = std::iter::once(fact.item_path.clone())
1236 .chain(fact.bool_params.iter().cloned())
1237 .chain(fact.combo_hits.iter().cloned())
1238 .collect();
1239 pattern_candidate! {
1240 pattern: RustPattern::OptionsStruct,
1241 scope,
1242 evidence_identities,
1243 {
1244 evidence: CorroboratedEvidence::new(primary, independent),
1245 preconditions: vec![Precondition {
1246 description: format!(
1247 "`{}` nimmt mehrere Bool-Parameter entgegen und prüft mindestens eine \
1248 Kombination davon gemeinsam im Funktionskörper.",
1249 fact.item_path
1250 ),
1251 }],
1252 contraindications: vec![
1253 Contraindication {
1254 description: "Wenige, klar benannte, unabhängig verwendete Bool-Flags können \
1255 lesbarer sein als ein zusätzlicher Enum-/Options-Typ."
1256 .to_string(),
1257 },
1258 Contraindication {
1259 description: "Wenn die Kombinationsprüfung nur eine einmalige \
1260 Eingabevalidierung ist (kein wiederholtes Muster), kann ein zusätzlicher Typ \
1261 Overkill sein."
1262 .to_string(),
1263 },
1264 ],
1265 migration: vec![
1266 MigrationStep {
1267 step: 1,
1268 description: "Gültige Optionen/Zustände benennen (Options-Struct vs. \
1269 Zustands-Enum, je nach Anzahl gültiger Kombinationen)."
1270 .to_string(),
1271 affected_paths: Vec::new(),
1272 },
1273 MigrationStep {
1274 step: 2,
1275 description: "Den gewählten Typ definieren.".to_string(),
1276 affected_paths: Vec::new(),
1277 },
1278 MigrationStep {
1279 step: 3,
1280 description: "Konstruktor-/Funktionsparameterliste ersetzen.".to_string(),
1281 affected_paths: vec![fact.file.clone()],
1282 },
1283 MigrationStep {
1284 step: 4,
1285 description: "Call-Sites aktualisieren.".to_string(),
1286 affected_paths: vec![fact.file.clone()],
1287 },
1288 ],
1289 related_findings: Vec::new(),
1290 }
1291 }
1292}
1293
1294fn is_bool_type(ty: &syn::Type) -> bool {
1297 matches!(
1298 ty,
1299 syn::Type::Path(type_path)
1300 if type_path.qself.is_none()
1301 && type_path.path.segments.last().is_some_and(|segment| {
1302 segment.ident == "bool" && matches!(segment.arguments, syn::PathArguments::None)
1303 })
1304 )
1305}
1306
1307struct BooleanStateClusterVisitor<'a> {
1310 file: &'a Path,
1311 self_type: Option<String>,
1312 facts: Vec<BoolClusterFact>,
1313}
1314
1315impl BooleanStateClusterVisitor<'_> {
1316 fn record_fn(
1317 &mut self,
1318 name: &str,
1319 sig: &syn::Signature,
1320 block: &syn::Block,
1321 span: proc_macro2::Span,
1322 ) {
1323 let item_path = qualified_item_path(self.self_type.as_deref(), name);
1324 let bool_params: BTreeSet<String> = sig
1325 .inputs
1326 .iter()
1327 .filter_map(|input| {
1328 let (name, ty) = typed_ident_arg(input)?;
1329 is_bool_type(ty).then_some(name)
1330 })
1331 .collect();
1332 if bool_params.len() < 3 {
1333 return;
1334 }
1335 let combo_hits = body_boolean_combo_hits(block, &bool_params);
1336 if combo_hits.is_empty() {
1337 return;
1338 }
1339 self.facts.push(BoolClusterFact {
1340 file: self.file.to_path_buf(),
1341 item_path,
1342 bool_params,
1343 combo_hits,
1344 line_start: span.start().line,
1345 line_end: span.end().line,
1346 });
1347 }
1348}
1349
1350impl<'ast> Visit<'ast> for BooleanStateClusterVisitor<'_> {
1351 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
1352 self.record_fn(
1353 &node.sig.ident.to_string(),
1354 &node.sig,
1355 &node.block,
1356 node.span(),
1357 );
1358 syn::visit::visit_item_fn(self, node);
1359 }
1360
1361 visit_item_impl_with_self_type!();
1362
1363 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
1364 self.record_fn(
1365 &node.sig.ident.to_string(),
1366 &node.sig,
1367 &node.block,
1368 node.span(),
1369 );
1370 syn::visit::visit_impl_item_fn(self, node);
1371 }
1372}
1373
1374fn referenced_params_in_expr(expr: &syn::Expr, params: &BTreeSet<String>) -> BTreeSet<String> {
1377 struct Collector<'a> {
1378 params: &'a BTreeSet<String>,
1379 found: BTreeSet<String>,
1380 }
1381 impl<'ast> Visit<'ast> for Collector<'_> {
1382 fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
1383 if let Some(ident) = node.path.get_ident() {
1384 let name = ident.to_string();
1385 if self.params.contains(&name) {
1386 self.found.insert(name);
1387 }
1388 }
1389 syn::visit::visit_expr_path(self, node);
1390 }
1391 }
1392 let mut collector = Collector {
1393 params,
1394 found: BTreeSet::new(),
1395 };
1396 collector.visit_expr(expr);
1397 collector.found
1398}
1399
1400fn body_boolean_combo_hits(block: &syn::Block, bool_params: &BTreeSet<String>) -> Vec<String> {
1404 use quote::ToTokens;
1405
1406 struct ComboVisitor<'a> {
1407 bool_params: &'a BTreeSet<String>,
1408 hits: Vec<String>,
1409 }
1410 impl<'ast> Visit<'ast> for ComboVisitor<'_> {
1411 fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
1412 if referenced_params_in_expr(&node.cond, self.bool_params).len() >= 2 {
1413 self.hits.push(node.cond.to_token_stream().to_string());
1414 }
1415 syn::visit::visit_expr_if(self, node);
1416 }
1417
1418 fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
1419 if referenced_params_in_expr(&node.expr, self.bool_params).len() >= 2 {
1420 self.hits.push(node.expr.to_token_stream().to_string());
1421 }
1422 syn::visit::visit_expr_match(self, node);
1423 }
1424 }
1425 let mut visitor = ComboVisitor {
1426 bool_params,
1427 hits: Vec::new(),
1428 };
1429 visitor.visit_block(block);
1430 visitor.hits
1431}
1432
1433fn public_invariant_bypass_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
1456 let mut candidates = Vec::new();
1457 for krate in &workspace.crates {
1458 let mut structs: BTreeMap<String, PubStructFact> = BTreeMap::new();
1459 for_each_parsed_source(krate, |file, ast| {
1460 let mut visitor = PubStructVisitor {
1461 file,
1462 structs: BTreeMap::new(),
1463 };
1464 visitor.visit_file(ast);
1465 structs.extend(visitor.structs);
1466 });
1467 if structs.is_empty() {
1468 continue;
1469 }
1470
1471 let mut constructor_hits: BTreeMap<String, Vec<ConstructorFact>> = BTreeMap::new();
1472 for_each_parsed_source(krate, |file, ast| {
1473 let mut visitor = ConstructorVisitor {
1474 file,
1475 self_type: None,
1476 structs: &structs,
1477 hits: BTreeMap::new(),
1478 };
1479 visitor.visit_file(ast);
1480 for (name, mut facts) in visitor.hits {
1481 constructor_hits.entry(name).or_default().append(&mut facts);
1482 }
1483 });
1484
1485 for (name, fact) in &structs {
1486 let Some(ctor_facts) = constructor_hits.get(name) else {
1487 continue;
1488 };
1489 if ctor_facts.is_empty() {
1490 continue;
1491 }
1492 candidates.push(build_public_invariant_bypass_candidate(
1493 krate, fact, ctor_facts,
1494 ));
1495 }
1496 }
1497 candidates
1498}
1499
1500struct PubStructFact {
1503 file: PathBuf,
1504 name: String,
1505 fields: BTreeSet<String>,
1506}
1507
1508fn has_non_exhaustive_attr(attrs: &[syn::Attribute]) -> bool {
1510 attrs
1511 .iter()
1512 .any(|attr| attr.path().is_ident("non_exhaustive"))
1513}
1514
1515struct PubStructVisitor<'a> {
1517 file: &'a Path,
1518 structs: BTreeMap<String, PubStructFact>,
1519}
1520
1521impl<'ast> Visit<'ast> for PubStructVisitor<'_> {
1522 fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
1523 if matches!(node.vis, syn::Visibility::Public(_)) && !has_non_exhaustive_attr(&node.attrs) {
1524 let fields: BTreeSet<String> = node
1525 .fields
1526 .iter()
1527 .filter(|field| matches!(field.vis, syn::Visibility::Public(_)))
1528 .filter_map(|field| field.ident.as_ref().map(ToString::to_string))
1529 .collect();
1530 if fields.len() >= 2 {
1531 let name = node.ident.to_string();
1532 self.structs.insert(
1533 name.clone(),
1534 PubStructFact {
1535 file: self.file.to_path_buf(),
1536 name,
1537 fields,
1538 },
1539 );
1540 }
1541 }
1542 syn::visit::visit_item_struct(self, node);
1543 }
1544}
1545
1546struct ConstructorFact {
1549 file: PathBuf,
1550 item_path: String,
1551 hits: Vec<String>,
1552}
1553
1554struct ConstructorVisitor<'a> {
1558 file: &'a Path,
1559 self_type: Option<String>,
1560 structs: &'a BTreeMap<String, PubStructFact>,
1561 hits: BTreeMap<String, Vec<ConstructorFact>>,
1562}
1563
1564impl ConstructorVisitor<'_> {
1565 fn record_fn(&mut self, name: &str, sig: &syn::Signature, block: &syn::Block) {
1566 let syn::ReturnType::Type(_, ty) = &sig.output else {
1567 return;
1568 };
1569 let Some(struct_name) = resolved_struct_name(ty, self.self_type.as_deref()) else {
1570 return;
1571 };
1572 let Some(fact) = self.structs.get(&struct_name) else {
1573 return;
1574 };
1575 let param_names: BTreeSet<String> = sig
1576 .inputs
1577 .iter()
1578 .filter_map(|input| typed_ident_arg(input).map(|(name, _)| name))
1579 .collect();
1580 let matching_params: BTreeSet<String> =
1581 param_names.intersection(&fact.fields).cloned().collect();
1582 if matching_params.len() < 2 {
1583 return;
1584 }
1585 let hits = constructor_combo_hits(block, &matching_params);
1586 if hits.is_empty() {
1587 return;
1588 }
1589 let item_path = qualified_item_path(self.self_type.as_deref(), name);
1590 self.hits
1591 .entry(struct_name)
1592 .or_default()
1593 .push(ConstructorFact {
1594 file: self.file.to_path_buf(),
1595 item_path,
1596 hits,
1597 });
1598 }
1599}
1600
1601impl<'ast> Visit<'ast> for ConstructorVisitor<'_> {
1602 visit_pub_fns_via_record_fn!();
1603 visit_item_impl_with_self_type!();
1604}
1605
1606fn resolved_struct_name(ty: &syn::Type, self_type: Option<&str>) -> Option<String> {
1611 let syn::Type::Path(type_path) = ty else {
1612 return None;
1613 };
1614 let segment = type_path.path.segments.last()?;
1615 let name = segment.ident.to_string();
1616 if name == "Self" {
1617 return self_type.map(str::to_string);
1618 }
1619 if name == "Result"
1620 && let syn::PathArguments::AngleBracketed(generics) = &segment.arguments
1621 && let Some(syn::GenericArgument::Type(inner)) = generics.args.first()
1622 {
1623 return resolved_struct_name(inner, self_type);
1624 }
1625 Some(name)
1626}
1627
1628fn constructor_combo_hits(block: &syn::Block, matching_params: &BTreeSet<String>) -> Vec<String> {
1632 use quote::ToTokens;
1633
1634 struct ComboVisitor<'a> {
1635 matching_params: &'a BTreeSet<String>,
1636 hits: Vec<String>,
1637 }
1638 impl<'ast> Visit<'ast> for ComboVisitor<'_> {
1639 fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
1640 if referenced_params_in_expr(&node.cond, self.matching_params).len() >= 2
1641 && block_leads_to_error_path(&node.then_branch)
1642 {
1643 self.hits.push(node.cond.to_token_stream().to_string());
1644 }
1645 syn::visit::visit_expr_if(self, node);
1646 }
1647
1648 fn visit_macro(&mut self, node: &'ast syn::Macro) {
1649 if node.path.is_ident("assert")
1650 && tokens_reference_at_least_two_idents(&node.tokens, self.matching_params)
1651 {
1652 self.hits.push(node.tokens.to_string());
1653 }
1654 syn::visit::visit_macro(self, node);
1655 }
1656 }
1657 let mut visitor = ComboVisitor {
1658 matching_params,
1659 hits: Vec::new(),
1660 };
1661 visitor.visit_block(block);
1662 visitor.hits
1663}
1664
1665fn tokens_reference_at_least_two_idents(
1669 tokens: &proc_macro2::TokenStream,
1670 idents: &BTreeSet<String>,
1671) -> bool {
1672 fn collect(
1673 tokens: proc_macro2::TokenStream,
1674 idents: &BTreeSet<String>,
1675 found: &mut BTreeSet<String>,
1676 ) {
1677 for tree in tokens {
1678 match tree {
1679 proc_macro2::TokenTree::Ident(node) => {
1680 let name = node.to_string();
1681 if idents.contains(&name) {
1682 found.insert(name);
1683 }
1684 }
1685 proc_macro2::TokenTree::Group(group) => collect(group.stream(), idents, found),
1686 _ => {}
1687 }
1688 }
1689 }
1690 let mut found = BTreeSet::new();
1691 collect(tokens.clone(), idents, &mut found);
1692 found.len() >= 2
1693}
1694
1695fn build_public_invariant_bypass_candidate(
1696 krate: &CrateInfo,
1697 fact: &PubStructFact,
1698 ctor_facts: &[ConstructorFact],
1699) -> PatternCandidate {
1700 let scope = crate_scope(krate, vec![fact.name.clone()]);
1701
1702 let primary_locations: Vec<EvidenceLocation> = fact
1703 .fields
1704 .iter()
1705 .map(|field| EvidenceLocation::new(fact.file.clone(), format!("{}::{field}", fact.name)))
1706 .collect();
1707 let field_list: Vec<&str> = fact.fields.iter().map(String::as_str).collect();
1708
1709 let primary = Evidence {
1710 description: format!(
1711 "`pub struct {}` in crate `{}` has {} `pub` field(s) ({}) and carries no \
1712 `#[non_exhaustive]` attribute.",
1713 fact.name,
1714 krate.name,
1715 fact.fields.len(),
1716 field_list.join(", ")
1717 ),
1718 locations: primary_locations,
1719 };
1720
1721 let mut independent_locations: Vec<EvidenceLocation> = ctor_facts
1722 .iter()
1723 .map(|ctor| EvidenceLocation::new(ctor.file.clone(), ctor.item_path.clone()))
1724 .collect();
1725 sort_evidence_locations(&mut independent_locations);
1726
1727 let combo_texts: Vec<&str> = ctor_facts
1728 .iter()
1729 .flat_map(|ctor| ctor.hits.iter())
1730 .map(String::as_str)
1731 .collect();
1732 let independent = Evidence {
1733 description: format!(
1734 "At least one constructor for `{}` already validates a combination of ≥2 of these \
1735 `pub` fields together, e.g. `{}`.",
1736 fact.name,
1737 combo_texts.join("`, `")
1738 ),
1739 locations: independent_locations,
1740 };
1741
1742 let evidence_identities: Vec<String> = std::iter::once(fact.name.clone())
1743 .chain(fact.fields.iter().cloned())
1744 .chain(ctor_facts.iter().map(|ctor| ctor.item_path.clone()))
1745 .collect();
1746 let mut affected_paths: Vec<PathBuf> = std::iter::once(fact.file.clone())
1747 .chain(ctor_facts.iter().map(|ctor| ctor.file.clone()))
1748 .collect();
1749 affected_paths.sort();
1750 affected_paths.dedup();
1751
1752 pattern_candidate! {
1753 pattern: RustPattern::SmartConstructor,
1754 scope,
1755 evidence_identities,
1756 {
1757 evidence: CorroboratedEvidence::new(primary, independent),
1758 preconditions: vec![Precondition {
1759 description: format!(
1760 "`{}` hat mindestens zwei öffentliche Felder und mindestens ein Konstruktor \
1761 validiert bereits eine Kombination davon.",
1762 fact.name
1763 ),
1764 }],
1765 contraindications: vec![
1766 Contraindication {
1767 description: "Wenn der Struct primär als reine Datenhülle ohne Invarianten \
1768 außerhalb des Konstruktors gedacht ist, kann öffentlicher Feldzugriff bewusst \
1769 sein."
1770 .to_string(),
1771 },
1772 Contraindication {
1773 description: "Private Felder erzwingen Getter-/Setter-Boilerplate, was bei \
1774 internen/Test-only-Structs mehr kostet als nützt."
1775 .to_string(),
1776 },
1777 ],
1778 migration: vec![
1779 MigrationStep {
1780 step: 1,
1781 description: "Felder privat machen.".to_string(),
1782 affected_paths: vec![fact.file.clone()],
1783 },
1784 MigrationStep {
1785 step: 2,
1786 description:
1787 "Bestehenden Konstruktor als einzigen Erzeugungsweg belassen/ausbauen."
1788 .to_string(),
1789 affected_paths: Vec::new(),
1790 },
1791 MigrationStep {
1792 step: 3,
1793 description: "Falls Änderungen nach Konstruktion nötig sind, validierte Setter \
1794 statt direkter Feldzuweisung ergänzen."
1795 .to_string(),
1796 affected_paths: Vec::new(),
1797 },
1798 MigrationStep {
1799 step: 4,
1800 description: "Call-Sites, die Struct-Update-Syntax nutzen, anpassen.".to_string(),
1801 affected_paths,
1802 },
1803 ],
1804 related_findings: Vec::new(),
1805 }
1806 }
1807}
1808
1809fn manual_resource_lifecycle_candidates(workspace: &Workspace) -> Vec<PatternCandidate> {
1835 let mut candidates = Vec::new();
1836 for krate in &workspace.crates {
1837 let mut has_drop_impl = false;
1838 let mut hits: Vec<EvidenceLocation> = Vec::new();
1839 for_each_parsed_source(krate, |file, ast| {
1840 if file_has_drop_impl(ast) {
1841 has_drop_impl = true;
1842 }
1843 let mut visitor = ResourceLifecycleVisitor {
1844 file,
1845 self_type: None,
1846 hits: Vec::new(),
1847 };
1848 visitor.visit_file(ast);
1849 hits.append(&mut visitor.hits);
1850 });
1851 if has_drop_impl || hits.is_empty() {
1852 continue;
1853 }
1854 candidates.push(build_manual_resource_lifecycle_candidate(krate, &hits));
1855 }
1856 candidates
1857}
1858
1859const ACQUIRE_CALL_NAMES: &[&str] = &[
1860 "register",
1861 "acquire",
1862 "open",
1863 "lock",
1864 "begin",
1865 "start",
1866 "connect",
1867 "subscribe",
1868];
1869
1870const RELEASE_CALL_NAMES: &[&str] = &[
1871 "unregister",
1872 "release",
1873 "close",
1874 "unlock",
1875 "end",
1876 "stop",
1877 "disconnect",
1878 "unsubscribe",
1879];
1880
1881fn file_has_drop_impl(ast: &syn::File) -> bool {
1884 struct DropFinder {
1885 found: bool,
1886 }
1887 impl<'ast> Visit<'ast> for DropFinder {
1888 fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
1889 if impl_trait_is(node, "Drop") {
1890 self.found = true;
1891 }
1892 syn::visit::visit_item_impl(self, node);
1893 }
1894 }
1895 let mut finder = DropFinder { found: false };
1896 finder.visit_file(ast);
1897 finder.found
1898}
1899
1900fn acquire_and_release_calls(block: &syn::Block) -> (bool, bool) {
1904 struct Finder {
1905 acquire: bool,
1906 release: bool,
1907 }
1908 impl Finder {
1909 fn observe(&mut self, name: &str) {
1910 if ACQUIRE_CALL_NAMES.contains(&name) {
1911 self.acquire = true;
1912 }
1913 if RELEASE_CALL_NAMES.contains(&name) {
1914 self.release = true;
1915 }
1916 }
1917 }
1918 impl<'ast> Visit<'ast> for Finder {
1919 fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
1920 self.observe(&node.method.to_string());
1921 syn::visit::visit_expr_method_call(self, node);
1922 }
1923
1924 fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
1925 if let syn::Expr::Path(path) = node.func.as_ref()
1926 && let Some(segment) = path.path.segments.last()
1927 {
1928 self.observe(&segment.ident.to_string());
1929 }
1930 syn::visit::visit_expr_call(self, node);
1931 }
1932 }
1933 let mut finder = Finder {
1934 acquire: false,
1935 release: false,
1936 };
1937 finder.visit_block(block);
1938 (finder.acquire, finder.release)
1939}
1940
1941struct ResourceLifecycleVisitor<'a> {
1945 file: &'a Path,
1946 self_type: Option<String>,
1947 hits: Vec<EvidenceLocation>,
1948}
1949
1950impl ResourceLifecycleVisitor<'_> {
1951 fn record_fn(&mut self, name: &str, block: &syn::Block) {
1952 let (has_acquire, has_release) = acquire_and_release_calls(block);
1953 if !has_acquire || !has_release {
1954 return;
1955 }
1956 let item_path = qualified_item_path(self.self_type.as_deref(), name);
1957 self.hits
1958 .push(EvidenceLocation::new(self.file.to_path_buf(), item_path));
1959 }
1960}
1961
1962impl<'ast> Visit<'ast> for ResourceLifecycleVisitor<'_> {
1963 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
1964 self.record_fn(&node.sig.ident.to_string(), &node.block);
1965 syn::visit::visit_item_fn(self, node);
1966 }
1967
1968 visit_item_impl_with_self_type!();
1969
1970 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
1971 self.record_fn(&node.sig.ident.to_string(), &node.block);
1972 syn::visit::visit_impl_item_fn(self, node);
1973 }
1974}
1975
1976fn build_manual_resource_lifecycle_candidate(
1977 krate: &CrateInfo,
1978 hits: &[EvidenceLocation],
1979) -> PatternCandidate {
1980 let modules: Vec<String> = hits
1981 .iter()
1982 .filter_map(|hit| hit.item_path.clone())
1983 .collect();
1984 let scope = crate_scope(krate, modules);
1985
1986 let mut primary_locations = hits.to_vec();
1987 sort_evidence_locations(&mut primary_locations);
1988
1989 let primary = Evidence {
1990 description: format!(
1991 "{} function(s) in crate `{}` call both an acquire-shaped operation (e.g. \
1992 `register`/`acquire`/`open`/`lock`/`begin`/`start`/`connect`/`subscribe`) and a \
1993 release-shaped counterpart (e.g. \
1994 `unregister`/`release`/`close`/`unlock`/`end`/`stop`/`disconnect`/`unsubscribe`) by \
1995 call name.",
1996 hits.len(),
1997 krate.name
1998 ),
1999 locations: primary_locations,
2000 };
2001 let independent = Evidence {
2002 description: format!(
2003 "Crate `{}` contains no `impl Drop for ...` block anywhere — no evidence this \
2004 codebase already uses RAII guards as a pattern.",
2005 krate.name
2006 ),
2007 locations: Vec::new(),
2008 };
2009
2010 let evidence_identities: Vec<String> = location_identities(hits);
2011 let mut affected_paths: Vec<PathBuf> = hits.iter().map(|hit| hit.file.clone()).collect();
2012 affected_paths.sort();
2013 affected_paths.dedup();
2014
2015 pattern_candidate! {
2016 pattern: RustPattern::RaiiGuard,
2017 scope,
2018 evidence_identities,
2019 {
2020 evidence: CorroboratedEvidence::new(primary, independent),
2021 preconditions: vec![Precondition {
2022 description: format!(
2023 "Crate `{}` enthält mindestens ein Acquire-/Release-Aufrufpaar innerhalb einer \
2024 Funktion, aber keine `Drop`-Implementierung.",
2025 krate.name
2026 ),
2027 }],
2028 contraindications: vec![
2029 Contraindication {
2030 description: "Diese Heuristik kann nicht belegen, dass Besitz und Lebensdauer \
2031 der Ressource eindeutig an einen einzelnen Guard gebunden werden können — das \
2032 ist Voraussetzung für einen sinnvollen RAII-Guard, nicht nur Namensähnlichkeit."
2033 .to_string(),
2034 },
2035 Contraindication {
2036 description: "Acquire/Release könnten unabhängige, zufällig gleich benannte \
2037 Operationen auf unterschiedlichen Objekten sein."
2038 .to_string(),
2039 },
2040 Contraindication {
2041 description: "Bei seltener, einmaliger Nutzung kann der Boilerplate eines \
2042 eigenen Guard-Typs mehr kosten als eine sorgfältige manuelle Passung."
2043 .to_string(),
2044 },
2045 ],
2046 migration: vec![
2047 MigrationStep {
2048 step: 1,
2049 description: "Ressourcentyp und Lebensdauer-Bindung manuell bestätigen (nicht \
2050 automatisierbar)."
2051 .to_string(),
2052 affected_paths: Vec::new(),
2053 },
2054 MigrationStep {
2055 step: 2,
2056 description: "Guard-Struct mit dem Handle als Feld definieren.".to_string(),
2057 affected_paths: Vec::new(),
2058 },
2059 MigrationStep {
2060 step: 3,
2061 description: "`Drop::drop` mit der Release-Logik implementieren.".to_string(),
2062 affected_paths: Vec::new(),
2063 },
2064 MigrationStep {
2065 step: 4,
2066 description: "Acquire-Stelle so umbauen, dass sie den Guard statt des rohen \
2067 Handles zurückgibt."
2068 .to_string(),
2069 affected_paths,
2070 },
2071 ],
2072 related_findings: Vec::new(),
2073 }
2074 }
2075}
2076
2077#[cfg(test)]
2078mod tests {
2079 use super::*;
2080 use crate::finding::{EvidenceClass, Location, OneBasedLine, Origin, Severity};
2081 use crate::ingest::{SourceFile, SourceKind};
2082 use crate::test_util::TempDir;
2083
2084 fn workspace_with_crate(root: PathBuf, files: Vec<PathBuf>) -> Workspace {
2085 Workspace {
2086 root: root.clone(),
2087 crates: vec![CrateInfo {
2088 name: "fixture".to_string(),
2089 version: "0.1.0".to_string(),
2090 manifest_path: root.join("Cargo.toml"),
2091 root,
2092 source_files: files
2093 .into_iter()
2094 .map(|path| SourceFile {
2095 path,
2096 kind: SourceKind::Authored,
2097 })
2098 .collect(),
2099 entry_points: Vec::new(),
2100 dependencies: Vec::new(),
2101 }],
2102 }
2103 }
2104
2105 fn catch_all_error_finding(file: &Path, item_path: &str, line: usize) -> Finding {
2106 Finding::new(
2107 format!("catch-all-error:{}:{line}:1", file.display()),
2108 crate::rules::slop::CATCH_ALL_ERROR_RULE,
2109 Severity::Warn,
2110 Location {
2111 file: file.to_path_buf(),
2112 line: OneBasedLine::new(line).unwrap(),
2113 item_path: item_path.to_string(),
2114 },
2115 EvidenceClass::DerivedFact,
2116 Origin::Code,
2117 None,
2118 )
2119 }
2120
2121 #[test]
2125 fn two_symptoms_plus_a_typed_error_produce_one_candidate() {
2126 let dir = TempDir::new("pattern-corroborated");
2127 let boundary = dir.join("boundary.rs");
2128 std::fs::write(
2129 &boundary,
2130 "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2131 pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2132 )
2133 .unwrap();
2134 let errors = dir.join("errors.rs");
2135 std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
2136
2137 let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2138 let findings = vec![
2139 catch_all_error_finding(&boundary, "a", 1),
2140 catch_all_error_finding(&boundary, "b", 2),
2141 ];
2142
2143 let candidates = analyze_workspace(&workspace, &findings);
2144 assert_eq!(candidates.len(), 1);
2145 let candidate = &candidates[0];
2146 assert_eq!(candidate.pattern, RustPattern::DomainError);
2147 assert_eq!(candidate.scope.krate, "fixture");
2148 assert_eq!(candidate.related_findings.len(), 2);
2149 assert!(!candidate.evidence.primary.locations.is_empty());
2150 assert!(!candidate.evidence.independent.locations.is_empty());
2151 assert!(candidate.evidence.primary.description.contains('2'));
2152 assert!(!candidate.contraindications.is_empty());
2153 assert!(candidate.migration.len() >= 2);
2154 }
2155
2156 #[test]
2159 fn a_single_finding_is_below_threshold() {
2160 let dir = TempDir::new("pattern-single-finding");
2161 let boundary = dir.join("boundary.rs");
2162 std::fs::write(
2163 &boundary,
2164 "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2165 )
2166 .unwrap();
2167 let errors = dir.join("errors.rs");
2168 std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
2169
2170 let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2171 let findings = vec![catch_all_error_finding(&boundary, "a", 1)];
2172
2173 assert!(analyze_workspace(&workspace, &findings).is_empty());
2174 }
2175
2176 #[test]
2179 fn two_findings_without_a_typed_error_are_not_corroborated() {
2180 let dir = TempDir::new("pattern-uncorroborated");
2181 let boundary = dir.join("boundary.rs");
2182 std::fs::write(
2183 &boundary,
2184 "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2185 pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2186 )
2187 .unwrap();
2188
2189 let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone()]);
2190 let findings = vec![
2191 catch_all_error_finding(&boundary, "a", 1),
2192 catch_all_error_finding(&boundary, "b", 2),
2193 ];
2194
2195 assert!(analyze_workspace(&workspace, &findings).is_empty());
2196 }
2197
2198 #[test]
2201 fn a_manual_error_trait_impl_counts_as_the_independent_signal() {
2202 let dir = TempDir::new("pattern-manual-impl");
2203 let boundary = dir.join("boundary.rs");
2204 std::fs::write(
2205 &boundary,
2206 "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2207 pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2208 )
2209 .unwrap();
2210 let errors = dir.join("errors.rs");
2211 std::fs::write(
2212 &errors,
2213 "struct Oops;\n\
2214 impl std::fmt::Display for Oops { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) } }\n\
2215 impl std::error::Error for Oops {}\n",
2216 )
2217 .unwrap();
2218
2219 let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2220 let findings = vec![
2221 catch_all_error_finding(&boundary, "a", 1),
2222 catch_all_error_finding(&boundary, "b", 2),
2223 ];
2224
2225 assert_eq!(analyze_workspace(&workspace, &findings).len(), 1);
2226 }
2227
2228 #[test]
2231 fn candidate_id_is_deterministic() {
2232 let dir = TempDir::new("pattern-deterministic-id");
2233 let boundary = dir.join("boundary.rs");
2234 std::fs::write(
2235 &boundary,
2236 "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2237 pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2238 )
2239 .unwrap();
2240 let errors = dir.join("errors.rs");
2241 std::fs::write(&errors, "enum FooError { Bad }\n").unwrap();
2242
2243 let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2244 let findings = vec![
2245 catch_all_error_finding(&boundary, "a", 1),
2246 catch_all_error_finding(&boundary, "b", 2),
2247 ];
2248
2249 let first = analyze_workspace(&workspace, &findings);
2250 let second = analyze_workspace(&workspace, &findings);
2251 assert_eq!(first[0].id, second[0].id);
2252 }
2253
2254 #[test]
2259 fn primitive_domain_value_two_signatures_plus_a_guard_produce_one_candidate() {
2260 let dir = TempDir::new("pattern-primitive-corroborated");
2261 let file = dir.join("lib.rs");
2262 std::fs::write(
2263 &file,
2264 "pub fn set_a(threshold: u32) {}\n\
2265 pub fn set_b(threshold: u32) -> Result<(), String> {\n\
2266 \x20 if threshold > 100 {\n\
2267 \x20 return Err(\"too big\".to_string());\n\
2268 \x20 }\n\
2269 \x20 Ok(())\n\
2270 }\n",
2271 )
2272 .unwrap();
2273
2274 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2275 let candidates = analyze_workspace(&workspace, &[]);
2276
2277 assert_eq!(candidates.len(), 1);
2278 let candidate = &candidates[0];
2279 assert_eq!(candidate.pattern, RustPattern::ValidatedNewtype);
2280 assert_eq!(candidate.scope.krate, "fixture");
2281 assert_eq!(candidate.evidence.primary.locations.len(), 2);
2282 assert_eq!(candidate.evidence.independent.locations.len(), 1);
2283 assert_eq!(
2284 candidate.evidence.independent.locations[0]
2285 .item_path
2286 .as_deref(),
2287 Some("set_b")
2288 );
2289 assert!(!candidate.contraindications.is_empty());
2290 assert!(candidate.migration.len() >= 2);
2291 }
2292
2293 #[test]
2296 fn primitive_domain_value_single_signature_is_below_threshold() {
2297 let dir = TempDir::new("pattern-primitive-single");
2298 let file = dir.join("lib.rs");
2299 std::fs::write(
2300 &file,
2301 "pub fn set_a(threshold: u32) -> Result<(), String> {\n\
2302 \x20 if threshold > 100 {\n\
2303 \x20 return Err(\"too big\".to_string());\n\
2304 \x20 }\n\
2305 \x20 Ok(())\n\
2306 }\n",
2307 )
2308 .unwrap();
2309
2310 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2311 assert!(analyze_workspace(&workspace, &[]).is_empty());
2312 }
2313
2314 #[test]
2317 fn primitive_domain_value_without_any_guard_is_not_corroborated() {
2318 let dir = TempDir::new("pattern-primitive-unguarded");
2319 let file = dir.join("lib.rs");
2320 std::fs::write(
2321 &file,
2322 "pub fn set_a(threshold: u32) {}\n\
2323 pub fn set_b(threshold: u32) {}\n",
2324 )
2325 .unwrap();
2326
2327 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2328 assert!(analyze_workspace(&workspace, &[]).is_empty());
2329 }
2330
2331 #[test]
2334 fn boolean_cluster_three_bools_plus_a_combined_condition_produce_one_candidate() {
2335 let dir = TempDir::new("pattern-bool-corroborated");
2336 let file = dir.join("lib.rs");
2337 std::fs::write(
2338 &file,
2339 "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2340 \x20 if verbose && strict {\n\
2341 \x20 do_thing();\n\
2342 \x20 }\n\
2343 \x20 let _ = dry_run;\n\
2344 }\n\
2345 fn do_thing() {}\n",
2346 )
2347 .unwrap();
2348
2349 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2350 let candidates = analyze_workspace(&workspace, &[]);
2351
2352 assert_eq!(candidates.len(), 1);
2353 let candidate = &candidates[0];
2354 assert_eq!(candidate.pattern, RustPattern::OptionsStruct);
2355 assert_eq!(candidate.scope.krate, "fixture");
2356 assert_eq!(candidate.scope.modules, vec!["configure".to_string()]);
2357 assert!(!candidate.contraindications.is_empty());
2358 }
2359
2360 #[test]
2363 fn boolean_cluster_without_a_combined_condition_is_not_corroborated() {
2364 let dir = TempDir::new("pattern-bool-independent-checks");
2365 let file = dir.join("lib.rs");
2366 std::fs::write(
2367 &file,
2368 "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2369 \x20 if verbose {\n\
2370 \x20 do_thing();\n\
2371 \x20 }\n\
2372 \x20 if strict {\n\
2373 \x20 do_thing();\n\
2374 \x20 }\n\
2375 \x20 if dry_run {\n\
2376 \x20 do_thing();\n\
2377 \x20 }\n\
2378 }\n\
2379 fn do_thing() {}\n",
2380 )
2381 .unwrap();
2382
2383 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2384 assert!(analyze_workspace(&workspace, &[]).is_empty());
2385 }
2386
2387 #[test]
2390 fn boolean_cluster_with_only_two_bools_is_below_threshold() {
2391 let dir = TempDir::new("pattern-bool-below-threshold");
2392 let file = dir.join("lib.rs");
2393 std::fs::write(
2394 &file,
2395 "pub fn configure(verbose: bool, strict: bool) {\n\
2396 \x20 if verbose && strict {\n\
2397 \x20 do_thing();\n\
2398 \x20 }\n\
2399 }\n\
2400 fn do_thing() {}\n",
2401 )
2402 .unwrap();
2403
2404 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2405 assert!(analyze_workspace(&workspace, &[]).is_empty());
2406 }
2407
2408 #[test]
2415 fn boolean_cluster_with_matching_clippy_hit_gains_a_third_evidence_entry() {
2416 let dir = TempDir::new("pattern-bool-clippy-corroborated");
2417 let file = dir.join("lib.rs");
2418 std::fs::write(
2419 &file,
2420 "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2421 \x20 if verbose && strict {\n\
2422 \x20 do_thing();\n\
2423 \x20 }\n\
2424 \x20 let _ = dry_run;\n\
2425 }\n\
2426 fn do_thing() {}\n",
2427 )
2428 .unwrap();
2429
2430 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2431
2432 let without_clippy = analyze_workspace(&workspace, &[]);
2435 assert_eq!(without_clippy.len(), 1);
2436 assert!(without_clippy[0].evidence.additional.is_empty());
2437
2438 let clippy_hits = vec![ClippyBoolParamsHit {
2440 file: PathBuf::from("lib.rs"),
2441 line_start: 1,
2442 line_end: 1,
2443 }];
2444 let with_clippy = analyze_workspace_with_clippy(&workspace, &[], &clippy_hits);
2445 assert_eq!(with_clippy.len(), 1);
2446 assert_eq!(with_clippy[0].evidence.additional.len(), 1);
2447 assert!(
2448 with_clippy[0].evidence.additional[0]
2449 .description
2450 .contains("fn_params_excessive_bools")
2451 );
2452 }
2453
2454 #[test]
2461 fn boolean_cluster_clippy_hit_alone_does_not_create_a_candidate() {
2462 let dir = TempDir::new("pattern-bool-clippy-alone");
2463 let file = dir.join("lib.rs");
2464 std::fs::write(
2465 &file,
2466 "pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2467 \x20 if verbose {\n\
2468 \x20 do_thing();\n\
2469 \x20 }\n\
2470 \x20 if strict {\n\
2471 \x20 do_thing();\n\
2472 \x20 }\n\
2473 \x20 if dry_run {\n\
2474 \x20 do_thing();\n\
2475 \x20 }\n\
2476 }\n\
2477 fn do_thing() {}\n",
2478 )
2479 .unwrap();
2480
2481 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2482 let clippy_hits = vec![ClippyBoolParamsHit {
2483 file: PathBuf::from("lib.rs"),
2484 line_start: 1,
2485 line_end: 1,
2486 }];
2487
2488 assert!(analyze_workspace_with_clippy(&workspace, &[], &clippy_hits).is_empty());
2489 }
2490
2491 #[test]
2494 fn public_invariant_bypass_struct_plus_combo_validating_constructor_produce_one_candidate() {
2495 let dir = TempDir::new("pattern-invariant-corroborated");
2496 let file = dir.join("lib.rs");
2497 std::fs::write(
2498 &file,
2499 "pub struct Range {\n\
2500 \x20 pub low: u32,\n\
2501 \x20 pub high: u32,\n\
2502 }\n\
2503 impl Range {\n\
2504 \x20 pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
2505 \x20 if low >= high {\n\
2506 \x20 return Err(\"low must be less than high\".to_string());\n\
2507 \x20 }\n\
2508 \x20 Ok(Self { low, high })\n\
2509 \x20 }\n\
2510 }\n",
2511 )
2512 .unwrap();
2513
2514 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2515 let candidates = analyze_workspace(&workspace, &[]);
2516
2517 assert_eq!(candidates.len(), 1);
2518 let candidate = &candidates[0];
2519 assert_eq!(candidate.pattern, RustPattern::SmartConstructor);
2520 assert_eq!(candidate.scope.krate, "fixture");
2521 assert_eq!(candidate.evidence.primary.locations.len(), 2);
2522 assert!(!candidate.evidence.independent.locations.is_empty());
2523 assert!(!candidate.contraindications.is_empty());
2524 assert!(candidate.migration.len() >= 2);
2525 }
2526
2527 #[test]
2531 fn public_invariant_bypass_non_exhaustive_struct_produces_no_candidate() {
2532 let dir = TempDir::new("pattern-invariant-non-exhaustive");
2533 let file = dir.join("lib.rs");
2534 std::fs::write(
2535 &file,
2536 "#[non_exhaustive]\n\
2537 pub struct Range {\n\
2538 \x20 pub low: u32,\n\
2539 \x20 pub high: u32,\n\
2540 }\n\
2541 impl Range {\n\
2542 \x20 pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
2543 \x20 if low >= high {\n\
2544 \x20 return Err(\"low must be less than high\".to_string());\n\
2545 \x20 }\n\
2546 \x20 Ok(Self { low, high })\n\
2547 \x20 }\n\
2548 }\n",
2549 )
2550 .unwrap();
2551
2552 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2553 assert!(analyze_workspace(&workspace, &[]).is_empty());
2554 }
2555
2556 #[test]
2560 fn public_invariant_bypass_single_field_validation_is_not_corroborated() {
2561 let dir = TempDir::new("pattern-invariant-single-field");
2562 let file = dir.join("lib.rs");
2563 std::fs::write(
2564 &file,
2565 "pub struct Range {\n\
2566 \x20 pub low: u32,\n\
2567 \x20 pub high: u32,\n\
2568 }\n\
2569 impl Range {\n\
2570 \x20 pub fn new(low: u32, high: u32) -> Result<Self, String> {\n\
2571 \x20 if low > 1000 {\n\
2572 \x20 return Err(\"too big\".to_string());\n\
2573 \x20 }\n\
2574 \x20 Ok(Self { low, high })\n\
2575 \x20 }\n\
2576 }\n",
2577 )
2578 .unwrap();
2579
2580 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2581 assert!(analyze_workspace(&workspace, &[]).is_empty());
2582 }
2583
2584 #[test]
2588 fn manual_resource_lifecycle_register_unregister_without_drop_produces_one_candidate() {
2589 let dir = TempDir::new("pattern-resource-corroborated");
2590 let file = dir.join("lib.rs");
2591 std::fs::write(
2592 &file,
2593 "pub fn manage(handle: u32) {\n\
2594 \x20 register(handle);\n\
2595 \x20 unregister(handle);\n\
2596 }\n\
2597 fn register(_handle: u32) {}\n\
2598 fn unregister(_handle: u32) {}\n",
2599 )
2600 .unwrap();
2601
2602 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2603 let candidates = analyze_workspace(&workspace, &[]);
2604
2605 assert_eq!(candidates.len(), 1);
2606 let candidate = &candidates[0];
2607 assert_eq!(candidate.pattern, RustPattern::RaiiGuard);
2608 assert_eq!(candidate.scope.krate, "fixture");
2609 assert!(!candidate.evidence.primary.locations.is_empty());
2610 assert_eq!(candidate.contraindications.len(), 3);
2611 assert!(candidate.migration.len() >= 2);
2612 }
2613
2614 #[test]
2618 fn manual_resource_lifecycle_with_an_existing_drop_impl_is_not_corroborated() {
2619 let dir = TempDir::new("pattern-resource-has-drop");
2620 let file = dir.join("lib.rs");
2621 std::fs::write(
2622 &file,
2623 "pub fn manage(handle: u32) {\n\
2624 \x20 register(handle);\n\
2625 \x20 unregister(handle);\n\
2626 }\n\
2627 fn register(_handle: u32) {}\n\
2628 fn unregister(_handle: u32) {}\n\
2629 struct X;\n\
2630 impl Drop for X {\n\
2631 \x20 fn drop(&mut self) {}\n\
2632 }\n",
2633 )
2634 .unwrap();
2635
2636 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2637 assert!(analyze_workspace(&workspace, &[]).is_empty());
2638 }
2639
2640 #[test]
2643 fn manual_resource_lifecycle_without_a_matching_release_call_produces_no_candidate() {
2644 let dir = TempDir::new("pattern-resource-unmatched");
2645 let file = dir.join("lib.rs");
2646 std::fs::write(
2647 &file,
2648 "pub fn manage(handle: u32) {\n\
2649 \x20 register(handle);\n\
2650 }\n\
2651 fn register(_handle: u32) {}\n",
2652 )
2653 .unwrap();
2654
2655 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2656 assert!(analyze_workspace(&workspace, &[]).is_empty());
2657 }
2658
2659 #[test]
2669 fn stringly_error_boundary_cfg_gated_typed_error_still_corroborates() {
2670 let dir = TempDir::new("pattern-stringly-cfg-gated");
2671 let boundary = dir.join("boundary.rs");
2672 std::fs::write(
2673 &boundary,
2674 "pub fn a() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n\
2675 pub fn b() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }\n",
2676 )
2677 .unwrap();
2678 let errors = dir.join("errors.rs");
2679 std::fs::write(
2680 &errors,
2681 "#[cfg(feature = \"not-enabled-by-default\")]\n\
2682 enum FooError { Bad }\n",
2683 )
2684 .unwrap();
2685
2686 let workspace = workspace_with_crate(dir.to_path_buf(), vec![boundary.clone(), errors]);
2687 let findings = vec![
2688 catch_all_error_finding(&boundary, "a", 1),
2689 catch_all_error_finding(&boundary, "b", 2),
2690 ];
2691
2692 let candidates = analyze_workspace(&workspace, &findings);
2693 assert_eq!(candidates.len(), 1);
2694 assert_eq!(candidates[0].pattern, RustPattern::DomainError);
2695 }
2696
2697 #[test]
2704 fn primitive_domain_value_cfg_gated_guard_still_corroborates() {
2705 let dir = TempDir::new("pattern-primitive-cfg-gated");
2706 let file = dir.join("lib.rs");
2707 std::fs::write(
2708 &file,
2709 "pub fn set_a(threshold: u32) {}\n\
2710 #[cfg(feature = \"not-enabled-by-default\")]\n\
2711 pub fn set_b(threshold: u32) -> Result<(), String> {\n\
2712 \x20 if threshold > 100 {\n\
2713 \x20 return Err(\"too big\".to_string());\n\
2714 \x20 }\n\
2715 \x20 Ok(())\n\
2716 }\n",
2717 )
2718 .unwrap();
2719
2720 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2721 let candidates = analyze_workspace(&workspace, &[]);
2722
2723 assert_eq!(candidates.len(), 1);
2724 assert_eq!(candidates[0].pattern, RustPattern::ValidatedNewtype);
2725 }
2726
2727 #[test]
2736 fn boolean_cluster_macro_generated_function_produces_no_candidate() {
2737 let dir = TempDir::new("pattern-bool-macro-generated");
2738 let file = dir.join("lib.rs");
2739 std::fs::write(
2740 &file,
2741 "macro_rules! configure_impl {\n\
2742 \x20 () => {\n\
2743 \x20 pub fn configure(verbose: bool, strict: bool, dry_run: bool) {\n\
2744 \x20 if verbose && strict {\n\
2745 \x20 do_thing();\n\
2746 \x20 }\n\
2747 \x20 let _ = dry_run;\n\
2748 \x20 }\n\
2749 \x20 };\n\
2750 }\n\
2751 configure_impl!();\n\
2752 fn do_thing() {}\n",
2753 )
2754 .unwrap();
2755
2756 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2757 assert!(analyze_workspace(&workspace, &[]).is_empty());
2758 }
2759
2760 #[test]
2769 fn public_invariant_bypass_derive_macro_constructor_produces_no_candidate() {
2770 let dir = TempDir::new("pattern-invariant-derive-macro");
2771 let file = dir.join("lib.rs");
2772 std::fs::write(
2773 &file,
2774 "#[derive(Builder)]\n\
2775 pub struct Range {\n\
2776 \x20 pub low: u32,\n\
2777 \x20 pub high: u32,\n\
2778 }\n",
2779 )
2780 .unwrap();
2781
2782 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2783 assert!(analyze_workspace(&workspace, &[]).is_empty());
2784 }
2785
2786 #[test]
2798 fn manual_resource_lifecycle_unrelated_types_sharing_call_names_still_fires() {
2799 let dir = TempDir::new("pattern-resource-coincidental-names");
2800 let file = dir.join("lib.rs");
2801 std::fs::write(
2802 &file,
2803 "struct MetricRegistry;\n\
2804 impl MetricRegistry {\n\
2805 \x20 fn register(&self, _id: u32) {}\n\
2806 }\n\
2807 struct ListSubscription;\n\
2808 impl ListSubscription {\n\
2809 \x20 fn unregister(&self) {}\n\
2810 }\n\
2811 pub fn unrelated_operations(id: u32) {\n\
2812 \x20 let registry = MetricRegistry;\n\
2813 \x20 let subscription = ListSubscription;\n\
2814 \x20 registry.register(id);\n\
2815 \x20 subscription.unregister();\n\
2816 }\n",
2817 )
2818 .unwrap();
2819
2820 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2821 let candidates = analyze_workspace(&workspace, &[]);
2822
2823 assert_eq!(candidates.len(), 1);
2824 assert_eq!(candidates[0].pattern, RustPattern::RaiiGuard);
2825 }
2826
2827 #[test]
2837 fn stringly_error_boundary_registry_example_still_triggers_the_rule() {
2838 let example = crate::rule_registry::lookup(STRINGLY_ERROR_BOUNDARY_RULE)
2839 .expect("stringly-error-boundary has a registry entry")
2840 .example
2841 .expect("stringly-error-boundary has a curated example")
2842 .before;
2843
2844 let dir = TempDir::new("pattern-registry-example-stringly");
2845 let file = dir.join("lib.rs");
2846 std::fs::write(&file, example).unwrap();
2847
2848 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file.clone()]);
2849 let findings = vec![
2850 catch_all_error_finding(&file, "fetch_user", 1),
2851 catch_all_error_finding(&file, "fetch_order", 5),
2852 ];
2853
2854 let candidates = analyze_workspace(&workspace, &findings);
2855 assert_eq!(candidates.len(), 1);
2856 assert_eq!(candidates[0].pattern, RustPattern::DomainError);
2857 }
2858
2859 #[test]
2865 fn primitive_domain_value_registry_example_still_triggers_the_rule() {
2866 let example = crate::rule_registry::lookup(PRIMITIVE_DOMAIN_VALUE_RULE)
2867 .expect("primitive-domain-value has a registry entry")
2868 .example
2869 .expect("primitive-domain-value has a curated example")
2870 .before;
2871
2872 let dir = TempDir::new("pattern-registry-example-primitive");
2873 let file = dir.join("lib.rs");
2874 std::fs::write(&file, example).unwrap();
2875
2876 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2877 let candidates = analyze_workspace(&workspace, &[]);
2878 assert_eq!(candidates.len(), 1);
2879 assert_eq!(candidates[0].pattern, RustPattern::ValidatedNewtype);
2880 }
2881
2882 #[test]
2888 fn boolean_state_cluster_registry_example_still_triggers_the_rule() {
2889 let example = crate::rule_registry::lookup(BOOLEAN_STATE_CLUSTER_RULE)
2890 .expect("boolean-state-cluster has a registry entry")
2891 .example
2892 .expect("boolean-state-cluster has a curated example")
2893 .before;
2894
2895 let dir = TempDir::new("pattern-registry-example-boolean");
2896 let file = dir.join("lib.rs");
2897 std::fs::write(&file, example).unwrap();
2898
2899 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2900 let candidates = analyze_workspace(&workspace, &[]);
2901 assert_eq!(candidates.len(), 1);
2902 assert_eq!(candidates[0].pattern, RustPattern::OptionsStruct);
2903 }
2904
2905 #[test]
2911 fn public_invariant_bypass_registry_example_still_triggers_the_rule() {
2912 let example = crate::rule_registry::lookup(PUBLIC_INVARIANT_BYPASS_RULE)
2913 .expect("public-invariant-bypass has a registry entry")
2914 .example
2915 .expect("public-invariant-bypass has a curated example")
2916 .before;
2917
2918 let dir = TempDir::new("pattern-registry-example-invariant");
2919 let file = dir.join("lib.rs");
2920 std::fs::write(&file, example).unwrap();
2921
2922 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2923 let candidates = analyze_workspace(&workspace, &[]);
2924 assert_eq!(candidates.len(), 1);
2925 assert_eq!(candidates[0].pattern, RustPattern::SmartConstructor);
2926 }
2927
2928 #[test]
2935 fn manual_resource_lifecycle_registry_example_still_triggers_the_rule() {
2936 let example = crate::rule_registry::lookup(MANUAL_RESOURCE_LIFECYCLE_RULE)
2937 .expect("manual-resource-lifecycle has a registry entry")
2938 .example
2939 .expect("manual-resource-lifecycle has a curated example")
2940 .before;
2941
2942 let dir = TempDir::new("pattern-registry-example-resource");
2943 let file = dir.join("lib.rs");
2944 std::fs::write(&file, example).unwrap();
2945
2946 let workspace = workspace_with_crate(dir.to_path_buf(), vec![file]);
2947 let candidates = analyze_workspace(&workspace, &[]);
2948 assert_eq!(candidates.len(), 1);
2949 assert_eq!(candidates[0].pattern, RustPattern::RaiiGuard);
2950 }
2951}