Skip to main content

lexicon_api/
diff.rs

1use serde::{Deserialize, Serialize};
2
3use crate::schema::{ApiItem, ApiItemKind, ApiSnapshot, Visibility};
4
5/// A change detail describing what specifically changed about an API item.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub enum ChangeDetail {
8    SignatureChanged { old: String, new: String },
9    VisibilityChanged { old: Visibility, new: Visibility },
10}
11
12/// A changed API item with details about what changed.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ApiChange {
15    pub name: String,
16    pub module_path: Vec<String>,
17    pub kind: ApiItemKind,
18    pub changes: Vec<ChangeDetail>,
19}
20
21/// The breaking level of a change.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum BreakingLevel {
24    Breaking,
25    Dangerous,
26    Additive,
27    Unchanged,
28}
29
30impl std::fmt::Display for BreakingLevel {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            Self::Breaking => write!(f, "BREAKING"),
34            Self::Dangerous => write!(f, "DANGEROUS"),
35            Self::Additive => write!(f, "ADDITIVE"),
36            Self::Unchanged => write!(f, "UNCHANGED"),
37        }
38    }
39}
40
41/// The diff between two API snapshots.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ApiDiff {
44    pub added: Vec<ApiItem>,
45    pub removed: Vec<ApiItem>,
46    pub changed: Vec<ApiChange>,
47}
48
49/// Key used to match items across snapshots.
50type ItemKey = (ApiItemKind, String, Vec<String>);
51
52fn item_key(item: &ApiItem) -> ItemKey {
53    (item.kind.clone(), item.name.clone(), item.module_path.clone())
54}
55
56/// Compare two API snapshots and produce a diff.
57pub fn diff_snapshots(baseline: &ApiSnapshot, current: &ApiSnapshot) -> ApiDiff {
58    let baseline_keys: std::collections::HashMap<ItemKey, &ApiItem> =
59        baseline.items.iter().map(|i| (item_key(i), i)).collect();
60    let current_keys: std::collections::HashMap<ItemKey, &ApiItem> =
61        current.items.iter().map(|i| (item_key(i), i)).collect();
62
63    let mut added = Vec::new();
64    let mut removed = Vec::new();
65    let mut changed = Vec::new();
66
67    // Items only in current -> added
68    for (key, item) in &current_keys {
69        if !baseline_keys.contains_key(key) {
70            added.push((*item).clone());
71        }
72    }
73
74    // Items only in baseline -> removed
75    for (key, item) in &baseline_keys {
76        if !current_keys.contains_key(key) {
77            removed.push((*item).clone());
78        }
79    }
80
81    // Items in both but potentially changed
82    for (key, baseline_item) in &baseline_keys {
83        if let Some(current_item) = current_keys.get(key) {
84            let mut changes = Vec::new();
85
86            if baseline_item.signature != current_item.signature {
87                changes.push(ChangeDetail::SignatureChanged {
88                    old: baseline_item.signature.clone(),
89                    new: current_item.signature.clone(),
90                });
91            }
92
93            if baseline_item.visibility != current_item.visibility {
94                changes.push(ChangeDetail::VisibilityChanged {
95                    old: baseline_item.visibility.clone(),
96                    new: current_item.visibility.clone(),
97                });
98            }
99
100            if !changes.is_empty() {
101                changed.push(ApiChange {
102                    name: baseline_item.name.clone(),
103                    module_path: baseline_item.module_path.clone(),
104                    kind: baseline_item.kind.clone(),
105                    changes,
106                });
107            }
108        }
109    }
110
111    ApiDiff { added, removed, changed }
112}
113
114/// Classify the breaking level of a change.
115pub fn classify_change(change: &ApiChange) -> BreakingLevel {
116    let mut level = BreakingLevel::Unchanged;
117
118    for detail in &change.changes {
119        let detail_level = match detail {
120            ChangeDetail::VisibilityChanged { old, new } => {
121                if is_visibility_narrowed(old, new) {
122                    BreakingLevel::Breaking
123                } else {
124                    BreakingLevel::Additive
125                }
126            }
127            ChangeDetail::SignatureChanged { .. } => BreakingLevel::Dangerous,
128        };
129
130        level = max_breaking(level, detail_level);
131    }
132
133    level
134}
135
136fn visibility_rank(v: &Visibility) -> u8 {
137    match v {
138        Visibility::Public => 3,
139        Visibility::Crate => 2,
140        Visibility::Restricted => 1,
141        Visibility::Private => 0,
142    }
143}
144
145fn is_visibility_narrowed(old: &Visibility, new: &Visibility) -> bool {
146    visibility_rank(new) < visibility_rank(old)
147}
148
149fn max_breaking(a: BreakingLevel, b: BreakingLevel) -> BreakingLevel {
150    fn rank(l: BreakingLevel) -> u8 {
151        match l {
152            BreakingLevel::Unchanged => 0,
153            BreakingLevel::Additive => 1,
154            BreakingLevel::Dangerous => 2,
155            BreakingLevel::Breaking => 3,
156        }
157    }
158    if rank(a) >= rank(b) { a } else { b }
159}
160
161impl ApiDiff {
162    /// Returns true if there are no differences.
163    pub fn is_empty(&self) -> bool {
164        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
165    }
166
167    /// Returns true if any change is breaking.
168    pub fn has_breaking(&self) -> bool {
169        if !self.removed.is_empty() {
170            return true;
171        }
172        self.changed.iter().any(|c| classify_change(c) == BreakingLevel::Breaking)
173    }
174
175    /// Count the number of breaking changes.
176    pub fn breaking_count(&self) -> usize {
177        let removed_count = self.removed.len();
178        let changed_breaking = self.changed.iter()
179            .filter(|c| classify_change(c) == BreakingLevel::Breaking)
180            .count();
181        removed_count + changed_breaking
182    }
183
184    /// Generate a summary string.
185    pub fn summary(&self) -> String {
186        let mut parts = Vec::new();
187        if !self.added.is_empty() {
188            parts.push(format!("{} added", self.added.len()));
189        }
190        if !self.removed.is_empty() {
191            parts.push(format!("{} removed", self.removed.len()));
192        }
193        if !self.changed.is_empty() {
194            parts.push(format!("{} changed", self.changed.len()));
195        }
196        if parts.is_empty() {
197            "No API changes".into()
198        } else {
199            parts.join(", ")
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn make_item(name: &str, sig: &str, vis: Visibility) -> ApiItem {
209        ApiItem {
210            kind: ApiItemKind::Function,
211            name: name.into(),
212            module_path: vec![],
213            signature: sig.into(),
214            visibility: vis,
215            trait_associations: vec![],
216            stability: None,
217            doc_summary: None,
218            span_file: None,
219            span_line: None,
220        }
221    }
222
223    fn make_snapshot(items: Vec<ApiItem>) -> ApiSnapshot {
224        ApiSnapshot {
225            crate_name: "test".into(),
226            version: None,
227            items,
228            extracted_at: "now".into(),
229        }
230    }
231
232    #[test]
233    fn diff_added_items() {
234        let baseline = make_snapshot(vec![
235            make_item("foo", "fn foo()", Visibility::Public),
236        ]);
237        let current = make_snapshot(vec![
238            make_item("foo", "fn foo()", Visibility::Public),
239            make_item("bar", "fn bar()", Visibility::Public),
240        ]);
241        let diff = diff_snapshots(&baseline, &current);
242        assert_eq!(diff.added.len(), 1);
243        assert_eq!(diff.added[0].name, "bar");
244        assert!(diff.removed.is_empty());
245        assert!(diff.changed.is_empty());
246    }
247
248    #[test]
249    fn diff_removed_items() {
250        let baseline = make_snapshot(vec![
251            make_item("foo", "fn foo()", Visibility::Public),
252            make_item("bar", "fn bar()", Visibility::Public),
253        ]);
254        let current = make_snapshot(vec![
255            make_item("foo", "fn foo()", Visibility::Public),
256        ]);
257        let diff = diff_snapshots(&baseline, &current);
258        assert!(diff.added.is_empty());
259        assert_eq!(diff.removed.len(), 1);
260        assert_eq!(diff.removed[0].name, "bar");
261        assert!(diff.has_breaking());
262    }
263
264    #[test]
265    fn diff_changed_signature() {
266        let baseline = make_snapshot(vec![
267            make_item("foo", "fn foo()", Visibility::Public),
268        ]);
269        let current = make_snapshot(vec![
270            make_item("foo", "fn foo(x: i32)", Visibility::Public),
271        ]);
272        let diff = diff_snapshots(&baseline, &current);
273        assert!(diff.added.is_empty());
274        assert!(diff.removed.is_empty());
275        assert_eq!(diff.changed.len(), 1);
276        assert_eq!(diff.changed[0].name, "foo");
277        assert_eq!(classify_change(&diff.changed[0]), BreakingLevel::Dangerous);
278    }
279
280    #[test]
281    fn diff_changed_visibility() {
282        let baseline = make_snapshot(vec![
283            make_item("foo", "fn foo()", Visibility::Public),
284        ]);
285        let current = make_snapshot(vec![
286            make_item("foo", "fn foo()", Visibility::Crate),
287        ]);
288        let diff = diff_snapshots(&baseline, &current);
289        assert_eq!(diff.changed.len(), 1);
290        assert_eq!(classify_change(&diff.changed[0]), BreakingLevel::Breaking);
291        assert!(diff.has_breaking());
292    }
293
294    #[test]
295    fn diff_no_changes() {
296        let baseline = make_snapshot(vec![
297            make_item("foo", "fn foo()", Visibility::Public),
298        ]);
299        let current = make_snapshot(vec![
300            make_item("foo", "fn foo()", Visibility::Public),
301        ]);
302        let diff = diff_snapshots(&baseline, &current);
303        assert!(diff.is_empty());
304        assert!(!diff.has_breaking());
305        assert_eq!(diff.breaking_count(), 0);
306        assert_eq!(diff.summary(), "No API changes");
307    }
308
309    #[test]
310    fn summary_format() {
311        let baseline = make_snapshot(vec![
312            make_item("foo", "fn foo()", Visibility::Public),
313        ]);
314        let current = make_snapshot(vec![
315            make_item("bar", "fn bar()", Visibility::Public),
316        ]);
317        let diff = diff_snapshots(&baseline, &current);
318        let summary = diff.summary();
319        assert!(summary.contains("added"));
320        assert!(summary.contains("removed"));
321    }
322
323    #[test]
324    fn breaking_count_includes_removed_and_breaking_changes() {
325        let baseline = make_snapshot(vec![
326            make_item("foo", "fn foo()", Visibility::Public),
327            make_item("bar", "fn bar()", Visibility::Public),
328        ]);
329        let current = make_snapshot(vec![
330            make_item("bar", "fn bar()", Visibility::Crate),
331        ]);
332        let diff = diff_snapshots(&baseline, &current);
333        // foo removed (breaking) + bar visibility narrowed (breaking) = 2
334        assert_eq!(diff.breaking_count(), 2);
335    }
336}