Skip to main content

guppy_summaries/
summary.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{diff::SummaryDiff, toml_compat};
5use camino::{Utf8Path, Utf8PathBuf};
6use semver::Version;
7use serde::{Deserialize, Serialize};
8use std::{
9    collections::{BTreeMap, BTreeSet},
10    fmt,
11};
12use toml::{Table, Value};
13
14/// A type representing a package map as used in `Summary` instances.
15pub type PackageMap = BTreeMap<SummaryId, PackageInfo>;
16
17/// An in-memory representation of a build summary.
18///
19/// For more, see the crate-level documentation.
20#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
21#[serde(rename_all = "kebab-case")]
22pub struct Summary {
23    /// Extra metadata associated with the summary.
24    ///
25    /// This may be used for storing extra information about the summary.
26    ///
27    /// Populate it from any [`Serialize`] type through [`Self::with_metadata`].
28    #[serde(default, skip_serializing_if = "Table::is_empty")]
29    pub metadata: Table,
30
31    /// The packages and features built on the target platform.
32    #[serde(
33        rename = "target-package",
34        with = "package_map_impl",
35        default = "PackageMap::new",
36        skip_serializing_if = "PackageMap::is_empty"
37    )]
38    pub target_packages: PackageMap,
39
40    /// The packages and features built on the host platform.
41    #[serde(
42        rename = "host-package",
43        with = "package_map_impl",
44        default = "PackageMap::new",
45        skip_serializing_if = "PackageMap::is_empty"
46    )]
47    pub host_packages: PackageMap,
48}
49
50impl Summary {
51    /// Constructs a new summary with the provided metadata, and an empty `target_packages` and
52    /// `host_packages`.
53    pub fn with_metadata(metadata: &impl Serialize) -> Result<Self, toml::ser::Error> {
54        // Serialize as a string, then deserialize as a table.
55        //
56        // This is a bit strange, right? Ordinarily we would use
57        // `Table::try_from`. But that doesn't work here, for two reasons:
58        //
59        // 1. It retains struct field order, while for compatibility reasons we must
60        //    reorder fields so that values are emitted before tables.
61        // 2. `Table::try_from` doesn't understand the special marker `toml_datetime`
62        //    uses to serialize `Datetime` values.
63        let toml_str = toml::to_string(metadata)?;
64        let metadata = toml_str
65            .parse()
66            .expect("toml::to_string creates a valid TOML string");
67        Ok(Self {
68            metadata,
69            ..Self::default()
70        })
71    }
72
73    /// Deserializes a summary from the given string.
74    pub fn parse(s: &str) -> Result<Self, toml::de::Error> {
75        toml::from_str(s)
76    }
77
78    /// Perform a diff of this summary against another.
79    ///
80    /// This doesn't diff the metadata, just the initials and packages.
81    pub fn diff<'a>(&'a self, other: &'a Summary) -> SummaryDiff<'a> {
82        SummaryDiff::new(self, other)
83    }
84
85    /// Serializes this summary to a TOML string.
86    pub fn to_string(&self) -> Result<String, toml::ser::Error> {
87        let mut dst = String::new();
88        self.write_to_string(&mut dst)?;
89        Ok(dst)
90    }
91
92    /// Serializes this summary into the given TOML string, using pretty TOML
93    /// syntax.
94    ///
95    /// This serializes the summary using the [`toml_compat`] module, for
96    /// byte-identical output against previous versions of guppy-summaries which
97    /// used toml 0.5.
98    pub fn write_to_string(&self, dst: &mut String) -> Result<(), toml::ser::Error> {
99        // toml 0.5 wrote the metadata map's entries in order but reordered
100        // every Value::Table below them, and wrote the package lists in struct
101        // order.
102        //
103        // Build the root table by hand rather than using Table::try_from(self).
104        // This also avoids mangling `Value::Datetime` instances stored in the
105        // metadata.
106        let mut table = Table::new();
107        if !self.metadata.is_empty() {
108            let metadata = self
109                .metadata
110                .iter()
111                .map(|(key, value)| (key.clone(), toml_compat::reorder_value(value)))
112                .collect();
113            table.insert("metadata".to_owned(), Value::Table(metadata));
114        }
115        table.extend(Table::try_from(SummaryPackages {
116            target_packages: &self.target_packages,
117            host_packages: &self.host_packages,
118        })?);
119        toml_compat::write_table(&table, dst)
120    }
121}
122
123#[derive(Serialize)]
124struct SummaryPackages<'a> {
125    #[serde(
126        rename = "target-package",
127        with = "package_map_impl",
128        skip_serializing_if = "PackageMap::is_empty"
129    )]
130    target_packages: &'a PackageMap,
131
132    #[serde(
133        rename = "host-package",
134        with = "package_map_impl",
135        skip_serializing_if = "PackageMap::is_empty"
136    )]
137    host_packages: &'a PackageMap,
138}
139
140/// A unique identifier for a package in a build summary.
141#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, Serialize, PartialEq, PartialOrd)]
142#[serde(rename_all = "kebab-case")]
143pub struct SummaryId {
144    /// The name of the package.
145    pub name: String,
146
147    /// The version number of the package.
148    pub version: Version,
149
150    /// The source for this package.
151    #[serde(flatten)]
152    pub source: SummarySource,
153}
154
155impl SummaryId {
156    /// Creates a new `SummaryId`.
157    pub fn new(name: impl Into<String>, version: Version, source: SummarySource) -> Self {
158        Self {
159            name: name.into(),
160            version,
161            source,
162        }
163    }
164}
165
166impl fmt::Display for SummaryId {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        write!(
169            f,
170            "{{ name = \"{}\", version = \"{}\", source = \"{}\"}}",
171            self.name, self.version, self.source
172        )
173    }
174}
175
176/// The location of a package.
177#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, Serialize, PartialEq, PartialOrd)]
178#[serde(rename_all = "kebab-case", untagged)]
179pub enum SummarySource {
180    /// A workspace path.
181    Workspace {
182        /// The path of this package, relative to the workspace root.
183        #[serde(
184            rename = "workspace-path",
185            serialize_with = "serialize_forward_slashes"
186        )]
187        workspace_path: Utf8PathBuf,
188    },
189
190    /// A non-workspace path.
191    ///
192    /// The path is usually relative to the workspace root, but on Windows a path that spans drives
193    /// (e.g. a path on D:\ when the workspace root is on C:\) cannot be relative. In those cases,
194    /// this will be the absolute path of the package.
195    Path {
196        /// The path of this package.
197        #[serde(serialize_with = "serialize_forward_slashes")]
198        path: Utf8PathBuf,
199    },
200
201    /// The `crates.io` registry.
202    #[serde(with = "crates_io_impl")]
203    CratesIo,
204
205    /// An external source that's not the `crates.io` registry, such as an alternate registry or
206    /// a `git` repository.
207    External {
208        /// The external source.
209        source: String,
210    },
211}
212
213impl SummarySource {
214    /// Creates a new `SummarySource` representing a workspace source.
215    pub fn workspace(workspace_path: impl Into<Utf8PathBuf>) -> Self {
216        SummarySource::Workspace {
217            workspace_path: workspace_path.into(),
218        }
219    }
220
221    /// Creates a new `SummarySource` representing a non-workspace path source.
222    pub fn path(path: impl Into<Utf8PathBuf>) -> Self {
223        SummarySource::Path { path: path.into() }
224    }
225
226    /// Creates a new `SummarySource` representing the `crates.io` registry.
227    pub fn crates_io() -> Self {
228        SummarySource::CratesIo
229    }
230
231    /// Creates a new `SummarySource` representing an external source like a Git repository or a
232    /// custom registry.
233    pub fn external(source: impl Into<String>) -> Self {
234        SummarySource::External {
235            source: source.into(),
236        }
237    }
238}
239
240impl fmt::Display for SummarySource {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            // Don't differentiate here between workspace and non-workspace paths because
244            // PackageStatus provides that info.
245            SummarySource::Workspace { workspace_path } => {
246                let path_out = path_replace_slashes(workspace_path);
247                write!(f, "path '{path_out}'")
248            }
249            SummarySource::Path { path } => {
250                let path_out = path_replace_slashes(path);
251                write!(f, "path '{path_out}'")
252            }
253            SummarySource::CratesIo => write!(f, "crates.io"),
254            SummarySource::External { source } => write!(f, "external '{source}'"),
255        }
256    }
257}
258
259/// Information about a package in a summary that isn't part of the unique identifier.
260#[derive(Clone, Debug, Deserialize, Eq, Hash, Serialize, PartialEq)]
261#[serde(rename_all = "kebab-case")]
262pub struct PackageInfo {
263    /// Where this package lies in the dependency graph.
264    pub status: PackageStatus,
265
266    /// The features built for this package.
267    pub features: BTreeSet<String>,
268
269    /// The optional dependencies built for this package.
270    #[serde(skip_serializing_if = "BTreeSet::is_empty", default)]
271    pub optional_deps: BTreeSet<String>,
272}
273
274/// The status of a package in a summary, such as whether it is part of the initial build set.
275///
276/// The ordering here determines what order packages will be written out in the summary.
277#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, Ord, Serialize, PartialEq, PartialOrd)]
278#[serde(rename_all = "kebab-case")]
279pub enum PackageStatus {
280    /// This package is part of the requested build set.
281    Initial,
282
283    /// This is a workspace package that isn't part of the requested build set.
284    Workspace,
285
286    /// This package is a direct non-workspace dependency.
287    ///
288    /// A `Direct` package may also be transitively included.
289    Direct,
290
291    /// This package is a transitive non-workspace dependency.
292    Transitive,
293}
294
295impl fmt::Display for PackageStatus {
296    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
297        let s = match self {
298            PackageStatus::Initial => "initial",
299            PackageStatus::Workspace => "workspace",
300            PackageStatus::Direct => "direct third-party",
301            PackageStatus::Transitive => "transitive third-party",
302        };
303        write!(f, "{s}")
304    }
305}
306
307/// Serialization and deserialization for `PackageMap` instances.
308mod package_map_impl {
309    use super::*;
310    use serde::{Deserializer, Serializer};
311
312    pub fn serialize<S>(package_map: &PackageMap, serializer: S) -> Result<S::Ok, S::Error>
313    where
314        S: Serializer,
315    {
316        // Make a list of `PackageSerialize` instances and sort by:
317        // * status (to ensure initials come first)
318        // * summary ID
319        let mut package_list: Vec<_> = package_map
320            .iter()
321            .map(|(summary_id, info)| PackageSerialize { summary_id, info })
322            .collect();
323        package_list.sort_unstable_by_key(|package| (&package.info.status, package.summary_id));
324        package_list.serialize(serializer)
325    }
326
327    /// TOML representation of a package in a build summary, for serialization.
328    #[derive(Serialize)]
329    struct PackageSerialize<'a> {
330        #[serde(flatten)]
331        summary_id: &'a SummaryId,
332        #[serde(flatten)]
333        info: &'a PackageInfo,
334    }
335
336    pub fn deserialize<'de, D>(deserializer: D) -> Result<PackageMap, D::Error>
337    where
338        D: Deserializer<'de>,
339    {
340        let packages = Vec::<PackageDeserialize>::deserialize(deserializer)?;
341        let mut package_map: PackageMap = BTreeMap::new();
342
343        for package in packages {
344            package_map.insert(package.summary_id, package.info);
345        }
346        Ok(package_map)
347    }
348
349    /// TOML representation of a package in a build summary, for deserialization.
350    #[derive(Deserialize)]
351    struct PackageDeserialize {
352        #[serde(flatten)]
353        summary_id: SummaryId,
354        #[serde(flatten)]
355        info: PackageInfo,
356    }
357}
358
359/// Serializes a path with forward slashes on Windows.
360pub fn serialize_forward_slashes<S>(path: &Utf8PathBuf, serializer: S) -> Result<S::Ok, S::Error>
361where
362    S: serde::Serializer,
363{
364    let path_out = path_replace_slashes(path);
365    path_out.serialize(serializer)
366}
367
368/// Replaces backslashes with forward slashes on Windows.
369fn path_replace_slashes(path: &Utf8Path) -> impl fmt::Display + Serialize + '_ {
370    // (Note: serde doesn't support non-Unicode paths anyway.)
371    cfg_if::cfg_if! {
372        if #[cfg(windows)] {
373            path.as_str().replace("\\", "/")
374        } else {
375            path.as_str()
376        }
377    }
378}
379
380/// Serialization and deserialization for the `CratesIo` variant.
381mod crates_io_impl {
382    use super::*;
383    use serde::{Deserializer, Serializer, de::Error, ser::SerializeMap};
384
385    pub fn serialize<S>(serializer: S) -> Result<S::Ok, S::Error>
386    where
387        S: Serializer,
388    {
389        let mut map = serializer.serialize_map(Some(1))?;
390        map.serialize_entry("crates-io", &true)?;
391        map.end()
392    }
393
394    pub fn deserialize<'de, D>(deserializer: D) -> Result<(), D::Error>
395    where
396        D: Deserializer<'de>,
397    {
398        let crates_io = CratesIoDeserialize::deserialize(deserializer)?;
399        if !crates_io.crates_io {
400            return Err(D::Error::custom("crates-io field should be true"));
401        }
402        Ok(())
403    }
404
405    #[derive(Deserialize)]
406    struct CratesIoDeserialize {
407        #[serde(rename = "crates-io")]
408        crates_io: bool,
409    }
410}