Skip to main content

layer_conform_core/
deviation.rs

1//! Deviation report: what differs from the closest golden.
2
3use compact_str::CompactString;
4
5use crate::rule::GoldenSelector;
6use crate::similarity::SimilarityScore;
7
8#[derive(Clone, Debug, Default, PartialEq)]
9pub struct Differences {
10    pub missing_calls: Vec<CompactString>,
11    pub extra_calls: Vec<CompactString>,
12    pub missing_imports: Vec<CompactString>,
13    pub extra_imports: Vec<CompactString>,
14}
15
16/// One scored comparison of an actual function against one golden.
17#[derive(Clone, Debug, PartialEq)]
18pub struct GoldenMatch {
19    pub golden: GoldenSelector,
20    pub similarity: SimilarityScore,
21}
22
23/// Result of comparing one function against all goldens of a rule.
24///
25/// `matched_golden` is the highest-scoring golden (= `all_golden_scores[0]`).
26/// `similarity` mirrors `matched_golden.similarity` for ergonomic access.
27#[derive(Clone, Debug, PartialEq)]
28pub struct Deviation {
29    pub rule_id: String,
30    pub file: String,
31    pub symbol: CompactString,
32    pub matched_golden: GoldenSelector,
33    pub all_golden_scores: Vec<GoldenMatch>,
34    pub similarity: SimilarityScore,
35    pub differences: Differences,
36}
37
38/// Pick the best golden out of a non-empty list of scored matches.
39///
40/// Sorts `all_golden_scores` descending by `overall`. Returns `(matched, sorted)`
41/// where `matched` is a clone of the top entry.
42///
43/// # Panics
44///
45/// Panics if `scores` is empty — callers must guarantee at least one golden.
46pub fn pick_best(scores: Vec<GoldenMatch>) -> (GoldenMatch, Vec<GoldenMatch>) {
47    assert!(!scores.is_empty(), "pick_best requires at least one golden");
48    let mut sorted = scores;
49    sorted.sort_by(|a, b| {
50        b.similarity
51            .overall
52            .partial_cmp(&a.similarity.overall)
53            .unwrap_or(std::cmp::Ordering::Equal)
54    });
55    let best = sorted[0].clone();
56    (best, sorted)
57}
58
59/// Both `golden` and `actual` MUST be sorted ascending.
60pub fn diff_sets(golden: &[CompactString], actual: &[CompactString]) -> (Vec<CompactString>, Vec<CompactString>) {
61    // missing = golden - actual, extra = actual - golden
62    let (mut i, mut j) = (0_usize, 0_usize);
63    let (mut missing, mut extra) = (Vec::new(), Vec::new());
64    while i < golden.len() && j < actual.len() {
65        match golden[i].cmp(&actual[j]) {
66            std::cmp::Ordering::Equal => {
67                i += 1;
68                j += 1;
69            }
70            std::cmp::Ordering::Less => {
71                missing.push(golden[i].clone());
72                i += 1;
73            }
74            std::cmp::Ordering::Greater => {
75                extra.push(actual[j].clone());
76                j += 1;
77            }
78        }
79    }
80    while i < golden.len() {
81        missing.push(golden[i].clone());
82        i += 1;
83    }
84    while j < actual.len() {
85        extra.push(actual[j].clone());
86        j += 1;
87    }
88    (missing, extra)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn cs(items: &[&str]) -> Vec<CompactString> {
96        let mut v: Vec<CompactString> = items.iter().map(|s| (*s).into()).collect();
97        v.sort();
98        v
99    }
100
101    #[test]
102    fn identical_sets_have_no_diff() {
103        let g = cs(&["useSWR"]);
104        let a = cs(&["useSWR"]);
105        let (m, e) = diff_sets(&g, &a);
106        assert!(m.is_empty());
107        assert!(e.is_empty());
108    }
109
110    #[test]
111    fn missing_call_detected() {
112        let g = cs(&["useSWR", "axios"]);
113        let a = cs(&["useSWR"]);
114        let (m, e) = diff_sets(&g, &a);
115        assert_eq!(m, cs(&["axios"]));
116        assert!(e.is_empty());
117    }
118
119    #[test]
120    fn extra_call_detected() {
121        let g = cs(&["useSWR"]);
122        let a = cs(&["useSWR", "fetch"]);
123        let (m, e) = diff_sets(&g, &a);
124        assert!(m.is_empty());
125        assert_eq!(e, cs(&["fetch"]));
126    }
127
128    #[test]
129    fn both_missing_and_extra() {
130        let g = cs(&["useSWR"]);
131        let a = cs(&["fetch"]);
132        let (m, e) = diff_sets(&g, &a);
133        assert_eq!(m, cs(&["useSWR"]));
134        assert_eq!(e, cs(&["fetch"]));
135    }
136
137    fn golden_match(file: &str, sym: &str, overall: f64) -> GoldenMatch {
138        GoldenMatch {
139            golden: GoldenSelector { file: file.into(), symbol: sym.into() },
140            similarity: SimilarityScore {
141                overall,
142                shape: overall,
143                calls: overall,
144                imports: overall,
145                signature: overall,
146            },
147        }
148    }
149
150    #[test]
151    fn pick_best_single_golden_returns_it() {
152        let m = golden_match("a.ts", "a", 0.42);
153        let (best, sorted) = pick_best(vec![m.clone()]);
154        assert_eq!(best, m);
155        assert_eq!(sorted, vec![m]);
156    }
157
158    #[test]
159    fn pick_best_picks_higher_overall() {
160        let lo = golden_match("a.ts", "a", 0.3);
161        let hi = golden_match("b.ts", "b", 0.8);
162        let (best, sorted) = pick_best(vec![lo.clone(), hi.clone()]);
163        assert_eq!(best, hi);
164        assert_eq!(sorted, vec![hi, lo]);
165    }
166
167    #[test]
168    fn pick_best_sorts_three_descending() {
169        let a = golden_match("a.ts", "a", 0.3);
170        let b = golden_match("b.ts", "b", 0.8);
171        let c = golden_match("c.ts", "c", 0.5);
172        let (_, sorted) = pick_best(vec![a, b.clone(), c.clone()]);
173        assert_eq!(sorted[0].golden.file, "b.ts");
174        assert_eq!(sorted[1].golden.file, "c.ts");
175        assert_eq!(sorted[2].golden.file, "a.ts");
176    }
177
178    #[test]
179    #[should_panic(expected = "at least one golden")]
180    fn pick_best_panics_on_empty_input() {
181        let _ = pick_best(vec![]);
182    }
183}