Skip to main content

uv_distribution_types/
index.rs

1use std::path::Path;
2use std::str::FromStr;
3
4use http::{HeaderValue, StatusCode};
5use serde::{Deserialize, Serialize, Serializer};
6use thiserror::Error;
7use url::Url;
8
9use uv_auth::{AuthPolicy, Credentials, CredentialsFromUrlError};
10use uv_pypi_types::HashAlgorithm;
11use uv_redacted::DisplaySafeUrl;
12use uv_small_str::SmallString;
13
14use crate::exclude_newer::ExcludeNewerOverride;
15use crate::index_name::{IndexName, IndexNameError};
16use crate::origin::Origin;
17use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode};
18
19/// Cache control configuration for an index.
20#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Default)]
21#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
22pub struct IndexCacheControl {
23    /// Cache control header for Simple API requests.
24    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
25    pub(crate) api: Option<HeaderValue>,
26    /// Cache control header for file downloads.
27    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
28    pub(crate) files: Option<HeaderValue>,
29}
30
31impl IndexCacheControl {
32    /// Return the default Simple API cache control headers for the given index URL, if applicable.
33    fn simple_api_cache_control(_url: &Url) -> Option<HeaderValue> {
34        None
35    }
36
37    /// Return the default files cache control headers for the given index URL, if applicable.
38    fn artifact_cache_control(url: &Url) -> Option<HeaderValue> {
39        let dominated_by_pytorch_or_nvidia = url.host_str().is_some_and(|host| {
40            host.eq_ignore_ascii_case("download.pytorch.org")
41                || host.eq_ignore_ascii_case("pypi.nvidia.com")
42        });
43        if dominated_by_pytorch_or_nvidia {
44            // Some wheels in the PyTorch registry were accidentally uploaded with `no-cache,no-store,must-revalidate`.
45            // The PyTorch team plans to correct this in the future, but in the meantime we override
46            // the cache control headers to allow caching of static files.
47            //
48            // See: https://github.com/pytorch/pytorch/pull/149218
49            //
50            // The same issue applies to files hosted on `pypi.nvidia.com`.
51            Some(HeaderValue::from_static(
52                "max-age=365000000, immutable, public",
53            ))
54        } else {
55            None
56        }
57    }
58}
59
60#[derive(Serialize)]
61#[serde(rename_all = "kebab-case")]
62struct IndexCacheControlRef<'a> {
63    #[serde(skip_serializing_if = "Option::is_none")]
64    api: Option<&'a str>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    files: Option<&'a str>,
67}
68
69impl Serialize for IndexCacheControl {
70    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71    where
72        S: Serializer,
73    {
74        IndexCacheControlRef {
75            api: self.api.as_ref().map(|api| {
76                api.to_str()
77                    .expect("cache-control.api is always parsed from a string")
78            }),
79            files: self.files.as_ref().map(|files| {
80                files
81                    .to_str()
82                    .expect("cache-control.files is always parsed from a string")
83            }),
84        }
85        .serialize(serializer)
86    }
87}
88
89#[derive(Debug, Clone, Deserialize)]
90#[serde(rename_all = "kebab-case")]
91struct IndexCacheControlWire {
92    api: Option<SmallString>,
93    files: Option<SmallString>,
94}
95
96impl<'de> Deserialize<'de> for IndexCacheControl {
97    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98    where
99        D: serde::Deserializer<'de>,
100    {
101        let wire = IndexCacheControlWire::deserialize(deserializer)?;
102
103        let api = wire
104            .api
105            .map(|api| {
106                HeaderValue::from_str(api.as_ref()).map_err(|_| {
107                    serde::de::Error::custom(
108                        "`cache-control.api` must be a valid HTTP header value",
109                    )
110                })
111            })
112            .transpose()?;
113        let files = wire
114            .files
115            .map(|files| {
116                HeaderValue::from_str(files.as_ref()).map_err(|_| {
117                    serde::de::Error::custom(
118                        "`cache-control.files` must be a valid HTTP header value",
119                    )
120                })
121            })
122            .transpose()?;
123
124        Ok(Self { api, files })
125    }
126}
127
128#[derive(Debug, Clone, Serialize)]
129#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
130#[serde(rename_all = "kebab-case")]
131pub struct Index {
132    /// The name of the index.
133    ///
134    /// Index names can be used to reference indexes elsewhere in the configuration. For example,
135    /// you can pin a package to a specific index by name:
136    ///
137    /// ```toml
138    /// [[tool.uv.index]]
139    /// name = "pytorch"
140    /// url = "https://download.pytorch.org/whl/cu130"
141    ///
142    /// [tool.uv.sources]
143    /// torch = { index = "pytorch" }
144    /// ```
145    pub name: Option<IndexName>,
146    /// The URL of the index.
147    ///
148    /// Expects to receive a URL (e.g., `https://pypi.org/simple`) or a local path.
149    pub url: IndexUrl,
150    /// Mark the index as explicit.
151    ///
152    /// Explicit indexes will _only_ be used when explicitly requested via a `[tool.uv.sources]`
153    /// definition, as in:
154    ///
155    /// ```toml
156    /// [[tool.uv.index]]
157    /// name = "pytorch"
158    /// url = "https://download.pytorch.org/whl/cu130"
159    /// explicit = true
160    ///
161    /// [tool.uv.sources]
162    /// torch = { index = "pytorch" }
163    /// ```
164    #[serde(default)]
165    pub explicit: bool,
166    /// Mark the index as the default index.
167    ///
168    /// By default, uv uses PyPI as the default index, such that even if additional indexes are
169    /// defined via `[[tool.uv.index]]`, PyPI will still be used as a fallback for packages that
170    /// aren't found elsewhere. To disable the PyPI default, set `default = true` on at least one
171    /// other index.
172    ///
173    /// Marking an index as default will move it to the front of the list of indexes, such that it
174    /// is given the highest priority when resolving packages.
175    #[serde(default)]
176    pub default: bool,
177    /// The origin of the index (e.g., a CLI flag, a user-level configuration file, etc.).
178    #[serde(skip)]
179    pub origin: Option<Origin>,
180    /// The format used by the index.
181    ///
182    /// Indexes can either be PEP 503-compliant (i.e., a PyPI-style registry implementing the Simple
183    /// API) or structured as a flat list of distributions (e.g., `--find-links`). In both cases,
184    /// indexes can point to either local or remote resources.
185    #[serde(default)]
186    pub format: IndexFormat,
187    /// The URL of the upload endpoint.
188    ///
189    /// When using `uv publish --index <name>`, this URL is used for publishing.
190    ///
191    /// A configuration for the default index PyPI would look as follows:
192    ///
193    /// ```toml
194    /// [[tool.uv.index]]
195    /// name = "pypi"
196    /// url = "https://pypi.org/simple"
197    /// publish-url = "https://upload.pypi.org/legacy/"
198    /// ```
199    pub publish_url: Option<DisplaySafeUrl>,
200    /// When uv should use authentication for requests to the index.
201    ///
202    /// ```toml
203    /// [[tool.uv.index]]
204    /// name = "my-index"
205    /// url = "https://<omitted>/simple"
206    /// authenticate = "always"
207    /// ```
208    #[serde(default)]
209    pub authenticate: AuthPolicy,
210    /// Status codes that uv should ignore when deciding whether to continue resolution after a
211    /// request to this index fails.
212    ///
213    /// ```toml
214    /// [[tool.uv.index]]
215    /// name = "my-index"
216    /// url = "https://<omitted>/simple"
217    /// ignore-error-codes = [401, 403]
218    /// ```
219    #[serde(default)]
220    pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
221    /// Cache control configuration for this index.
222    ///
223    /// When set, these headers will override the server's cache control headers
224    /// for both package metadata requests and artifact downloads.
225    ///
226    /// ```toml
227    /// [[tool.uv.index]]
228    /// name = "my-index"
229    /// url = "https://<omitted>/simple"
230    /// cache-control = { api = "max-age=600", files = "max-age=3600" }
231    /// ```
232    #[serde(default)]
233    pub cache_control: Option<IndexCacheControl>,
234    /// The hash algorithm that must be used for distributions resolved from this index.
235    ///
236    /// If a distribution does not advertise a hash using this algorithm, lockfile generation
237    /// will fail.
238    ///
239    /// This option is in preview and may change in any future release.
240    ///
241    /// ```toml
242    /// [[tool.uv.index]]
243    /// name = "my-index"
244    /// url = "https://<omitted>/simple"
245    /// hash-algorithm = "sha256"
246    /// ```
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub hash_algorithm: Option<IndexHashAlgorithm>,
249    /// An index-specific `exclude-newer` cutoff.
250    ///
251    /// Accepts the same date, timestamp, and duration values as the global `exclude-newer`
252    /// setting. Set this to `false` to disable `exclude-newer` for this index entirely.
253    ///
254    /// When set to a value, packages resolved from this index will use that cutoff instead of the
255    /// globally-specified value, unless a package-specific `exclude-newer-package` override is
256    /// present.
257    ///
258    /// This option is in preview and may change in any future release.
259    ///
260    /// ```toml
261    /// [tool.uv]
262    /// exclude-newer = "2025-01-01T00:00:00Z"
263    ///
264    /// [[tool.uv.index]]
265    /// name = "internal"
266    /// url = "https://internal.example.com/simple"
267    /// exclude-newer = "7 days"
268    /// ```
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    #[cfg_attr(feature = "schemars", schemars(with = "ExcludeNewerOverride"))]
271    pub exclude_newer: Option<ExcludeNewerOverride>,
272}
273
274#[derive(Debug, Error)]
275#[error("Failed to parse credentials in index URL: {url}")]
276pub struct IndexCredentialsError {
277    url: DisplaySafeUrl,
278    #[source]
279    source: CredentialsFromUrlError,
280}
281
282impl PartialEq for Index {
283    fn eq(&self, other: &Self) -> bool {
284        let Self {
285            name,
286            url,
287            explicit,
288            default,
289            origin: _,
290            format,
291            publish_url,
292            authenticate,
293            ignore_error_codes,
294            cache_control,
295            hash_algorithm,
296            exclude_newer,
297        } = self;
298        *url == other.url
299            && *name == other.name
300            && *explicit == other.explicit
301            && *default == other.default
302            && *format == other.format
303            && *publish_url == other.publish_url
304            && *authenticate == other.authenticate
305            && *ignore_error_codes == other.ignore_error_codes
306            && *cache_control == other.cache_control
307            && *hash_algorithm == other.hash_algorithm
308            && *exclude_newer == other.exclude_newer
309    }
310}
311
312impl Eq for Index {}
313
314impl PartialOrd for Index {
315    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
316        Some(self.cmp(other))
317    }
318}
319
320impl Ord for Index {
321    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
322        let Self {
323            name,
324            url,
325            explicit,
326            default,
327            origin: _,
328            format,
329            publish_url,
330            authenticate,
331            ignore_error_codes,
332            cache_control,
333            hash_algorithm,
334            exclude_newer,
335        } = self;
336        url.cmp(&other.url)
337            .then_with(|| name.cmp(&other.name))
338            .then_with(|| explicit.cmp(&other.explicit))
339            .then_with(|| default.cmp(&other.default))
340            .then_with(|| format.cmp(&other.format))
341            .then_with(|| publish_url.cmp(&other.publish_url))
342            .then_with(|| authenticate.cmp(&other.authenticate))
343            .then_with(|| ignore_error_codes.cmp(&other.ignore_error_codes))
344            .then_with(|| cache_control.cmp(&other.cache_control))
345            .then_with(|| hash_algorithm.cmp(&other.hash_algorithm))
346            .then_with(|| exclude_newer.cmp(&other.exclude_newer))
347    }
348}
349
350impl std::hash::Hash for Index {
351    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
352        let Self {
353            name,
354            url,
355            explicit,
356            default,
357            origin: _,
358            format,
359            publish_url,
360            authenticate,
361            ignore_error_codes,
362            cache_control,
363            hash_algorithm,
364            exclude_newer,
365        } = self;
366        url.hash(state);
367        name.hash(state);
368        explicit.hash(state);
369        default.hash(state);
370        format.hash(state);
371        publish_url.hash(state);
372        authenticate.hash(state);
373        ignore_error_codes.hash(state);
374        cache_control.hash(state);
375        hash_algorithm.hash(state);
376        exclude_newer.hash(state);
377    }
378}
379
380#[derive(
381    Default,
382    Debug,
383    Copy,
384    Clone,
385    Hash,
386    Eq,
387    PartialEq,
388    Ord,
389    PartialOrd,
390    serde::Serialize,
391    serde::Deserialize,
392)]
393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
394#[serde(rename_all = "kebab-case")]
395pub enum IndexFormat {
396    /// A PyPI-style index implementing the Simple Repository API.
397    #[default]
398    Simple,
399    /// A `--find-links`-style index containing a flat list of wheels and source distributions.
400    Flat,
401}
402
403/// A hash algorithm that can be required for distributions resolved from an index.
404#[derive(
405    Debug, Copy, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize,
406)]
407#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
408#[serde(rename_all = "lowercase")]
409pub enum IndexHashAlgorithm {
410    Md5,
411    Sha256,
412    Sha384,
413    Sha512,
414    Blake2b,
415}
416
417impl From<IndexHashAlgorithm> for HashAlgorithm {
418    fn from(value: IndexHashAlgorithm) -> Self {
419        match value {
420            IndexHashAlgorithm::Md5 => Self::Md5,
421            IndexHashAlgorithm::Sha256 => Self::Sha256,
422            IndexHashAlgorithm::Sha384 => Self::Sha384,
423            IndexHashAlgorithm::Sha512 => Self::Sha512,
424            IndexHashAlgorithm::Blake2b => Self::Blake2b,
425        }
426    }
427}
428
429impl Index {
430    /// Initialize an [`Index`] from a pip-style `--index-url`.
431    pub fn from_index_url(url: IndexUrl) -> Self {
432        Self {
433            url,
434            name: None,
435            explicit: false,
436            default: true,
437            origin: None,
438            format: IndexFormat::Simple,
439            publish_url: None,
440            authenticate: AuthPolicy::default(),
441            ignore_error_codes: None,
442            cache_control: None,
443            hash_algorithm: None,
444            exclude_newer: None,
445        }
446    }
447
448    /// Initialize an [`Index`] from a pip-style `--extra-index-url`.
449    pub fn from_extra_index_url(url: IndexUrl) -> Self {
450        Self {
451            url,
452            name: None,
453            explicit: false,
454            default: false,
455            origin: None,
456            format: IndexFormat::Simple,
457            publish_url: None,
458            authenticate: AuthPolicy::default(),
459            ignore_error_codes: None,
460            cache_control: None,
461            hash_algorithm: None,
462            exclude_newer: None,
463        }
464    }
465
466    /// Initialize an [`Index`] from a pip-style `--find-links`.
467    pub fn from_find_links(url: IndexUrl) -> Self {
468        Self {
469            url,
470            name: None,
471            explicit: false,
472            default: false,
473            origin: None,
474            format: IndexFormat::Flat,
475            publish_url: None,
476            authenticate: AuthPolicy::default(),
477            ignore_error_codes: None,
478            cache_control: None,
479            hash_algorithm: None,
480            exclude_newer: None,
481        }
482    }
483
484    /// Set the [`Origin`] of the index.
485    #[must_use]
486    pub fn with_origin(mut self, origin: Origin) -> Self {
487        self.origin = Some(origin);
488        self
489    }
490
491    /// Return the [`IndexUrl`] of the index.
492    pub fn url(&self) -> &IndexUrl {
493        &self.url
494    }
495
496    /// Return the raw [`Url`] of the index.
497    pub fn raw_url(&self) -> &DisplaySafeUrl {
498        self.url.url()
499    }
500
501    /// Return the root [`Url`] of the index, if applicable.
502    ///
503    /// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
504    /// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
505    pub fn root_url(&self) -> Option<DisplaySafeUrl> {
506        self.url.root()
507    }
508
509    /// If credentials are available (via the URL or environment) and [`AuthPolicy`] is
510    /// [`AuthPolicy::Auto`], promote to [`AuthPolicy::Always`] so that future operations
511    /// (e.g., `uv tool upgrade`) know that authentication is required even after the credentials
512    /// are stripped from the stored URL.
513    #[must_use]
514    pub fn with_promoted_auth_policy(mut self) -> Self {
515        if matches!(self.authenticate, AuthPolicy::Auto) && self.has_credentials() {
516            self.authenticate = AuthPolicy::Always;
517        }
518        self
519    }
520
521    /// Return whether credentials are configured for the index.
522    ///
523    /// This only checks for the presence of credentials. It intentionally avoids decoding URL
524    /// credentials, since this is used to preserve the authentication policy after credentials are
525    /// removed from the stored URL; parsing errors are reported when the credentials are retrieved.
526    fn has_credentials(&self) -> bool {
527        if self
528            .name
529            .as_ref()
530            .is_some_and(|name| Credentials::from_env(name.to_env_var()).is_some())
531        {
532            return true;
533        }
534
535        let url = self.url.url();
536        !url.username().is_empty() || url.password().is_some()
537    }
538
539    /// Retrieve the credentials for the index, either from the environment, or from the URL itself.
540    pub fn credentials(&self) -> Result<Option<Credentials>, IndexCredentialsError> {
541        // If the index is named, and credentials are provided via the environment, prefer those.
542        if let Some(name) = self.name.as_ref() {
543            if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
544                return Ok(Some(credentials));
545            }
546        }
547
548        // Otherwise, extract the credentials from the URL.
549        Credentials::from_url(self.url.url()).map_err(|source| IndexCredentialsError {
550            url: self.url.url().clone(),
551            source,
552        })
553    }
554
555    /// Resolve the index relative to the given root directory.
556    pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
557        if let IndexUrl::Path(ref url) = self.url
558            && let Some(given) = url.given()
559        {
560            self.url = IndexUrl::parse(given, Some(root_dir))?;
561        }
562        Ok(self)
563    }
564
565    /// Return the [`IndexStatusCodeStrategy`] for this index.
566    pub(crate) fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
567        if let Some(ignore_error_codes) = &self.ignore_error_codes {
568            IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
569        } else {
570            IndexStatusCodeStrategy::from_index_url(self.url.url())
571        }
572    }
573
574    /// Return whether the given status code is explicitly ignored for this index.
575    pub(crate) fn ignores_error_code(&self, status_code: StatusCode) -> bool {
576        self.ignore_error_codes.as_ref().is_some_and(|codes| {
577            codes
578                .iter()
579                .any(|ignored_status_code| **ignored_status_code == status_code)
580        })
581    }
582
583    /// Return the cache control header for file requests to this index, if any.
584    pub(crate) fn artifact_cache_control(&self) -> Option<HeaderValue> {
585        self.cache_control
586            .as_ref()
587            .and_then(|cache_control| cache_control.files.clone())
588            .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
589    }
590
591    /// Return the cache control header for API requests to this index, if any.
592    pub(crate) fn simple_api_cache_control(&self) -> Option<HeaderValue> {
593        self.cache_control
594            .as_ref()
595            .and_then(|cache_control| cache_control.api.clone())
596            .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
597    }
598
599    /// Return the `exclude-newer` setting for this index.
600    pub(crate) fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
601        self.exclude_newer.as_ref()
602    }
603}
604
605impl From<IndexUrl> for Index {
606    fn from(value: IndexUrl) -> Self {
607        Self {
608            name: None,
609            url: value,
610            explicit: false,
611            default: false,
612            origin: None,
613            format: IndexFormat::Simple,
614            publish_url: None,
615            authenticate: AuthPolicy::default(),
616            ignore_error_codes: None,
617            cache_control: None,
618            hash_algorithm: None,
619            exclude_newer: None,
620        }
621    }
622}
623
624impl FromStr for Index {
625    type Err = IndexSourceError;
626
627    fn from_str(s: &str) -> Result<Self, Self::Err> {
628        // Determine whether the source is prefixed with a name, as in `name=https://pypi.org/simple`.
629        if let Some((name, url)) = s.split_once('=')
630            && !name.chars().any(|c| c == ':')
631        {
632            let name = IndexName::from_str(name)?;
633            let url = IndexUrl::from_str(url)?;
634            return Ok(Self {
635                name: Some(name),
636                url,
637                explicit: false,
638                default: false,
639                origin: None,
640                format: IndexFormat::Simple,
641                publish_url: None,
642                authenticate: AuthPolicy::default(),
643                ignore_error_codes: None,
644                cache_control: None,
645                hash_algorithm: None,
646                exclude_newer: None,
647            });
648        }
649
650        // Otherwise, assume the source is a URL.
651        let url = IndexUrl::from_str(s)?;
652        Ok(Self {
653            name: None,
654            url,
655            explicit: false,
656            default: false,
657            origin: None,
658            format: IndexFormat::Simple,
659            publish_url: None,
660            authenticate: AuthPolicy::default(),
661            ignore_error_codes: None,
662            cache_control: None,
663            hash_algorithm: None,
664            exclude_newer: None,
665        })
666    }
667}
668
669/// An [`IndexUrl`] along with the metadata necessary to query the index.
670#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
671pub struct IndexMetadata {
672    /// The URL of the index.
673    pub url: IndexUrl,
674    /// The format used by the index.
675    pub format: IndexFormat,
676}
677
678impl IndexMetadata {
679    /// Consume the [`IndexMetadata`] and return the [`IndexUrl`].
680    pub fn into_url(self) -> IndexUrl {
681        self.url
682    }
683}
684
685/// A reference to an [`IndexMetadata`].
686#[derive(Debug, Copy, Clone)]
687pub struct IndexMetadataRef<'a> {
688    /// The URL of the index.
689    pub url: &'a IndexUrl,
690    /// The format used by the index.
691    pub format: IndexFormat,
692}
693
694impl IndexMetadata {
695    /// Return the [`IndexUrl`] of the index.
696    pub fn url(&self) -> &IndexUrl {
697        &self.url
698    }
699}
700
701impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
702    fn from(value: &'a Index) -> Self {
703        Self {
704            url: &value.url,
705            format: value.format,
706        }
707    }
708}
709
710impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
711    fn from(value: &'a IndexMetadata) -> Self {
712        Self {
713            url: &value.url,
714            format: value.format,
715        }
716    }
717}
718
719impl From<IndexUrl> for IndexMetadata {
720    fn from(value: IndexUrl) -> Self {
721        Self {
722            url: value,
723            format: IndexFormat::Simple,
724        }
725    }
726}
727
728impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
729    fn from(value: &'a IndexUrl) -> Self {
730        Self {
731            url: value,
732            format: IndexFormat::Simple,
733        }
734    }
735}
736
737/// Wire type for deserializing an [`Index`] with validation.
738#[derive(Deserialize)]
739#[serde(rename_all = "kebab-case")]
740struct IndexWire {
741    name: Option<IndexName>,
742    url: IndexUrl,
743    #[serde(default)]
744    explicit: bool,
745    #[serde(default)]
746    default: bool,
747    #[serde(default)]
748    format: IndexFormat,
749    publish_url: Option<DisplaySafeUrl>,
750    #[serde(default)]
751    authenticate: AuthPolicy,
752    #[serde(default)]
753    ignore_error_codes: Option<Vec<SerializableStatusCode>>,
754    #[serde(default)]
755    cache_control: Option<IndexCacheControl>,
756    #[serde(default)]
757    hash_algorithm: Option<IndexHashAlgorithm>,
758    #[serde(default)]
759    exclude_newer: Option<ExcludeNewerOverride>,
760}
761
762impl<'de> Deserialize<'de> for Index {
763    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
764    where
765        D: serde::Deserializer<'de>,
766    {
767        let wire = IndexWire::deserialize(deserializer)?;
768
769        if wire.explicit && wire.name.is_none() {
770            return Err(serde::de::Error::custom(format!(
771                "An index with `explicit = true` requires a `name`: {}",
772                wire.url
773            )));
774        }
775
776        Ok(Self {
777            name: wire.name,
778            url: wire.url,
779            explicit: wire.explicit,
780            default: wire.default,
781            origin: None,
782            format: wire.format,
783            publish_url: wire.publish_url,
784            authenticate: wire.authenticate,
785            ignore_error_codes: wire.ignore_error_codes,
786            cache_control: wire.cache_control,
787            hash_algorithm: wire.hash_algorithm,
788            exclude_newer: wire.exclude_newer,
789        })
790    }
791}
792
793/// An error that can occur when parsing an [`Index`].
794#[derive(Error, Debug)]
795pub enum IndexSourceError {
796    #[error(transparent)]
797    Url(#[from] IndexUrlError),
798    #[error(transparent)]
799    IndexName(#[from] IndexNameError),
800    #[error("Index included a name, but the name was empty")]
801    EmptyName,
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use http::HeaderValue;
808
809    #[test]
810    fn test_index_cache_control_headers() {
811        // Test that cache control headers are properly parsed from TOML
812        let toml_str = r#"
813            name = "test-index"
814            url = "https://test.example.com/simple"
815            cache-control = { api = "max-age=600", files = "max-age=3600" }
816        "#;
817
818        let index: Index = toml::from_str(toml_str).unwrap();
819        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
820        assert!(index.cache_control.is_some());
821        assert_eq!(index.exclude_newer, None);
822        let cache_control = index.cache_control.as_ref().unwrap();
823        assert_eq!(
824            cache_control.api,
825            Some(HeaderValue::from_static("max-age=600"))
826        );
827        assert_eq!(
828            cache_control.files,
829            Some(HeaderValue::from_static("max-age=3600"))
830        );
831    }
832
833    #[test]
834    fn test_index_without_cache_control() {
835        // Test that indexes work without cache control headers
836        let toml_str = r#"
837            name = "test-index"
838            url = "https://test.example.com/simple"
839        "#;
840
841        let index: Index = toml::from_str(toml_str).unwrap();
842        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
843        assert_eq!(index.cache_control, None);
844        assert_eq!(index.exclude_newer, None);
845    }
846
847    #[test]
848    fn test_index_partial_cache_control() {
849        // Test that cache control can have just one field
850        let toml_str = r#"
851            name = "test-index"
852            url = "https://test.example.com/simple"
853            cache-control = { api = "max-age=300" }
854        "#;
855
856        let index: Index = toml::from_str(toml_str).unwrap();
857        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
858        assert!(index.cache_control.is_some());
859        assert_eq!(index.exclude_newer, None);
860        let cache_control = index.cache_control.as_ref().unwrap();
861        assert_eq!(
862            cache_control.api,
863            Some(HeaderValue::from_static("max-age=300"))
864        );
865        assert_eq!(cache_control.files, None);
866    }
867
868    #[test]
869    fn test_index_invalid_api_cache_control() {
870        let toml_str = r#"
871            name = "test-index"
872            url = "https://test.example.com/simple"
873            cache-control = { api = "max-age=600\n" }
874        "#;
875
876        let err = toml::from_str::<Index>(toml_str).unwrap_err();
877        assert!(
878            err.to_string()
879                .contains("`cache-control.api` must be a valid HTTP header value")
880        );
881    }
882
883    #[test]
884    fn test_index_invalid_files_cache_control() {
885        let toml_str = r#"
886            name = "test-index"
887            url = "https://test.example.com/simple"
888            cache-control = { files = "max-age=3600\n" }
889        "#;
890
891        let err = toml::from_str::<Index>(toml_str).unwrap_err();
892        assert!(
893            err.to_string()
894                .contains("`cache-control.files` must be a valid HTTP header value")
895        );
896    }
897
898    #[test]
899    fn test_index_exclude_newer_disable() {
900        let toml_str = r#"
901            name = "internal"
902            url = "https://internal.example.com/simple"
903            exclude-newer = false
904        "#;
905
906        let index: Index = toml::from_str(toml_str).unwrap();
907        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
908        assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
909    }
910
911    #[test]
912    fn test_index_exclude_newer_relative() {
913        let toml_str = r#"
914            name = "internal"
915            url = "https://internal.example.com/simple"
916            exclude-newer = "7 days"
917        "#;
918
919        let index: Index = toml::from_str(toml_str).unwrap();
920        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
921        assert!(matches!(
922            index.exclude_newer,
923            Some(ExcludeNewerOverride::Enabled(_))
924        ));
925    }
926}