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