wowlab_tidy/languages/rust/rules/api/
similar_structs.rs1#[cfg(test)]
2use googletest::prelude::*;
3use wowlab_types::sim::{FastMap, FastSet};
4
5use crate::{
6 Example, Violation,
7 languages::workspace::{StructRecord, WorkspaceCtx},
8 matches_ignore, violation,
9};
10
11const CANDIDATE_FIELD_ALLOWANCE: usize = 2;
12const PERCENT_DENOMINATOR: usize = 100;
13
14#[rustfmt::skip]
15const EXAMPLES: &[Example] = &[
16 Example {
17 label: "exact twins",
18 code: "struct One { a: u32, b: String, c: bool, d: f64 }\nstruct Two { d: f64, c: bool, b: String, a: u32 }",
19 pass: false,
20 },
21 Example {
22 label: "near twins",
23 code: "struct One { a: u32, b: String, c: bool, d: f64 }\nstruct Two { a: u32, b: String, c: bool, d: f64, e: usize }",
24 pass: false,
25 },
26 Example {
27 label: "containment twins",
28 code: "struct One { a: u32, b: String, c: bool, d: f64 }\nstruct Two { a: u32, b: String, c: bool, d: f64, e: usize, f: usize }",
29 pass: false,
30 },
31 Example {
32 label: "input twin is sanctioned",
33 code: "struct Request { a: u32, b: String, c: bool, d: f64 }\nstruct RequestInput { a: u32, b: String, c: bool, d: f64 }",
34 pass: true,
35 },
36 Example {
37 label: "generic arity differs",
38 code: "struct One<T> { a: u32, b: String, c: bool, d: T }\nstruct Two<T, U> { a: u32, b: String, c: bool, d: T, marker: U }",
39 pass: true,
40 },
41 Example {
42 label: "below threshold",
43 code: "struct One { a: u32, b: String, c: bool }\nstruct Two { a: u32, b: String, c: bool, d: f64 }",
44 pass: true,
45 },
46];
47
48crate::workspace_rule!(
49 similar_structs,
50 "Find exact, near, and containment duplicate named-field structs; full-workspace runs are authoritative.",
51 "Structural duplication often signals a missing shared domain type; indexed candidate generation keeps the check scalable.",
52 Low,
53 params {
54 min_fields: i64 = 4,
55 jaccard_percent: i64 = 80
56 },
57);
58
59#[derive(Clone, Copy)]
60struct Located<'a> {
61 rel: &'a str,
62 record: &'a StructRecord,
63}
64
65fn check_similar_structs(ctx: &WorkspaceCtx<'_>) -> Vec<Violation> {
68 let min_fields = ctx.config.get_usize("rust_similar_structs", &PARAMS[0]);
69 let jaccard_percent = ctx.config.get_usize("rust_similar_structs", &PARAMS[1]);
70 let records: Vec<Located<'_>> = ctx
71 .files
72 .iter()
73 .filter(|file| {
74 !matches_ignore(
75 &file.rel,
76 ctx.config.ignore_patterns("rust_similar_structs"),
77 )
78 })
79 .flat_map(|file| {
80 file.structs.iter().map(|record| Located {
81 rel: &file.rel,
82 record,
83 })
84 })
85 .collect();
86 let mut index = FastMap::<&str, Vec<usize>>::default();
87
88 for (record_index, located) in records.iter().enumerate() {
89 for (name, _) in &located.record.fields {
90 index.entry(name).or_default().push(record_index);
91 }
92 }
93
94 let mut shared = FastMap::<(usize, usize), usize>::default();
95
96 for members in index.values() {
97 for (position, left) in members.iter().enumerate() {
98 for right in members.iter().skip(position + 1) {
99 let pair = if left < right {
100 (*left, *right)
101 } else {
102 (*right, *left)
103 };
104
105 *shared.entry(pair).or_default() += 1;
106 }
107 }
108 }
109
110 let candidate_floor = min_fields.saturating_sub(CANDIDATE_FIELD_ALLOWANCE);
111 let mut violations = Vec::new();
112
113 for ((left_index, right_index), shared_names) in shared {
114 if shared_names < candidate_floor {
115 continue;
116 }
117
118 let Some(&left) = records.get(left_index) else {
119 continue;
120 };
121 let Some(&right) = records.get(right_index) else {
122 continue;
123 };
124
125 if left.record.generics_arity != right.record.generics_arity
126 || sanctioned_input_twins(&left.record.name, &right.record.name)
127 {
128 continue;
129 }
130
131 let left_fields: FastSet<(String, String)> = left.record.fields.iter().cloned().collect();
132 let right_fields: FastSet<(String, String)> = right.record.fields.iter().cloned().collect();
133 let exact = left_fields == right_fields && left.record.name != right.record.name;
134 let intersection = left_fields.intersection(&right_fields).count();
135 let union = left_fields.union(&right_fields).count();
136 let near = left_fields.len() >= min_fields
137 && right_fields.len() >= min_fields
138 && intersection.saturating_mul(PERCENT_DENOMINATOR)
139 >= union.saturating_mul(jaccard_percent);
140 let (small, large) = if left_fields.len() <= right_fields.len() {
141 (&left_fields, &right_fields)
142 } else {
143 (&right_fields, &left_fields)
144 };
145 let containment = small.len() >= min_fields
146 && small.is_subset(large)
147 && large.len().saturating_sub(small.len()) <= CANDIDATE_FIELD_ALLOWANCE
148 && small.len() < large.len();
149 let evidence = if exact {
150 Some(format!("exact: {} identical fields", left_fields.len()))
151 } else if near {
152 Some(format!(
153 "near: {intersection}/{union} field Jaccard overlap"
154 ))
155 } else if containment {
156 Some(format!(
157 "containment: {} of {} fields are shared",
158 small.len(),
159 large.len()
160 ))
161 } else {
162 None
163 };
164 let Some(evidence) = evidence else { continue };
165 let (anchor, counterpart) = later(left, right);
166
167 violations.push(violation(
168 anchor.rel,
169 anchor.record.line,
170 format!(
171 "{evidence}; similar to {}:{}",
172 counterpart.rel, counterpart.record.line
173 ),
174 ));
175 }
176
177 violations
178}
179
180fn sanctioned_input_twins(left: &str, right: &str) -> bool {
181 left.strip_suffix("Input") == Some(right) || right.strip_suffix("Input") == Some(left)
182}
183
184fn later<'a>(left: Located<'a>, right: Located<'a>) -> (Located<'a>, Located<'a>) {
185 if (left.rel, left.record.line) > (right.rel, right.record.line) {
186 (left, right)
187 } else {
188 (right, left)
189 }
190}
191
192crate::tidy_workspace_test!(check_similar_structs, {
193 crate::example_tests!(EXAMPLES, check_similar_structs);
194
195 #[gtest]
196 fn compares_multiple_sources() -> Result<()> {
197 let violations = crate::check_workspace_sources(
198 &[
199 ("a.rs", "struct One { a: u32, b: String, c: bool, d: f64 }"),
200 ("b.rs", "struct Two { a: u32, b: String, c: bool, d: f64 }"),
201 ],
202 check_similar_structs,
203 );
204 verify_eq!(violations.len(), 1)?;
205 verify_eq!(violations[0].rel, "b.rs")?;
206 verify_true!(violations[0].message.contains("a.rs:1"))?;
207
208 Ok(())
209 }
210
211 #[gtest]
212 fn honors_configured_path_ignores() -> Result<()> {
213 let violations = crate::test_support::check_workspace_sources_with_ignore(
214 &[
215 (
216 "crates/example/src/lib.rs",
217 "struct One { a: u32, b: String, c: bool, d: f64 }",
218 ),
219 (
220 "crates/example/src/tests.rs",
221 "struct Two { a: u32, b: String, c: bool, d: f64 }",
222 ),
223 ],
224 "rust_similar_structs",
225 &["**/tests.rs"],
226 check_similar_structs,
227 );
228
229 verify_true!(violations.is_empty())?;
230
231 Ok(())
232 }
233});