public_api/
diff.rs

1//! Contains facilities that allows you diff public APIs between releases and
2//! commits. [`cargo
3//! public-api`](https://github.com/cargo-public-api/cargo-public-api) contains
4//! additional helpers for that.
5
6use crate::{
7    PublicApi,
8    public_item::{PublicItem, PublicItemPath},
9};
10use hashbag::HashBag;
11use std::collections::HashMap;
12
13type ItemsWithPath = HashMap<PublicItemPath, Vec<PublicItem>>;
14
15/// An item has changed in the public API. Two [`PublicItem`]s are considered
16/// the same if their `path` is the same.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct ChangedPublicItem {
19    /// How the item used to look.
20    pub old: PublicItem,
21
22    /// How the item looks now.
23    pub new: PublicItem,
24}
25
26impl ChangedPublicItem {
27    /// See [`PublicItem::grouping_cmp`]
28    #[must_use]
29    pub fn grouping_cmp(&self, other: &Self) -> std::cmp::Ordering {
30        match PublicItem::grouping_cmp(&self.old, &other.old) {
31            std::cmp::Ordering::Equal => PublicItem::grouping_cmp(&self.new, &other.new),
32            ordering => ordering,
33        }
34    }
35}
36
37/// The return value of [`Self::between`]. To quickly get a sense of what it
38/// contains, you can pretty-print it:
39/// ```txt
40/// println!("{:#?}", public_api_diff);
41/// ```
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct PublicApiDiff {
44    /// Items that have been removed from the public API. A MAJOR change, in
45    /// semver terminology. Sorted.
46    pub removed: Vec<PublicItem>,
47
48    /// Items in the public API that has been changed. Generally a MAJOR change,
49    /// but exceptions exist. For example, if the return value of a method is
50    /// changed from `ExplicitType` to `Self` and `Self` is the same as
51    /// `ExplicitType`.
52    pub changed: Vec<ChangedPublicItem>,
53
54    /// Items that have been added to public API. A MINOR change, in semver
55    /// terminology. Sorted.
56    pub added: Vec<PublicItem>,
57}
58
59impl PublicApiDiff {
60    /// Allows you to diff the public API between two arbitrary versions of a
61    /// library, e.g. different releases. The input parameters `old` and `new`
62    /// is the output of two different invocations of
63    /// [`crate::Builder::build`].
64    #[must_use]
65    pub fn between(old: PublicApi, new: PublicApi) -> Self {
66        // We must use a HashBag, because with a HashSet we would lose public
67        // items that happen to have the same representation due to limitations
68        // or bugs
69        let old = old.into_items().collect::<HashBag<_>>();
70        let new = new.into_items().collect::<HashBag<_>>();
71
72        // First figure out what items have been removed and what have been
73        // added. Later we will match added and removed items with the same path
74        // and construct a list of changed items. A changed item is an item with
75        // the same path that has been both removed and added.
76        let all_removed = old.difference(&new);
77        let all_added = new.difference(&old);
78
79        // Convert the data to make it easier to work with
80        let mut removed_paths: ItemsWithPath = bag_to_path_map(all_removed);
81        let mut added_paths: ItemsWithPath = bag_to_path_map(all_added);
82
83        // The result we return from this function will be put in these vectors
84        let mut removed: Vec<PublicItem> = vec![];
85        let mut changed: Vec<ChangedPublicItem> = vec![];
86        let mut added: Vec<PublicItem> = vec![];
87
88        // Figure out all paths of items that are either removed or added. Later
89        // we will match paths that have been both removed and added (i.e.
90        // changed)
91        let mut touched_paths: Vec<PublicItemPath> = vec![];
92        touched_paths.extend::<Vec<_>>(removed_paths.keys().cloned().collect());
93        touched_paths.extend::<Vec<_>>(added_paths.keys().cloned().collect());
94
95        // OK, we are ready to do some actual heavy lifting. Go through all
96        // paths and look for changed items. The remaining items are either
97        // purely removed or purely added.
98        for path in touched_paths {
99            let mut removed_items = removed_paths.remove(&path).unwrap_or_default();
100            let mut added_items = added_paths.remove(&path).unwrap_or_default();
101            loop {
102                match (removed_items.pop(), added_items.pop()) {
103                    (Some(old), Some(new)) => changed.push(ChangedPublicItem { old, new }),
104                    (Some(old), None) => removed.push(old),
105                    (None, Some(new)) => added.push(new),
106                    (None, None) => break,
107                }
108            }
109        }
110
111        // Make output predictable and stable
112        removed.sort_by(PublicItem::grouping_cmp);
113        changed.sort_by(ChangedPublicItem::grouping_cmp);
114        added.sort_by(PublicItem::grouping_cmp);
115
116        Self {
117            removed,
118            changed,
119            added,
120        }
121    }
122
123    /// Check whether the diff is empty
124    #[must_use]
125    pub fn is_empty(&self) -> bool {
126        self.removed.is_empty() && self.changed.is_empty() && self.added.is_empty()
127    }
128}
129
130/// Converts a set (read: bag) of public items into a hash map that maps a given
131/// path to a vec of public items with that path.
132fn bag_to_path_map<'a>(difference: impl Iterator<Item = (&'a PublicItem, usize)>) -> ItemsWithPath {
133    let mut map: ItemsWithPath = HashMap::new();
134    for (item, occurrences) in difference {
135        let items = map.entry(item.sortable_path.clone()).or_default();
136        for _ in 0..occurrences {
137            items.push(item.clone());
138        }
139    }
140    map
141}
142
143#[cfg(test)]
144mod tests {
145    use rustdoc_types::Id;
146
147    use crate::tokens::Token;
148
149    use super::*;
150
151    const DUMMY_ID: Id = Id(1234);
152
153    #[test]
154    fn single_and_only_item_removed() {
155        let old = api([item_with_path("foo")]);
156        let new = api([]);
157
158        let actual = PublicApiDiff::between(old, new);
159        let expected = PublicApiDiff {
160            removed: vec![item_with_path("foo")],
161            changed: vec![],
162            added: vec![],
163        };
164        assert_eq!(actual, expected);
165        assert!(!actual.is_empty());
166    }
167
168    #[test]
169    fn single_and_only_item_added() {
170        let old = api([]);
171        let new = api([item_with_path("foo")]);
172
173        let actual = PublicApiDiff::between(old, new);
174        let expected = PublicApiDiff {
175            removed: vec![],
176            changed: vec![],
177            added: vec![item_with_path("foo")],
178        };
179        assert_eq!(actual, expected);
180        assert!(!actual.is_empty());
181    }
182
183    #[test]
184    fn middle_item_added() {
185        let old = api([item_with_path("1"), item_with_path("3")]);
186        let new = api([
187            item_with_path("1"),
188            item_with_path("2"),
189            item_with_path("3"),
190        ]);
191
192        let actual = PublicApiDiff::between(old, new);
193        let expected = PublicApiDiff {
194            removed: vec![],
195            changed: vec![],
196            added: vec![item_with_path("2")],
197        };
198        assert_eq!(actual, expected);
199        assert!(!actual.is_empty());
200    }
201
202    #[test]
203    fn middle_item_removed() {
204        let old = api([
205            item_with_path("1"),
206            item_with_path("2"),
207            item_with_path("3"),
208        ]);
209        let new = api([item_with_path("1"), item_with_path("3")]);
210
211        let actual = PublicApiDiff::between(old, new);
212        let expected = PublicApiDiff {
213            removed: vec![item_with_path("2")],
214            changed: vec![],
215            added: vec![],
216        };
217        assert_eq!(actual, expected);
218        assert!(!actual.is_empty());
219    }
220
221    #[test]
222    fn many_identical_items() {
223        let old = api([
224            item_with_path("1"),
225            item_with_path("2"),
226            item_with_path("2"),
227            item_with_path("3"),
228            item_with_path("3"),
229            item_with_path("3"),
230            fn_with_param_type(&["a", "b"], "i32"),
231            fn_with_param_type(&["a", "b"], "i32"),
232        ]);
233        let new = api([
234            item_with_path("1"),
235            item_with_path("2"),
236            item_with_path("3"),
237            item_with_path("4"),
238            item_with_path("4"),
239            fn_with_param_type(&["a", "b"], "i64"),
240            fn_with_param_type(&["a", "b"], "i64"),
241        ]);
242
243        let actual = PublicApiDiff::between(old, new);
244        let expected = PublicApiDiff {
245            removed: vec![
246                item_with_path("2"),
247                item_with_path("3"),
248                item_with_path("3"),
249            ],
250            changed: vec![
251                ChangedPublicItem {
252                    old: fn_with_param_type(&["a", "b"], "i32"),
253                    new: fn_with_param_type(&["a", "b"], "i64"),
254                },
255                ChangedPublicItem {
256                    old: fn_with_param_type(&["a", "b"], "i32"),
257                    new: fn_with_param_type(&["a", "b"], "i64"),
258                },
259            ],
260            added: vec![item_with_path("4"), item_with_path("4")],
261        };
262        assert_eq!(actual, expected);
263        assert!(!actual.is_empty());
264    }
265
266    /// Regression test for
267    /// <https://github.com/cargo-public-api/cargo-public-api/issues/50>
268    #[test]
269    fn no_off_by_one_diff_skewing() {
270        let old = api([
271            fn_with_param_type(&["a", "b"], "i8"),
272            fn_with_param_type(&["a", "b"], "i32"),
273            fn_with_param_type(&["a", "b"], "i64"),
274        ]);
275        // Same as `old` but with a new item with the same path added on top.
276        // The diffing algorithm needs to figure out that only one item has been
277        // added, rather than showing that of three items changed.
278        let new = api([
279            fn_with_param_type(&["a", "b"], "u8"), // The only new item!
280            fn_with_param_type(&["a", "b"], "i8"),
281            fn_with_param_type(&["a", "b"], "i32"),
282            fn_with_param_type(&["a", "b"], "i64"),
283        ]);
284        let expected = PublicApiDiff {
285            removed: vec![],
286            changed: vec![],
287            added: vec![fn_with_param_type(&["a", "b"], "u8")],
288        };
289        let actual = PublicApiDiff::between(old, new);
290        assert_eq!(actual, expected);
291        assert!(!actual.is_empty());
292    }
293
294    #[test]
295    fn no_diff_means_empty_diff() {
296        let old = api([item_with_path("foo")]);
297        let new = api([item_with_path("foo")]);
298
299        let actual = PublicApiDiff::between(old, new);
300        let expected = PublicApiDiff {
301            removed: vec![],
302            changed: vec![],
303            added: vec![],
304        };
305        assert_eq!(actual, expected);
306        assert!(actual.is_empty());
307    }
308
309    fn item_with_path(path_str: &str) -> PublicItem {
310        new_public_item(
311            path_str
312                .split("::")
313                .map(std::string::ToString::to_string)
314                .collect(),
315            vec![crate::tokens::Token::identifier(path_str)],
316        )
317    }
318
319    fn api(items: impl IntoIterator<Item = PublicItem>) -> PublicApi {
320        PublicApi {
321            items: items.into_iter().collect(),
322            missing_item_ids: vec![],
323        }
324    }
325
326    fn fn_with_param_type(path_str: &[&str], type_: &str) -> PublicItem {
327        let path: Vec<_> = path_str
328            .iter()
329            .map(std::string::ToString::to_string)
330            .collect();
331
332        // Begin with "pub fn "
333        let mut tokens = vec![q("pub"), w(), k("fn"), w()];
334
335        // Add path e.g. "a::b"
336        tokens.extend(itertools::intersperse(
337            path.iter().cloned().map(Token::identifier),
338            Token::symbol("::"),
339        ));
340
341        // Append function "(x: usize)"
342        tokens.extend(vec![q("("), i("x"), s(":"), w(), t(type_), q(")")]);
343
344        // End result is e.g. "pub fn a::b(x: usize)"
345        new_public_item(path, tokens)
346    }
347
348    fn new_public_item(path: PublicItemPath, tokens: Vec<Token>) -> PublicItem {
349        PublicItem {
350            sortable_path: path,
351            tokens,
352            parent_id: None,
353            id: DUMMY_ID,
354        }
355    }
356
357    fn s(s: &str) -> Token {
358        Token::symbol(s)
359    }
360
361    fn t(s: &str) -> Token {
362        Token::type_(s)
363    }
364
365    fn q(s: &str) -> Token {
366        Token::qualifier(s)
367    }
368
369    fn k(s: &str) -> Token {
370        Token::kind(s)
371    }
372
373    fn i(s: &str) -> Token {
374        Token::identifier(s)
375    }
376
377    fn w() -> Token {
378        Token::Whitespace
379    }
380}