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 api: Option<HeaderValue>,
25    /// Cache control header for file downloads.
26    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
27    pub files: Option<HeaderValue>,
28}
29
30impl IndexCacheControl {
31    /// Return the default Simple API cache control headers for the given index URL, if applicable.
32    pub 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    pub 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/cu121"
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/cu121"
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    /// Consume the [`Index`] and return the [`IndexUrl`].
438    pub fn into_url(self) -> IndexUrl {
439        self.url
440    }
441
442    /// Return the raw [`Url`] of the index.
443    pub fn raw_url(&self) -> &DisplaySafeUrl {
444        self.url.url()
445    }
446
447    /// Return the root [`Url`] of the index, if applicable.
448    ///
449    /// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
450    /// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
451    pub fn root_url(&self) -> Option<DisplaySafeUrl> {
452        self.url.root()
453    }
454
455    /// If credentials are available (via the URL or environment) and [`AuthPolicy`] is
456    /// [`AuthPolicy::Auto`], promote to [`AuthPolicy::Always`] so that future operations
457    /// (e.g., `uv tool upgrade`) know that authentication is required even after the credentials
458    /// are stripped from the stored URL.
459    #[must_use]
460    pub fn with_promoted_auth_policy(mut self) -> Self {
461        if matches!(self.authenticate, AuthPolicy::Auto) && self.credentials().is_some() {
462            self.authenticate = AuthPolicy::Always;
463        }
464        self
465    }
466
467    /// Retrieve the credentials for the index, either from the environment, or from the URL itself.
468    pub fn credentials(&self) -> Option<Credentials> {
469        // If the index is named, and credentials are provided via the environment, prefer those.
470        if let Some(name) = self.name.as_ref() {
471            if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
472                return Some(credentials);
473            }
474        }
475
476        // Otherwise, extract the credentials from the URL.
477        Credentials::from_url(self.url.url())
478    }
479
480    /// Resolve the index relative to the given root directory.
481    pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
482        if let IndexUrl::Path(ref url) = self.url {
483            if let Some(given) = url.given() {
484                self.url = IndexUrl::parse(given, Some(root_dir))?;
485            }
486        }
487        Ok(self)
488    }
489
490    /// Return the [`IndexStatusCodeStrategy`] for this index.
491    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
492        if let Some(ignore_error_codes) = &self.ignore_error_codes {
493            IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
494        } else {
495            IndexStatusCodeStrategy::from_index_url(self.url.url())
496        }
497    }
498
499    /// Return the cache control header for file requests to this index, if any.
500    pub fn artifact_cache_control(&self) -> Option<HeaderValue> {
501        self.cache_control
502            .as_ref()
503            .and_then(|cache_control| cache_control.files.clone())
504            .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
505    }
506
507    /// Return the cache control header for API requests to this index, if any.
508    pub fn simple_api_cache_control(&self) -> Option<HeaderValue> {
509        self.cache_control
510            .as_ref()
511            .and_then(|cache_control| cache_control.api.clone())
512            .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
513    }
514
515    /// Return the `exclude-newer` setting for this index.
516    pub fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
517        self.exclude_newer.as_ref()
518    }
519}
520
521impl From<IndexUrl> for Index {
522    fn from(value: IndexUrl) -> Self {
523        Self {
524            name: None,
525            url: value,
526            explicit: false,
527            default: false,
528            origin: None,
529            format: IndexFormat::Simple,
530            publish_url: None,
531            authenticate: AuthPolicy::default(),
532            ignore_error_codes: None,
533            cache_control: None,
534            exclude_newer: None,
535        }
536    }
537}
538
539impl FromStr for Index {
540    type Err = IndexSourceError;
541
542    fn from_str(s: &str) -> Result<Self, Self::Err> {
543        // Determine whether the source is prefixed with a name, as in `name=https://pypi.org/simple`.
544        if let Some((name, url)) = s.split_once('=') {
545            if !name.chars().any(|c| c == ':') {
546                let name = IndexName::from_str(name)?;
547                let url = IndexUrl::from_str(url)?;
548                return Ok(Self {
549                    name: Some(name),
550                    url,
551                    explicit: false,
552                    default: false,
553                    origin: None,
554                    format: IndexFormat::Simple,
555                    publish_url: None,
556                    authenticate: AuthPolicy::default(),
557                    ignore_error_codes: None,
558                    cache_control: None,
559                    exclude_newer: None,
560                });
561            }
562        }
563
564        // Otherwise, assume the source is a URL.
565        let url = IndexUrl::from_str(s)?;
566        Ok(Self {
567            name: None,
568            url,
569            explicit: false,
570            default: false,
571            origin: None,
572            format: IndexFormat::Simple,
573            publish_url: None,
574            authenticate: AuthPolicy::default(),
575            ignore_error_codes: None,
576            cache_control: None,
577            exclude_newer: None,
578        })
579    }
580}
581
582/// An [`IndexUrl`] along with the metadata necessary to query the index.
583#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
584pub struct IndexMetadata {
585    /// The URL of the index.
586    pub url: IndexUrl,
587    /// The format used by the index.
588    pub format: IndexFormat,
589}
590
591impl IndexMetadata {
592    /// Return a reference to the [`IndexMetadata`].
593    pub fn as_ref(&self) -> IndexMetadataRef<'_> {
594        let Self { url, format: kind } = self;
595        IndexMetadataRef { url, format: *kind }
596    }
597
598    /// Consume the [`IndexMetadata`] and return the [`IndexUrl`].
599    pub fn into_url(self) -> IndexUrl {
600        self.url
601    }
602}
603
604/// A reference to an [`IndexMetadata`].
605#[derive(Debug, Copy, Clone)]
606pub struct IndexMetadataRef<'a> {
607    /// The URL of the index.
608    pub url: &'a IndexUrl,
609    /// The format used by the index.
610    pub format: IndexFormat,
611}
612
613impl IndexMetadata {
614    /// Return the [`IndexUrl`] of the index.
615    pub fn url(&self) -> &IndexUrl {
616        &self.url
617    }
618}
619
620impl IndexMetadataRef<'_> {
621    /// Return the [`IndexUrl`] of the index.
622    pub fn url(&self) -> &IndexUrl {
623        self.url
624    }
625}
626
627impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
628    fn from(value: &'a Index) -> Self {
629        Self {
630            url: &value.url,
631            format: value.format,
632        }
633    }
634}
635
636impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
637    fn from(value: &'a IndexMetadata) -> Self {
638        Self {
639            url: &value.url,
640            format: value.format,
641        }
642    }
643}
644
645impl From<IndexUrl> for IndexMetadata {
646    fn from(value: IndexUrl) -> Self {
647        Self {
648            url: value,
649            format: IndexFormat::Simple,
650        }
651    }
652}
653
654impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
655    fn from(value: &'a IndexUrl) -> Self {
656        Self {
657            url: value,
658            format: IndexFormat::Simple,
659        }
660    }
661}
662
663/// Wire type for deserializing an [`Index`] with validation.
664#[derive(Deserialize)]
665#[serde(rename_all = "kebab-case")]
666struct IndexWire {
667    name: Option<IndexName>,
668    url: IndexUrl,
669    #[serde(default)]
670    explicit: bool,
671    #[serde(default)]
672    default: bool,
673    #[serde(default)]
674    format: IndexFormat,
675    publish_url: Option<DisplaySafeUrl>,
676    #[serde(default)]
677    authenticate: AuthPolicy,
678    #[serde(default)]
679    ignore_error_codes: Option<Vec<SerializableStatusCode>>,
680    #[serde(default)]
681    cache_control: Option<IndexCacheControl>,
682    #[serde(default)]
683    exclude_newer: Option<ExcludeNewerOverride>,
684}
685
686impl<'de> Deserialize<'de> for Index {
687    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
688    where
689        D: serde::Deserializer<'de>,
690    {
691        let wire = IndexWire::deserialize(deserializer)?;
692
693        if wire.explicit && wire.name.is_none() {
694            return Err(serde::de::Error::custom(format!(
695                "An index with `explicit = true` requires a `name`: {}",
696                wire.url
697            )));
698        }
699
700        Ok(Self {
701            name: wire.name,
702            url: wire.url,
703            explicit: wire.explicit,
704            default: wire.default,
705            origin: None,
706            format: wire.format,
707            publish_url: wire.publish_url,
708            authenticate: wire.authenticate,
709            ignore_error_codes: wire.ignore_error_codes,
710            cache_control: wire.cache_control,
711            exclude_newer: wire.exclude_newer,
712        })
713    }
714}
715
716/// An error that can occur when parsing an [`Index`].
717#[derive(Error, Debug)]
718pub enum IndexSourceError {
719    #[error(transparent)]
720    Url(#[from] IndexUrlError),
721    #[error(transparent)]
722    IndexName(#[from] IndexNameError),
723    #[error("Index included a name, but the name was empty")]
724    EmptyName,
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use http::HeaderValue;
731
732    #[test]
733    fn test_index_cache_control_headers() {
734        // Test that cache control headers are properly parsed from TOML
735        let toml_str = r#"
736            name = "test-index"
737            url = "https://test.example.com/simple"
738            cache-control = { api = "max-age=600", files = "max-age=3600" }
739        "#;
740
741        let index: Index = toml::from_str(toml_str).unwrap();
742        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
743        assert!(index.cache_control.is_some());
744        assert_eq!(index.exclude_newer, None);
745        let cache_control = index.cache_control.as_ref().unwrap();
746        assert_eq!(
747            cache_control.api,
748            Some(HeaderValue::from_static("max-age=600"))
749        );
750        assert_eq!(
751            cache_control.files,
752            Some(HeaderValue::from_static("max-age=3600"))
753        );
754    }
755
756    #[test]
757    fn test_index_without_cache_control() {
758        // Test that indexes work without cache control headers
759        let toml_str = r#"
760            name = "test-index"
761            url = "https://test.example.com/simple"
762        "#;
763
764        let index: Index = toml::from_str(toml_str).unwrap();
765        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
766        assert_eq!(index.cache_control, None);
767        assert_eq!(index.exclude_newer, None);
768    }
769
770    #[test]
771    fn test_index_partial_cache_control() {
772        // Test that cache control can have just one field
773        let toml_str = r#"
774            name = "test-index"
775            url = "https://test.example.com/simple"
776            cache-control = { api = "max-age=300" }
777        "#;
778
779        let index: Index = toml::from_str(toml_str).unwrap();
780        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
781        assert!(index.cache_control.is_some());
782        assert_eq!(index.exclude_newer, None);
783        let cache_control = index.cache_control.as_ref().unwrap();
784        assert_eq!(
785            cache_control.api,
786            Some(HeaderValue::from_static("max-age=300"))
787        );
788        assert_eq!(cache_control.files, None);
789    }
790
791    #[test]
792    fn test_index_invalid_api_cache_control() {
793        let toml_str = r#"
794            name = "test-index"
795            url = "https://test.example.com/simple"
796            cache-control = { api = "max-age=600\n" }
797        "#;
798
799        let err = toml::from_str::<Index>(toml_str).unwrap_err();
800        assert!(
801            err.to_string()
802                .contains("`cache-control.api` must be a valid HTTP header value")
803        );
804    }
805
806    #[test]
807    fn test_index_invalid_files_cache_control() {
808        let toml_str = r#"
809            name = "test-index"
810            url = "https://test.example.com/simple"
811            cache-control = { files = "max-age=3600\n" }
812        "#;
813
814        let err = toml::from_str::<Index>(toml_str).unwrap_err();
815        assert!(
816            err.to_string()
817                .contains("`cache-control.files` must be a valid HTTP header value")
818        );
819    }
820
821    #[test]
822    fn test_index_exclude_newer_disable() {
823        let toml_str = r#"
824            name = "internal"
825            url = "https://internal.example.com/simple"
826            exclude-newer = false
827        "#;
828
829        let index: Index = toml::from_str(toml_str).unwrap();
830        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
831        assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
832    }
833
834    #[test]
835    fn test_index_exclude_newer_relative() {
836        let toml_str = r#"
837            name = "internal"
838            url = "https://internal.example.com/simple"
839            exclude-newer = "7 days"
840        "#;
841
842        let index: Index = toml::from_str(toml_str).unwrap();
843        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
844        assert!(matches!(
845            index.exclude_newer,
846            Some(ExcludeNewerOverride::Enabled(_))
847        ));
848    }
849}