Skip to main content

wowlab_tidy/languages/rust/rules/api/
similar_structs.rs

1#[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
65// #t(fn: rust_alloc_in_loop) indexed candidate and evidence sets own normalized field signatures
66// #t(fn: rust_cyclomatic_complexity) tier scoring deliberately evaluates exact, near, and containment evidence together
67fn 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});