1use 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 pub projects: BTreeMap<String, MuktiProject>,
13}
14
15#[derive(Clone, Debug, Deserialize, Serialize)]
16pub struct MuktiProject {
17 pub latest: Option<VersionRange>,
19
20 #[serde(serialize_with = "serialize_reverse")]
22 pub ranges: BTreeMap<VersionRange, ReleaseRangeData>,
23}
24
25impl MuktiProject {
26 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 pub fn get_version_data(&self, version: &Version) -> Option<(&Version, &ReleaseVersionData)> {
40 self.all_versions()
42 .find(|&(v2, _)| eq_ignoring_build_metadata(version, v2))
43 }
44
45 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 pub latest: Version,
59
60 pub is_prerelease: bool,
62
63 #[serde(serialize_with = "serialize_reverse")]
65 pub versions: BTreeMap<Version, ReleaseVersionData>,
66}
67
68#[derive(Clone, Debug, Deserialize, Serialize)]
69pub struct ReleaseVersionData {
70 pub release_url: String,
72
73 pub status: ReleaseStatus,
75
76 pub locations: Vec<ReleaseLocation>,
78
79 #[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 Active,
89
90 Yanked,
92}
93
94#[derive(Clone, Debug, Deserialize, Serialize)]
95pub struct ReleaseLocation {
96 pub target: String,
98
99 pub format: String,
101
102 pub url: String,
104
105 #[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 pub const SHA256: Self = Self(Cow::Borrowed("sha256"));
119
120 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#[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#[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,
174 Minor,
176 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 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 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 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 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 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 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 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}