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