uv_pypi_types/
simple_json.rs

1use std::borrow::Cow;
2use std::str::FromStr;
3
4use jiff::Timestamp;
5use rustc_hash::FxHashMap;
6use serde::{Deserialize, Deserializer, Serialize};
7
8use uv_normalize::{ExtraName, PackageName};
9use uv_pep440::{Version, VersionSpecifiers, VersionSpecifiersParseError};
10use uv_pep508::Requirement;
11use uv_small_str::SmallString;
12
13use crate::VerbatimParsedUrl;
14use crate::lenient_requirement::LenientVersionSpecifiers;
15
16/// A collection of "files" from `PyPI`'s JSON API for a single package, as served by the
17/// `vnd.pypi.simple.v1` media type.
18#[derive(Debug, Clone, Deserialize)]
19pub struct PypiSimpleDetail {
20    /// The list of [`PypiFile`]s available for download sorted by filename.
21    #[serde(deserialize_with = "sorted_simple_json_files")]
22    pub files: Vec<PypiFile>,
23}
24
25/// Deserializes a sequence of "simple" files from `PyPI` and ensures that they
26/// are sorted in a stable order.
27fn sorted_simple_json_files<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<PypiFile>, D::Error> {
28    let mut files = <Vec<PypiFile>>::deserialize(d)?;
29    // While it has not been positively observed, we sort the files
30    // to ensure we have a defined ordering. Otherwise, if we rely on
31    // the API to provide a stable ordering and doesn't, it can lead
32    // non-deterministic behavior elsewhere. (This is somewhat hand-wavy
33    // and a bit of a band-aide, since arguably, the order of this API
34    // response probably shouldn't have an impact on things downstream from
35    // this. That is, if something depends on ordering, then it should
36    // probably be the thing that does the sorting.)
37    files.sort_unstable_by(|f1, f2| f1.filename.cmp(&f2.filename));
38    Ok(files)
39}
40
41/// A single (remote) file belonging to a package, either a wheel or a source distribution, as
42/// served by the `vnd.pypi.simple.v1` media type.
43///
44/// <https://peps.python.org/pep-0691/#project-detail>
45#[derive(Debug, Clone)]
46pub struct PypiFile {
47    pub core_metadata: Option<CoreMetadata>,
48    pub filename: SmallString,
49    pub hashes: Hashes,
50    pub requires_python: Option<Result<VersionSpecifiers, VersionSpecifiersParseError>>,
51    pub size: Option<u64>,
52    pub upload_time: Option<Timestamp>,
53    pub url: SmallString,
54    pub yanked: Option<Box<Yanked>>,
55}
56
57impl<'de> Deserialize<'de> for PypiFile {
58    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
59    where
60        D: Deserializer<'de>,
61    {
62        struct FileVisitor;
63
64        impl<'de> serde::de::Visitor<'de> for FileVisitor {
65            type Value = PypiFile;
66
67            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
68                formatter.write_str("a map containing file metadata")
69            }
70
71            fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
72            where
73                M: serde::de::MapAccess<'de>,
74            {
75                let mut core_metadata = None;
76                let mut filename = None;
77                let mut hashes = None;
78                let mut requires_python = None;
79                let mut size = None;
80                let mut upload_time = None;
81                let mut url = None;
82                let mut yanked = None;
83
84                while let Some(key) = access.next_key::<String>()? {
85                    match key.as_str() {
86                        "core-metadata" | "dist-info-metadata" | "data-dist-info-metadata" => {
87                            if core_metadata.is_none() {
88                                core_metadata = access.next_value()?;
89                            } else {
90                                let _: serde::de::IgnoredAny = access.next_value()?;
91                            }
92                        }
93                        "filename" => filename = Some(access.next_value()?),
94                        "hashes" => hashes = Some(access.next_value()?),
95                        "requires-python" => {
96                            requires_python =
97                                access.next_value::<Option<Cow<'_, str>>>()?.map(|s| {
98                                    LenientVersionSpecifiers::from_str(s.as_ref())
99                                        .map(VersionSpecifiers::from)
100                                });
101                        }
102                        "size" => size = Some(access.next_value()?),
103                        "upload-time" => upload_time = Some(access.next_value()?),
104                        "url" => url = Some(access.next_value()?),
105                        "yanked" => yanked = Some(access.next_value()?),
106                        _ => {
107                            let _: serde::de::IgnoredAny = access.next_value()?;
108                        }
109                    }
110                }
111
112                Ok(PypiFile {
113                    core_metadata,
114                    filename: filename
115                        .ok_or_else(|| serde::de::Error::missing_field("filename"))?,
116                    hashes: hashes.ok_or_else(|| serde::de::Error::missing_field("hashes"))?,
117                    requires_python,
118                    size,
119                    upload_time,
120                    url: url.ok_or_else(|| serde::de::Error::missing_field("url"))?,
121                    yanked,
122                })
123            }
124        }
125
126        deserializer.deserialize_map(FileVisitor)
127    }
128}
129
130/// A collection of "files" from the Simple API.
131#[derive(Debug, Clone, Deserialize)]
132#[serde(rename_all = "kebab-case")]
133pub struct PyxSimpleDetail {
134    /// The list of [`PyxFile`]s available for download sorted by filename.
135    pub files: Vec<PyxFile>,
136    /// The core metadata for the project, keyed by version.
137    #[serde(default)]
138    pub core_metadata: FxHashMap<Version, CoreMetadatum>,
139}
140
141/// A single (remote) file belonging to a package, either a wheel or a source distribution,
142/// as served by the Simple API.
143#[derive(Debug, Clone)]
144pub struct PyxFile {
145    pub core_metadata: Option<CoreMetadata>,
146    pub filename: Option<SmallString>,
147    pub hashes: Hashes,
148    pub requires_python: Option<Result<VersionSpecifiers, VersionSpecifiersParseError>>,
149    pub size: Option<u64>,
150    pub upload_time: Option<Timestamp>,
151    pub url: SmallString,
152    pub yanked: Option<Box<Yanked>>,
153    pub zstd: Option<Zstd>,
154}
155
156impl<'de> Deserialize<'de> for PyxFile {
157    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
158    where
159        D: Deserializer<'de>,
160    {
161        struct FileVisitor;
162
163        impl<'de> serde::de::Visitor<'de> for FileVisitor {
164            type Value = PyxFile;
165
166            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
167                formatter.write_str("a map containing file metadata")
168            }
169
170            fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
171            where
172                M: serde::de::MapAccess<'de>,
173            {
174                let mut core_metadata = None;
175                let mut filename = None;
176                let mut hashes = None;
177                let mut requires_python = None;
178                let mut size = None;
179                let mut upload_time = None;
180                let mut url = None;
181                let mut yanked = None;
182                let mut zstd = None;
183
184                while let Some(key) = access.next_key::<String>()? {
185                    match key.as_str() {
186                        "core-metadata" | "dist-info-metadata" | "data-dist-info-metadata" => {
187                            if core_metadata.is_none() {
188                                core_metadata = access.next_value()?;
189                            } else {
190                                let _: serde::de::IgnoredAny = access.next_value()?;
191                            }
192                        }
193                        "filename" => filename = Some(access.next_value()?),
194                        "hashes" => hashes = Some(access.next_value()?),
195                        "requires-python" => {
196                            requires_python =
197                                access.next_value::<Option<Cow<'_, str>>>()?.map(|s| {
198                                    LenientVersionSpecifiers::from_str(s.as_ref())
199                                        .map(VersionSpecifiers::from)
200                                });
201                        }
202                        "size" => size = Some(access.next_value()?),
203                        "upload-time" => upload_time = Some(access.next_value()?),
204                        "url" => url = Some(access.next_value()?),
205                        "yanked" => yanked = Some(access.next_value()?),
206                        "zstd" => {
207                            zstd = Some(access.next_value()?);
208                        }
209                        _ => {
210                            let _: serde::de::IgnoredAny = access.next_value()?;
211                        }
212                    }
213                }
214
215                Ok(PyxFile {
216                    core_metadata,
217                    filename,
218                    hashes: hashes.ok_or_else(|| serde::de::Error::missing_field("hashes"))?,
219                    requires_python,
220                    size,
221                    upload_time,
222                    url: url.ok_or_else(|| serde::de::Error::missing_field("url"))?,
223                    yanked,
224                    zstd,
225                })
226            }
227        }
228
229        deserializer.deserialize_map(FileVisitor)
230    }
231}
232
233#[derive(Debug, Clone, Deserialize)]
234#[serde(rename_all = "kebab-case")]
235pub struct CoreMetadatum {
236    #[serde(default)]
237    pub requires_python: Option<VersionSpecifiers>,
238    #[serde(default)]
239    pub requires_dist: Box<[Requirement<VerbatimParsedUrl>]>,
240    #[serde(default, alias = "provides-extras")]
241    pub provides_extra: Box<[ExtraName]>,
242}
243
244#[derive(Debug, Clone)]
245pub enum CoreMetadata {
246    Bool(bool),
247    Hashes(Hashes),
248}
249
250impl<'de> Deserialize<'de> for CoreMetadata {
251    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
252    where
253        D: Deserializer<'de>,
254    {
255        serde_untagged::UntaggedEnumVisitor::new()
256            .bool(|bool| Ok(Self::Bool(bool)))
257            .map(|map| map.deserialize().map(CoreMetadata::Hashes))
258            .deserialize(deserializer)
259    }
260}
261
262impl Serialize for CoreMetadata {
263    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
264    where
265        S: serde::Serializer,
266    {
267        match self {
268            Self::Bool(is_available) => serializer.serialize_bool(*is_available),
269            Self::Hashes(hashes) => hashes.serialize(serializer),
270        }
271    }
272}
273
274impl CoreMetadata {
275    pub fn is_available(&self) -> bool {
276        match self {
277            Self::Bool(is_available) => *is_available,
278            Self::Hashes(_) => true,
279        }
280    }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
284#[rkyv(derive(Debug))]
285pub enum Yanked {
286    Bool(bool),
287    Reason(SmallString),
288}
289
290impl<'de> Deserialize<'de> for Yanked {
291    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
292    where
293        D: Deserializer<'de>,
294    {
295        serde_untagged::UntaggedEnumVisitor::new()
296            .bool(|bool| Ok(Self::Bool(bool)))
297            .string(|string| Ok(Self::Reason(SmallString::from(string))))
298            .deserialize(deserializer)
299    }
300}
301
302impl Serialize for Yanked {
303    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
304    where
305        S: serde::Serializer,
306    {
307        match self {
308            Self::Bool(is_yanked) => serializer.serialize_bool(*is_yanked),
309            Self::Reason(reason) => serializer.serialize_str(reason.as_ref()),
310        }
311    }
312}
313
314impl Yanked {
315    pub fn is_yanked(&self) -> bool {
316        match self {
317            Self::Bool(is_yanked) => *is_yanked,
318            Self::Reason(_) => true,
319        }
320    }
321}
322
323impl Default for Yanked {
324    fn default() -> Self {
325        Self::Bool(false)
326    }
327}
328
329#[derive(Debug, Clone, Eq, PartialEq, Default, Deserialize, Serialize)]
330pub struct Zstd {
331    pub hashes: Hashes,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub size: Option<u64>,
334}
335
336/// A dictionary mapping a hash name to a hex encoded digest of the file.
337///
338/// PEP 691 says multiple hashes can be included and the interpretation is left to the client.
339#[derive(Debug, Clone, Eq, PartialEq, Default, Deserialize, Serialize)]
340pub struct Hashes {
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub md5: Option<SmallString>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub sha256: Option<SmallString>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub sha384: Option<SmallString>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub sha512: Option<SmallString>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub blake2b: Option<SmallString>,
351}
352
353impl Hashes {
354    /// Parse the hash from a fragment, as in: `sha256=6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61`
355    pub fn parse_fragment(fragment: &str) -> Result<Self, HashError> {
356        let mut parts = fragment.split('=');
357
358        // Extract the key and value.
359        let name = parts
360            .next()
361            .ok_or_else(|| HashError::InvalidFragment(fragment.to_string()))?;
362        let value = parts
363            .next()
364            .ok_or_else(|| HashError::InvalidFragment(fragment.to_string()))?;
365
366        // Ensure there are no more parts.
367        if parts.next().is_some() {
368            return Err(HashError::InvalidFragment(fragment.to_string()));
369        }
370
371        match name {
372            "md5" => Ok(Self {
373                md5: Some(SmallString::from(value)),
374                sha256: None,
375                sha384: None,
376                sha512: None,
377                blake2b: None,
378            }),
379            "sha256" => Ok(Self {
380                md5: None,
381                sha256: Some(SmallString::from(value)),
382                sha384: None,
383                sha512: None,
384                blake2b: None,
385            }),
386            "sha384" => Ok(Self {
387                md5: None,
388                sha256: None,
389                sha384: Some(SmallString::from(value)),
390                sha512: None,
391                blake2b: None,
392            }),
393            "sha512" => Ok(Self {
394                md5: None,
395                sha256: None,
396                sha384: None,
397                sha512: Some(SmallString::from(value)),
398                blake2b: None,
399            }),
400            "blake2b" => Ok(Self {
401                md5: None,
402                sha256: None,
403                sha384: None,
404                sha512: None,
405                blake2b: Some(SmallString::from(value)),
406            }),
407            _ => Err(HashError::UnsupportedHashAlgorithm(fragment.to_string())),
408        }
409    }
410}
411
412impl FromStr for Hashes {
413    type Err = HashError;
414
415    fn from_str(s: &str) -> Result<Self, Self::Err> {
416        let mut parts = s.split(':');
417
418        // Extract the key and value.
419        let name = parts
420            .next()
421            .ok_or_else(|| HashError::InvalidStructure(s.to_string()))?;
422        let value = parts
423            .next()
424            .ok_or_else(|| HashError::InvalidStructure(s.to_string()))?;
425
426        // Ensure there are no more parts.
427        if parts.next().is_some() {
428            return Err(HashError::InvalidStructure(s.to_string()));
429        }
430
431        match name {
432            "md5" => Ok(Self {
433                md5: Some(SmallString::from(value)),
434                sha256: None,
435                sha384: None,
436                sha512: None,
437                blake2b: None,
438            }),
439            "sha256" => Ok(Self {
440                md5: None,
441                sha256: Some(SmallString::from(value)),
442                sha384: None,
443                sha512: None,
444                blake2b: None,
445            }),
446            "sha384" => Ok(Self {
447                md5: None,
448                sha256: None,
449                sha384: Some(SmallString::from(value)),
450                sha512: None,
451                blake2b: None,
452            }),
453            "sha512" => Ok(Self {
454                md5: None,
455                sha256: None,
456                sha384: None,
457                sha512: Some(SmallString::from(value)),
458                blake2b: None,
459            }),
460            "blake2b" => Ok(Self {
461                md5: None,
462                sha256: None,
463                sha384: None,
464                sha512: None,
465                blake2b: Some(SmallString::from(value)),
466            }),
467            _ => Err(HashError::UnsupportedHashAlgorithm(s.to_string())),
468        }
469    }
470}
471
472#[derive(
473    Debug,
474    Clone,
475    Copy,
476    Ord,
477    PartialOrd,
478    Eq,
479    PartialEq,
480    Hash,
481    Serialize,
482    Deserialize,
483    rkyv::Archive,
484    rkyv::Deserialize,
485    rkyv::Serialize,
486)]
487#[rkyv(derive(Debug))]
488pub enum HashAlgorithm {
489    Md5,
490    Sha256,
491    Sha384,
492    Sha512,
493    Blake2b,
494}
495
496impl FromStr for HashAlgorithm {
497    type Err = HashError;
498
499    fn from_str(s: &str) -> Result<Self, Self::Err> {
500        match s {
501            "md5" => Ok(Self::Md5),
502            "sha256" => Ok(Self::Sha256),
503            "sha384" => Ok(Self::Sha384),
504            "sha512" => Ok(Self::Sha512),
505            "blake2b" => Ok(Self::Blake2b),
506            _ => Err(HashError::UnsupportedHashAlgorithm(s.to_string())),
507        }
508    }
509}
510
511impl std::fmt::Display for HashAlgorithm {
512    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513        match self {
514            Self::Md5 => write!(f, "md5"),
515            Self::Sha256 => write!(f, "sha256"),
516            Self::Sha384 => write!(f, "sha384"),
517            Self::Sha512 => write!(f, "sha512"),
518            Self::Blake2b => write!(f, "blake2b"),
519        }
520    }
521}
522
523/// A hash name and hex encoded digest of the file.
524#[derive(
525    Debug,
526    Clone,
527    Ord,
528    PartialOrd,
529    Eq,
530    PartialEq,
531    Hash,
532    Serialize,
533    Deserialize,
534    rkyv::Archive,
535    rkyv::Deserialize,
536    rkyv::Serialize,
537)]
538#[rkyv(derive(Debug))]
539pub struct HashDigest {
540    pub algorithm: HashAlgorithm,
541    pub digest: SmallString,
542}
543
544impl HashDigest {
545    /// Return the [`HashAlgorithm`] of the digest.
546    pub fn algorithm(&self) -> HashAlgorithm {
547        self.algorithm
548    }
549}
550
551impl std::fmt::Display for HashDigest {
552    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        write!(f, "{}:{}", self.algorithm, self.digest)
554    }
555}
556
557impl FromStr for HashDigest {
558    type Err = HashError;
559
560    fn from_str(s: &str) -> Result<Self, Self::Err> {
561        let mut parts = s.split(':');
562
563        // Extract the key and value.
564        let name = parts
565            .next()
566            .ok_or_else(|| HashError::InvalidStructure(s.to_string()))?;
567        let value = parts
568            .next()
569            .ok_or_else(|| HashError::InvalidStructure(s.to_string()))?;
570
571        // Ensure there are no more parts.
572        if parts.next().is_some() {
573            return Err(HashError::InvalidStructure(s.to_string()));
574        }
575
576        let algorithm = HashAlgorithm::from_str(name)?;
577        let digest = SmallString::from(value);
578
579        Ok(Self { algorithm, digest })
580    }
581}
582
583/// A collection of [`HashDigest`] entities.
584#[derive(
585    Debug,
586    Clone,
587    Ord,
588    PartialOrd,
589    Eq,
590    PartialEq,
591    Hash,
592    Serialize,
593    Deserialize,
594    rkyv::Archive,
595    rkyv::Deserialize,
596    rkyv::Serialize,
597)]
598#[rkyv(derive(Debug))]
599pub struct HashDigests(Box<[HashDigest]>);
600
601impl HashDigests {
602    /// Initialize an empty collection of [`HashDigest`] entities.
603    pub fn empty() -> Self {
604        Self(Box::new([]))
605    }
606
607    /// Return the [`HashDigest`] entities as a slice.
608    pub fn as_slice(&self) -> &[HashDigest] {
609        self.0.as_ref()
610    }
611
612    /// Returns `true` if the [`HashDigests`] are empty.
613    pub fn is_empty(&self) -> bool {
614        self.0.is_empty()
615    }
616
617    /// Returns the first [`HashDigest`] entity.
618    pub fn first(&self) -> Option<&HashDigest> {
619        self.0.first()
620    }
621
622    /// Return the [`HashDigest`] entities as a vector.
623    pub fn to_vec(&self) -> Vec<HashDigest> {
624        self.0.to_vec()
625    }
626
627    /// Returns an [`Iterator`] over the [`HashDigest`] entities.
628    pub fn iter(&self) -> impl Iterator<Item = &HashDigest> {
629        self.0.iter()
630    }
631
632    /// Sort the underlying [`HashDigest`] entities.
633    pub fn sort_unstable(&mut self) {
634        self.0.sort_unstable();
635    }
636}
637
638/// Convert a set of [`Hashes`] into a list of [`HashDigest`]s.
639impl From<Hashes> for HashDigests {
640    fn from(value: Hashes) -> Self {
641        let mut digests = Vec::with_capacity(
642            usize::from(value.sha512.is_some())
643                + usize::from(value.sha384.is_some())
644                + usize::from(value.sha256.is_some())
645                + usize::from(value.md5.is_some()),
646        );
647        if let Some(sha512) = value.sha512 {
648            digests.push(HashDigest {
649                algorithm: HashAlgorithm::Sha512,
650                digest: sha512,
651            });
652        }
653        if let Some(sha384) = value.sha384 {
654            digests.push(HashDigest {
655                algorithm: HashAlgorithm::Sha384,
656                digest: sha384,
657            });
658        }
659        if let Some(sha256) = value.sha256 {
660            digests.push(HashDigest {
661                algorithm: HashAlgorithm::Sha256,
662                digest: sha256,
663            });
664        }
665        if let Some(md5) = value.md5 {
666            digests.push(HashDigest {
667                algorithm: HashAlgorithm::Md5,
668                digest: md5,
669            });
670        }
671        Self::from(digests)
672    }
673}
674
675impl From<HashDigests> for Hashes {
676    fn from(value: HashDigests) -> Self {
677        let mut hashes = Self::default();
678        for digest in value {
679            match digest.algorithm() {
680                HashAlgorithm::Md5 => hashes.md5 = Some(digest.digest),
681                HashAlgorithm::Sha256 => hashes.sha256 = Some(digest.digest),
682                HashAlgorithm::Sha384 => hashes.sha384 = Some(digest.digest),
683                HashAlgorithm::Sha512 => hashes.sha512 = Some(digest.digest),
684                HashAlgorithm::Blake2b => hashes.blake2b = Some(digest.digest),
685            }
686        }
687        hashes
688    }
689}
690
691impl From<HashDigest> for HashDigests {
692    fn from(value: HashDigest) -> Self {
693        Self(Box::new([value]))
694    }
695}
696
697impl From<&[HashDigest]> for HashDigests {
698    fn from(value: &[HashDigest]) -> Self {
699        Self(Box::from(value))
700    }
701}
702
703impl From<Vec<HashDigest>> for HashDigests {
704    fn from(value: Vec<HashDigest>) -> Self {
705        Self(value.into_boxed_slice())
706    }
707}
708
709impl FromIterator<HashDigest> for HashDigests {
710    fn from_iter<T: IntoIterator<Item = HashDigest>>(iter: T) -> Self {
711        Self(iter.into_iter().collect())
712    }
713}
714
715impl IntoIterator for HashDigests {
716    type Item = HashDigest;
717    type IntoIter = std::vec::IntoIter<HashDigest>;
718
719    fn into_iter(self) -> Self::IntoIter {
720        self.0.into_vec().into_iter()
721    }
722}
723
724#[derive(thiserror::Error, Debug)]
725pub enum HashError {
726    #[error("Unexpected hash (expected `<algorithm>:<hash>`): {0}")]
727    InvalidStructure(String),
728
729    #[error("Unexpected fragment (expected `#sha256=...` or similar) on URL: {0}")]
730    InvalidFragment(String),
731
732    #[error(
733        "Unsupported hash algorithm (expected one of: `md5`, `sha256`, `sha384`, `sha512`, or `blake2b`) on: `{0}`"
734    )]
735    UnsupportedHashAlgorithm(String),
736}
737
738#[cfg(test)]
739mod tests {
740    use crate::{HashError, Hashes};
741
742    #[test]
743    fn parse_hashes() -> Result<(), HashError> {
744        let hashes: Hashes =
745            "blake2b:af4793213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a".parse()?;
746        assert_eq!(
747            hashes,
748            Hashes {
749                md5: None,
750                sha256: None,
751                sha384: None,
752                sha512: None,
753                blake2b: Some(
754                    "af4793213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a".into()
755                ),
756            }
757        );
758
759        let hashes: Hashes =
760            "sha512:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".parse()?;
761        assert_eq!(
762            hashes,
763            Hashes {
764                md5: None,
765                sha256: None,
766                sha384: None,
767                sha512: Some(
768                    "40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".into()
769                ),
770                blake2b: None,
771            }
772        );
773
774        let hashes: Hashes =
775            "sha384:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".parse()?;
776        assert_eq!(
777            hashes,
778            Hashes {
779                md5: None,
780                sha256: None,
781                sha384: Some(
782                    "40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".into()
783                ),
784                sha512: None,
785                blake2b: None,
786            }
787        );
788
789        let hashes: Hashes =
790            "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".parse()?;
791        assert_eq!(
792            hashes,
793            Hashes {
794                md5: None,
795                sha256: Some(
796                    "40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".into()
797                ),
798                sha384: None,
799                sha512: None,
800                blake2b: None,
801            }
802        );
803
804        let hashes: Hashes =
805            "md5:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2".parse()?;
806        assert_eq!(
807            hashes,
808            Hashes {
809                md5: Some(
810                    "090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2".into()
811                ),
812                sha256: None,
813                sha384: None,
814                sha512: None,
815                blake2b: None,
816            }
817        );
818
819        let result = "sha256=40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"
820            .parse::<Hashes>();
821        assert!(result.is_err());
822
823        let result = "blake2:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"
824            .parse::<Hashes>();
825        assert!(result.is_err());
826
827        Ok(())
828    }
829}
830
831/// Response from the Simple API root endpoint (index) listing all available projects,
832/// as served by the `vnd.pypi.simple.v1` media type.
833///
834/// <https://peps.python.org/pep-0691/#specification>
835#[derive(Debug, Clone, Deserialize, Serialize)]
836#[serde(rename_all = "kebab-case")]
837pub struct PypiSimpleIndex {
838    /// Metadata about the response.
839    pub meta: SimpleIndexMeta,
840    /// The list of projects available in the index.
841    pub projects: Vec<ProjectEntry>,
842}
843
844/// Response from the Pyx Simple API root endpoint listing all available projects,
845/// as served by the `vnd.pyx.simple.v1` media types.
846#[derive(Debug, Clone, Deserialize, Serialize)]
847#[serde(rename_all = "kebab-case")]
848pub struct PyxSimpleIndex {
849    /// Metadata about the response.
850    pub meta: SimpleIndexMeta,
851    /// The list of projects available in the index.
852    pub projects: Vec<ProjectEntry>,
853}
854
855/// Metadata about a Simple API index response.
856#[derive(Debug, Clone, Deserialize, Serialize)]
857#[serde(rename_all = "kebab-case")]
858pub struct SimpleIndexMeta {
859    /// The API version.
860    pub api_version: SmallString,
861}
862
863/// A single project entry in the Simple API index.
864#[derive(Debug, Clone, Deserialize, Serialize)]
865pub struct ProjectEntry {
866    /// The name of the project.
867    pub name: PackageName,
868}