guppy_summaries/
diff.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Compare and diff summaries.
5//!
6//! A diff of two summaries is a list of changes between them.
7//!
8//! The main entry point is `SummaryDiff`, which can be created through the `diff` method on
9//! summaries or through `SummaryDiff::new`.
10
11pub use crate::report::SummaryReport;
12use crate::{PackageInfo, PackageMap, PackageStatus, Summary, SummaryId, SummarySource};
13use ahash::AHashMap;
14use diffus::{edit, Diffable};
15use semver::Version;
16use serde::{ser::SerializeStruct, Serialize};
17use std::{
18    collections::{BTreeMap, BTreeSet},
19    fmt, mem,
20};
21
22/// A diff of two package summaries.
23///
24/// This struct contains information on the packages that were changed, as well as those that were
25/// not.
26///
27/// ## Human-readable reports
28///
29/// The [`report`](SummaryDiff::report) method can be used with `fmt::Display` to generate a
30/// friendly, human-readable report.
31///
32/// ## Machine-readable serialization
33///
34/// A `SummaryDiff` can be serialized through `serde`. The output format is part of the API.
35///
36/// An example of TOML-serialized output:
37///
38/// ```toml
39/// [[target-packages.changed]]
40/// name = "dep"
41/// version = "0.4.3"
42/// crates-io = true
43/// change = "added"
44/// status = "direct"
45/// features = ["std"]
46///
47/// [[target-packages.changed]]
48/// name = "foo"
49/// version = "1.2.3"
50/// workspace-path = "foo"
51/// change = "modified"
52/// new-status = "initial"
53/// added-features = ["feature2"]
54/// removed-features = []
55/// unchanged-features = ["default", "feature1"]
56///
57/// [[target-packages.unchanged]]
58/// name = "no-changes"
59/// version = "1.5.3"
60/// crates-io = true
61/// status = "transitive"
62/// features = ["default"]
63///
64/// [[host-packages.changed]]
65/// name = "dep"
66/// version = "0.4.2"
67/// crates-io = true
68/// change = "removed"
69/// old-status = "direct"
70/// old-features = ["std"]
71/// ```
72#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub struct SummaryDiff<'a> {
75    /// Diff of target packages.
76    pub target_packages: PackageDiff<'a>,
77
78    /// Diff of host packages.
79    pub host_packages: PackageDiff<'a>,
80}
81
82impl<'a> SummaryDiff<'a> {
83    /// Computes a diff between two summaries.
84    pub fn new(old: &'a Summary, new: &'a Summary) -> Self {
85        Self {
86            target_packages: PackageDiff::new(&old.target_packages, &new.target_packages),
87            host_packages: PackageDiff::new(&old.host_packages, &new.host_packages),
88        }
89    }
90
91    /// Returns true if there are any changes in this diff.
92    pub fn is_changed(&self) -> bool {
93        !self.is_unchanged()
94    }
95
96    /// Returns true if there are no changes in this diff.
97    pub fn is_unchanged(&self) -> bool {
98        self.target_packages.is_unchanged() && self.host_packages.is_unchanged()
99    }
100
101    /// Returns a report for this diff.
102    ///
103    /// This report can be used with `fmt::Display`.
104    pub fn report<'b>(&'b self) -> SummaryReport<'a, 'b> {
105        SummaryReport::new(self)
106    }
107}
108
109/// Type alias for list entries in the `PackageDiff::unchanged` map.
110pub type UnchangedInfo<'a> = (&'a Version, &'a SummarySource, &'a PackageInfo);
111
112/// A diff from a particular section of a summary.
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct PackageDiff<'a> {
115    /// Changed packages.
116    pub changed: BTreeMap<&'a SummaryId, SummaryDiffStatus<'a>>,
117
118    /// Unchanged packages, keyed by name.
119    pub unchanged: BTreeMap<&'a str, Vec<UnchangedInfo<'a>>>,
120}
121
122impl<'a> PackageDiff<'a> {
123    /// Constructs a new `PackageDiff` from a pair of `PackageMap` instances.
124    pub fn new(old: &'a PackageMap, new: &'a PackageMap) -> Self {
125        let mut changed = BTreeMap::new();
126        let mut unchanged = BTreeMap::new();
127
128        let mut add_unchanged = |summary_id: &'a SummaryId, info: &'a PackageInfo| {
129            unchanged
130                .entry(summary_id.name.as_str())
131                .or_insert_with(Vec::new)
132                .push((&summary_id.version, &summary_id.source, info));
133        };
134
135        match (*old).diff(new) {
136            edit::Edit::Copy(_) => {
137                // Add all elements to unchanged.
138                for (summary_id, info) in new {
139                    add_unchanged(summary_id, info);
140                }
141            }
142            edit::Edit::Change(diff) => {
143                for (summary_id, diff) in diff {
144                    match diff {
145                        edit::map::Edit::Copy(info) => {
146                            // No changes.
147                            add_unchanged(summary_id, info);
148                        }
149                        edit::map::Edit::Insert(info) => {
150                            // New package.
151                            let status = SummaryDiffStatus::Added { info };
152                            changed.insert(summary_id, status);
153                        }
154                        edit::map::Edit::Remove(old_info) => {
155                            // Removed package.
156                            let status = SummaryDiffStatus::Removed { old_info };
157                            changed.insert(summary_id, status);
158                        }
159                        edit::map::Edit::Change((old_info, new_info)) => {
160                            // The feature set or status changed.
161                            let status =
162                                SummaryDiffStatus::make_changed(None, None, old_info, new_info);
163                            changed.insert(summary_id, status);
164                        }
165                    }
166                }
167            }
168        }
169
170        // Combine lone inserts and removes into changes.
171        Self::combine_insert_remove(&mut changed);
172
173        Self { changed, unchanged }
174    }
175
176    /// Returns true if there are no changes in this diff.
177    pub fn is_unchanged(&self) -> bool {
178        self.changed.is_empty()
179    }
180
181    // ---
182    // Helper methods
183    // ---
184
185    fn combine_insert_remove(changed: &mut BTreeMap<&'a SummaryId, SummaryDiffStatus<'a>>) {
186        let mut combine_statuses: AHashMap<&str, CombineStatus<'_>> =
187            AHashMap::with_capacity(changed.len());
188
189        for (summary_id, status) in &*changed {
190            let entry = combine_statuses
191                .entry(summary_id.name.as_str())
192                .or_insert_with(|| CombineStatus::None);
193            match status {
194                SummaryDiffStatus::Added { .. } => entry.record_added(summary_id),
195                SummaryDiffStatus::Removed { .. } => entry.record_removed(summary_id),
196                SummaryDiffStatus::Modified { .. } => entry.record_changed(),
197            }
198        }
199
200        for status in combine_statuses.values() {
201            if let CombineStatus::Combine { added, removed } = status {
202                let removed_status = changed
203                    .remove(removed)
204                    .expect("removed ID should be present");
205
206                let old_info = match removed_status {
207                    SummaryDiffStatus::Removed { old_info } => old_info,
208                    other => panic!("expected Removed, found {:?}", other),
209                };
210
211                let added_status = changed.get_mut(added).expect("added ID should be present");
212                let new_info = match &*added_status {
213                    SummaryDiffStatus::Added { info } => *info,
214                    other => panic!("expected Added, found {:?}", other),
215                };
216
217                let old_version = if added.version != removed.version {
218                    Some(&removed.version)
219                } else {
220                    None
221                };
222                let old_source = if added.source != removed.source {
223                    Some(&removed.source)
224                } else {
225                    None
226                };
227
228                // Don't need the old value of added_status any more since we've already extracted the value out of it.
229                let _ = mem::replace(
230                    added_status,
231                    SummaryDiffStatus::make_changed(old_version, old_source, old_info, new_info),
232                );
233            }
234        }
235    }
236}
237
238pub(crate) fn changed_sort_key<'a>(
239    summary_id: &'a SummaryId,
240    status: &SummaryDiffStatus<'_>,
241) -> impl Ord + 'a {
242    // The sort order is:
243    // * diff tag (added/modified/removed)
244    // * package status
245    // * summary id
246    // TODO: allow customizing sort order?
247    (status.tag(), status.latest_status(), summary_id)
248}
249
250impl Serialize for PackageDiff<'_> {
251    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
252    where
253        S: serde::Serializer,
254    {
255        #[derive(Serialize)]
256        struct Changed<'a> {
257            // Flatten both fields so that all the details show up in a single map. (This is
258            // required for TOML.)
259            #[serde(flatten)]
260            package: &'a SummaryId,
261            #[serde(flatten)]
262            changes: &'a SummaryDiffStatus<'a>,
263        }
264
265        let mut changed: Vec<Changed> = self
266            .changed
267            .iter()
268            .map(|(package, changes)| Changed { package, changes })
269            .collect();
270        // The sorting ensures the order added -> modified -> removed.
271        changed.sort_by_key(|item| changed_sort_key(item.package, item.changes));
272
273        let mut state = serializer.serialize_struct("PackageDiff", 2)?;
274        state.serialize_field("changed", &changed)?;
275
276        #[derive(Serialize)]
277        struct Unchanged<'a> {
278            // This matches the SummaryId format.
279            name: &'a str,
280            version: &'a Version,
281            #[serde(flatten)]
282            source: &'a SummarySource,
283            #[serde(flatten)]
284            info: &'a PackageInfo,
285        }
286
287        // Trying to print out an empty unchanged can cause a ValueAfterTable issue with the TOML
288        // output.
289        if !self.unchanged.is_empty() {
290            let mut unchanged: Vec<_> = self
291                .unchanged
292                .iter()
293                .flat_map(|(&name, info)| {
294                    info.iter().map(move |(version, source, info)| Unchanged {
295                        name,
296                        version,
297                        source,
298                        info,
299                    })
300                })
301                .collect();
302            // Sort by (name, version, source).
303            unchanged.sort_by_key(|item| (item.name, item.version, item.source));
304            state.serialize_field("unchanged", &unchanged)?;
305        }
306
307        state.end()
308    }
309}
310
311/// The diff status for a particular summary ID and source.
312#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
313#[serde(rename_all = "kebab-case", tag = "change")]
314pub enum SummaryDiffStatus<'a> {
315    /// This package was added.
316    #[serde(rename_all = "kebab-case")]
317    Added {
318        /// The information for this package.
319        #[serde(flatten)]
320        info: &'a PackageInfo,
321    },
322
323    /// This package was removed.
324    #[serde(rename_all = "kebab-case")]
325    Removed {
326        /// The information this package used to have.
327        #[serde(flatten, with = "removed_impl")]
328        old_info: &'a PackageInfo,
329    },
330
331    /// Some details about the package changed:
332    /// * a feature was added or removed
333    /// * the version or source changed.
334    #[serde(rename_all = "kebab-case")]
335    Modified {
336        /// The old version of this package, if the version changed.
337        old_version: Option<&'a Version>,
338
339        /// The old source of this package, if the source changed.
340        old_source: Option<&'a SummarySource>,
341
342        /// The old status of this package, if the status changed.
343        old_status: Option<PackageStatus>,
344
345        /// The current status of this package.
346        new_status: PackageStatus,
347
348        /// The set of features added to the package.
349        added_features: BTreeSet<&'a str>,
350
351        /// The set of features removed from the package.
352        removed_features: BTreeSet<&'a str>,
353
354        /// The set of features which were enabled both in both the old and new summaries.
355        unchanged_features: BTreeSet<&'a str>,
356
357        /// The set of optional dependencies added to the package.
358        #[serde(default)]
359        added_optional_deps: BTreeSet<&'a str>,
360
361        /// The set of optional dependencies removed from the package.
362        #[serde(default)]
363        removed_optional_deps: BTreeSet<&'a str>,
364
365        /// The set of optional dependencies enabled both in both the old and new summaries.
366        #[serde(default)]
367        unchanged_optional_deps: BTreeSet<&'a str>,
368    },
369}
370
371impl<'a> SummaryDiffStatus<'a> {
372    fn make_changed(
373        old_version: Option<&'a Version>,
374        old_source: Option<&'a SummarySource>,
375        old_info: &'a PackageInfo,
376        new_info: &'a PackageInfo,
377    ) -> Self {
378        let old_status = if old_info.status != new_info.status {
379            Some(old_info.status)
380        } else {
381            None
382        };
383
384        let [added_features, removed_features, unchanged_features] =
385            Self::make_changed_diff(&old_info.features, &new_info.features);
386
387        let [added_optional_deps, removed_optional_deps, unchanged_optional_deps] =
388            Self::make_changed_diff(&old_info.optional_deps, &new_info.optional_deps);
389
390        SummaryDiffStatus::Modified {
391            old_version,
392            old_source,
393            old_status,
394            new_status: new_info.status,
395            added_features,
396            removed_features,
397            unchanged_features,
398            added_optional_deps,
399            removed_optional_deps,
400            unchanged_optional_deps,
401        }
402    }
403
404    fn make_changed_diff(
405        old_features: &'a BTreeSet<String>,
406        new_features: &'a BTreeSet<String>,
407    ) -> [BTreeSet<&'a str>; 3] {
408        let mut added_features = BTreeSet::new();
409        let mut removed_features = BTreeSet::new();
410        let mut unchanged_features = BTreeSet::new();
411
412        match old_features.diff(new_features) {
413            edit::Edit::Copy(features) => {
414                unchanged_features.extend(features.iter().map(|feature| feature.as_str()));
415            }
416            edit::Edit::Change(diff) => {
417                for (_, diff) in diff {
418                    match diff {
419                        edit::set::Edit::Copy(feature) => {
420                            unchanged_features.insert(feature.as_str());
421                        }
422                        edit::set::Edit::Insert(feature) => {
423                            added_features.insert(feature.as_str());
424                        }
425                        edit::set::Edit::Remove(feature) => {
426                            removed_features.insert(feature.as_str());
427                        }
428                    }
429                }
430            }
431        }
432
433        [added_features, removed_features, unchanged_features]
434    }
435
436    /// Returns the tag for this status.
437    ///
438    /// The tag is similar to this enum, except it has no associated data.
439    pub fn tag(&self) -> SummaryDiffTag {
440        match self {
441            SummaryDiffStatus::Added { .. } => SummaryDiffTag::Added,
442            SummaryDiffStatus::Removed { .. } => SummaryDiffTag::Removed,
443            SummaryDiffStatus::Modified { .. } => SummaryDiffTag::Modified,
444        }
445    }
446
447    /// Returns the new package status if available, otherwise the old status.
448    pub fn latest_status(&self) -> PackageStatus {
449        match self {
450            SummaryDiffStatus::Added { info } => info.status,
451            SummaryDiffStatus::Removed { old_info } => old_info.status,
452            SummaryDiffStatus::Modified { new_status, .. } => *new_status,
453        }
454    }
455}
456
457mod removed_impl {
458    use super::*;
459    use serde::Serializer;
460
461    pub fn serialize<S>(item: &PackageInfo, serializer: S) -> Result<S::Ok, S::Error>
462    where
463        S: Serializer,
464    {
465        #[derive(Serialize)]
466        #[serde(rename_all = "kebab-case")]
467        struct OldPackageInfo<'a> {
468            old_status: &'a PackageStatus,
469            old_features: &'a BTreeSet<String>,
470        }
471
472        let old_info = OldPackageInfo {
473            old_status: &item.status,
474            old_features: &item.features,
475        };
476
477        old_info.serialize(serializer)
478    }
479}
480
481/// A tag representing `SummaryDiffStatus` except with no data attached.
482///
483/// The order is significant: it is what's used as the default order in reports.
484#[derive(Copy, Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
485pub enum SummaryDiffTag {
486    /// This package was added.
487    Added,
488
489    /// This package was modified.
490    Modified,
491
492    /// This package was removed.
493    Removed,
494}
495
496impl fmt::Display for SummaryDiffTag {
497    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
498        match self {
499            SummaryDiffTag::Added => write!(f, "A"),
500            SummaryDiffTag::Modified => write!(f, "M"),
501            SummaryDiffTag::Removed => write!(f, "R"),
502        }
503    }
504}
505
506impl<'a> Diffable<'a> for PackageInfo {
507    type Diff = (&'a PackageInfo, &'a PackageInfo);
508
509    fn diff(&'a self, other: &'a Self) -> edit::Edit<'a, Self> {
510        if self == other {
511            edit::Edit::Copy(self)
512        } else {
513            edit::Edit::Change((self, other))
514        }
515    }
516}
517
518impl<'a> Diffable<'a> for PackageStatus {
519    type Diff = (&'a PackageStatus, &'a PackageStatus);
520
521    fn diff(&'a self, other: &'a Self) -> edit::Edit<'a, Self> {
522        if self == other {
523            edit::Edit::Copy(self)
524        } else {
525            edit::Edit::Change((self, other))
526        }
527    }
528}
529
530// Status tracker for combining inserts and removes.
531enum CombineStatus<'a> {
532    None,
533    Added(&'a SummaryId),
534    Removed(&'a SummaryId),
535    Combine {
536        added: &'a SummaryId,
537        removed: &'a SummaryId,
538    },
539    Ignore,
540}
541
542impl<'a> CombineStatus<'a> {
543    fn record_added(&mut self, summary_id: &'a SummaryId) {
544        let new = match self {
545            CombineStatus::None => CombineStatus::Added(summary_id),
546            CombineStatus::Removed(removed) => CombineStatus::Combine {
547                added: summary_id,
548                removed,
549            },
550            _ => CombineStatus::Ignore,
551        };
552
553        let _ = mem::replace(self, new);
554    }
555
556    fn record_removed(&mut self, summary_id: &'a SummaryId) {
557        let new = match self {
558            CombineStatus::None => CombineStatus::Removed(summary_id),
559            CombineStatus::Added(added) => CombineStatus::Combine {
560                added,
561                removed: summary_id,
562            },
563            _ => CombineStatus::Ignore,
564        };
565
566        let _ = mem::replace(self, new);
567    }
568
569    fn record_changed(&mut self) {
570        // If this package name appears in the changed list at all, don't combine its
571        // features.
572        let _ = mem::replace(self, CombineStatus::Ignore);
573    }
574}