mukti_metadata/
models.rs

1// Copyright (c) The mukti Contributors
2// SPDX-License-Identifier: MIT or Apache-2.0
3
4use crate::VersionRangeParseError;
5use semver::{Version, VersionReq};
6use serde::{de::Visitor, ser::SerializeMap, Deserialize, Serialize, Serializer};
7use std::{borrow::Cow, collections::BTreeMap, fmt, str::FromStr};
8
9#[derive(Clone, Debug, Default, Deserialize, Serialize)]
10pub struct MuktiReleasesJson {
11    /// The projects that are part of this releases.json.
12    pub projects: BTreeMap<String, MuktiProject>,
13}
14
15#[derive(Clone, Debug, Deserialize, Serialize)]
16pub struct MuktiProject {
17    /// The latest version range (key in the releases field) without any pre-releases.
18    pub latest: Option<VersionRange>,
19
20    /// Map of version range (major or minor version) to release data about it
21    #[serde(serialize_with = "serialize_reverse")]
22    pub ranges: BTreeMap<VersionRange, ReleaseRangeData>,
23}
24
25impl MuktiProject {
26    /// Return all version data for this release, ordered by most recent version first.
27    ///
28    /// Includes pre-release and yanked versions.
29    pub fn all_versions(&self) -> impl Iterator<Item = (&Version, &ReleaseVersionData)> {
30        self.ranges
31            .values()
32            .rev()
33            .flat_map(|range| range.versions.iter().rev())
34    }
35
36    /// Retrieve data for this exact version if found.
37    ///
38    /// Can include yanked or pre-release versions.
39    pub fn get_version_data(&self, version: &Version) -> Option<(&Version, &ReleaseVersionData)> {
40        // Ignore build metadata since it isn't relevant.
41        self.all_versions()
42            .find(|&(v2, _)| eq_ignoring_build_metadata(version, v2))
43    }
44
45    /// Retrieve the latest version that matches this `VersionReq`.
46    ///
47    /// This will match the latest non-pre-release, non-yanked version.
48    pub fn get_latest_matching(&self, req: &VersionReq) -> Option<(&Version, &ReleaseVersionData)> {
49        self.all_versions().find(|&(version, version_data)| {
50            version_data.status == ReleaseStatus::Active && req.matches(version)
51        })
52    }
53}
54
55#[derive(Clone, Debug, Deserialize, Serialize)]
56pub struct ReleaseRangeData {
57    /// The latest version within this range (can be a prerelease)
58    pub latest: Version,
59
60    /// True if this version range only has prereleases.
61    pub is_prerelease: bool,
62
63    /// All known versions
64    #[serde(serialize_with = "serialize_reverse")]
65    pub versions: BTreeMap<Version, ReleaseVersionData>,
66}
67
68#[derive(Clone, Debug, Deserialize, Serialize)]
69pub struct ReleaseVersionData {
70    /// Canonical URL for this release
71    pub release_url: String,
72
73    /// The status of a release
74    pub status: ReleaseStatus,
75
76    /// Release locations
77    pub locations: Vec<ReleaseLocation>,
78
79    /// Custom domain-specific information stored about this release.
80    #[serde(default)]
81    pub metadata: serde_json::Value,
82}
83
84#[derive(Copy, Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum ReleaseStatus {
87    /// This release is active.
88    Active,
89
90    /// This release was yanked.
91    Yanked,
92}
93
94#[derive(Clone, Debug, Deserialize, Serialize)]
95pub struct ReleaseLocation {
96    /// The target string
97    pub target: String,
98
99    /// The archive format (e.g. ".tar.gz" or ".zip")
100    pub format: String,
101
102    /// The URL the target can be downloaded at
103    pub url: String,
104
105    /// The checksums for the target as a map of algorithm to checksum. This is
106    /// left open-ended to allow for new checksum algorithms to be added in the
107    /// future.
108    #[serde(default)]
109    pub checksums: BTreeMap<DigestAlgorithm, Digest>,
110}
111
112#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
113#[serde(transparent)]
114pub struct DigestAlgorithm(Cow<'static, str>);
115
116impl DigestAlgorithm {
117    /// The SHA-256 checksum algorithm.
118    pub const SHA256: Self = Self(Cow::Borrowed("sha256"));
119
120    /// The BLAKE2b-512 checksum algorithm.
121    pub const BLAKE2B: Self = Self(Cow::Borrowed("blake2b"));
122
123    pub const fn new_static(algorithm: &'static str) -> Self {
124        Self(Cow::Borrowed(algorithm))
125    }
126
127    pub fn new(algorithm: String) -> Self {
128        Self(Cow::Owned(algorithm))
129    }
130}
131
132/// A digest, typically encoded as a hex string.
133#[derive(Clone, Debug, Deserialize, Serialize)]
134#[serde(transparent)]
135pub struct Digest(pub String);
136
137fn serialize_reverse<S, K, V>(map: &BTreeMap<K, V>, serializer: S) -> Result<S::Ok, S::Error>
138where
139    S: Serializer,
140    K: Serialize,
141    V: Serialize,
142{
143    let mut serialize_map = serializer.serialize_map(Some(map.len()))?;
144    for (k, v) in map.iter().rev() {
145        serialize_map.serialize_entry(k, v)?;
146    }
147    serialize_map.end()
148}
149
150/// Represents a range of versions
151#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)]
152pub enum VersionRange {
153    Patch(u64),
154    Minor(u64),
155    Major(u64),
156}
157
158impl VersionRange {
159    pub fn from_version(version: &Version) -> Self {
160        if version.major >= 1 {
161            VersionRange::Major(version.major)
162        } else if version.minor >= 1 {
163            VersionRange::Minor(version.minor)
164        } else {
165            VersionRange::Patch(version.patch)
166        }
167    }
168}
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)]
171pub enum VersionRangeKind {
172    /// Patch version.
173    Patch,
174    /// Minor version.
175    Minor,
176    /// Major version.
177    Major,
178}
179
180impl VersionRangeKind {
181    pub fn description(&self) -> &'static str {
182        match self {
183            Self::Major => "major",
184            Self::Minor => "minor",
185            Self::Patch => "patch",
186        }
187    }
188}
189
190impl fmt::Display for VersionRange {
191    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
192        match self {
193            Self::Major(major) => write!(f, "{}", major),
194            Self::Minor(minor) => write!(f, "0.{}", minor),
195            Self::Patch(patch) => write!(f, "0.0.{}", patch),
196        }
197    }
198}
199
200impl FromStr for VersionRange {
201    type Err = VersionRangeParseError;
202
203    fn from_str(input: &str) -> Result<Self, Self::Err> {
204        if let Some(patch_str) = input.strip_prefix("0.0.") {
205            parse_component(patch_str, VersionRangeKind::Patch).map(Self::Patch)
206        } else if let Some(minor_str) = input.strip_prefix("0.") {
207            parse_component(minor_str, VersionRangeKind::Minor).map(Self::Minor)
208        } else {
209            parse_component(input, VersionRangeKind::Major).map(Self::Major)
210        }
211    }
212}
213
214fn parse_component(s: &str, component: VersionRangeKind) -> Result<u64, VersionRangeParseError> {
215    s.parse()
216        .map_err(|err| VersionRangeParseError::new(s, component, err))
217}
218
219impl Serialize for VersionRange {
220    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
221    where
222        S: Serializer,
223    {
224        serializer.serialize_str(&format!("{}", self))
225    }
226}
227
228impl<'de> Deserialize<'de> for VersionRange {
229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230    where
231        D: serde::Deserializer<'de>,
232    {
233        deserializer.deserialize_str(VersionRangeDeVisitor)
234    }
235}
236
237struct VersionRangeDeVisitor;
238
239impl<'de> Visitor<'de> for VersionRangeDeVisitor {
240    type Value = VersionRange;
241
242    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
243        write!(
244            formatter,
245            "a version range in the format major, major.minor, or major.minor.patch"
246        )
247    }
248
249    fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
250    where
251        E: serde::de::Error,
252    {
253        s.parse().map_err(|err| E::custom(err))
254    }
255}
256
257#[inline]
258fn eq_ignoring_build_metadata(a: &Version, b: &Version) -> bool {
259    a.major == b.major && a.minor == b.minor && a.patch == b.patch && a.pre == b.pre
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    static FIXTURE_JSON: &str = include_str!("../../fixtures/mukti-releases.json");
267
268    #[test]
269    fn test_get_version_data() {
270        let json: MuktiReleasesJson = serde_json::from_str(FIXTURE_JSON).unwrap();
271        let project = &json.projects["mukti"];
272        // Active version matches
273        assert_eq!(
274            get_version_data(project, "0.5.3").release_url,
275            "https://my-release-url/version-0.5.3",
276            "data for active version 0.5.3 matches"
277        );
278        // Yanked version still matches.
279        assert_eq!(
280            get_version_data(project, "0.5.2").release_url,
281            "https://my-release-url/version-0.5.2",
282            "data for yanked version 0.5.2 matches"
283        );
284        // Pre-release version matches.
285        assert_eq!(
286            get_version_data(project, "0.6.0-alpha.1").release_url,
287            "https://my-release-url/version-0.6.0-alpha.1",
288            "data for pre-release 0.6.0-alpha.1 matches"
289        );
290    }
291
292    #[track_caller]
293    fn version(version_str: &str) -> Version {
294        Version::parse(version_str)
295            .unwrap_or_else(|err| panic!("unable to parse version string {version_str}: {err}"))
296    }
297
298    fn get_version_data<'a>(
299        project: &'a MuktiProject,
300        version_str: &str,
301    ) -> &'a ReleaseVersionData {
302        let version = version(version_str);
303        let (_, version_data) = project
304            .get_version_data(&version)
305            .unwrap_or_else(|| panic!("no version data found for {version_str}"));
306        version_data
307    }
308
309    #[test]
310    fn test_get_latest_matching() {
311        let json: MuktiReleasesJson = serde_json::from_str(FIXTURE_JSON).unwrap();
312        let project = &json.projects["mukti"];
313
314        // Latest version.
315        assert_eq!(
316            get_latest_matching_version(project, "*"),
317            Some(&version("0.5.3")),
318            "latest non-prerelease version"
319        );
320        assert_eq!(
321            get_latest_matching_version(project, "0.5"),
322            Some(&version("0.5.3")),
323            "latest version in the 0.5 series"
324        );
325
326        // Version matching only pre-releases.
327        assert_eq!(
328            get_latest_matching_version(project, "0.6"),
329            None,
330            "0.6.0-alpha.1, being a pre-release, should not match 0.6.0"
331        );
332
333        // Version matching pre-release.
334        assert_eq!(
335            get_latest_matching_version(project, "0.6.0-alpha.1"),
336            Some(&version("0.6.0-alpha.1")),
337            "0.6.0-alpha.1 is a pre-release that matches this comparator"
338        );
339
340        // 0.5.2 is yanked.
341        assert_eq!(
342            get_latest_matching_version(project, "^0.5,<0.5.3"),
343            Some(&version("0.5.1")),
344            "0.5.2 is yanked so 0.5.1 should be returned"
345        );
346    }
347
348    fn get_latest_matching_version<'a>(
349        project: &'a MuktiProject,
350        version_req_str: &str,
351    ) -> Option<&'a Version> {
352        let version_req = VersionReq::parse(version_req_str).unwrap();
353        let (version, _) = project.get_latest_matching(&version_req)?;
354        Some(version)
355    }
356}