Skip to main content

uv_distribution_types/
index.rs

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