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, CredentialsFromUrlError};
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
258#[derive(Debug, Error)]
259#[error("Failed to parse credentials in index URL: {url}")]
260pub struct IndexCredentialsError {
261    url: DisplaySafeUrl,
262    #[source]
263    source: CredentialsFromUrlError,
264}
265
266impl PartialEq for Index {
267    fn eq(&self, other: &Self) -> bool {
268        let Self {
269            name,
270            url,
271            explicit,
272            default,
273            origin: _,
274            format,
275            publish_url,
276            authenticate,
277            ignore_error_codes,
278            cache_control,
279            exclude_newer,
280        } = self;
281        *url == other.url
282            && *name == other.name
283            && *explicit == other.explicit
284            && *default == other.default
285            && *format == other.format
286            && *publish_url == other.publish_url
287            && *authenticate == other.authenticate
288            && *ignore_error_codes == other.ignore_error_codes
289            && *cache_control == other.cache_control
290            && *exclude_newer == other.exclude_newer
291    }
292}
293
294impl Eq for Index {}
295
296impl PartialOrd for Index {
297    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
298        Some(self.cmp(other))
299    }
300}
301
302impl Ord for Index {
303    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
304        let Self {
305            name,
306            url,
307            explicit,
308            default,
309            origin: _,
310            format,
311            publish_url,
312            authenticate,
313            ignore_error_codes,
314            cache_control,
315            exclude_newer,
316        } = self;
317        url.cmp(&other.url)
318            .then_with(|| name.cmp(&other.name))
319            .then_with(|| explicit.cmp(&other.explicit))
320            .then_with(|| default.cmp(&other.default))
321            .then_with(|| format.cmp(&other.format))
322            .then_with(|| publish_url.cmp(&other.publish_url))
323            .then_with(|| authenticate.cmp(&other.authenticate))
324            .then_with(|| ignore_error_codes.cmp(&other.ignore_error_codes))
325            .then_with(|| cache_control.cmp(&other.cache_control))
326            .then_with(|| exclude_newer.cmp(&other.exclude_newer))
327    }
328}
329
330impl std::hash::Hash for Index {
331    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
332        let Self {
333            name,
334            url,
335            explicit,
336            default,
337            origin: _,
338            format,
339            publish_url,
340            authenticate,
341            ignore_error_codes,
342            cache_control,
343            exclude_newer,
344        } = self;
345        url.hash(state);
346        name.hash(state);
347        explicit.hash(state);
348        default.hash(state);
349        format.hash(state);
350        publish_url.hash(state);
351        authenticate.hash(state);
352        ignore_error_codes.hash(state);
353        cache_control.hash(state);
354        exclude_newer.hash(state);
355    }
356}
357
358#[derive(
359    Default,
360    Debug,
361    Copy,
362    Clone,
363    Hash,
364    Eq,
365    PartialEq,
366    Ord,
367    PartialOrd,
368    serde::Serialize,
369    serde::Deserialize,
370)]
371#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
372#[serde(rename_all = "kebab-case")]
373pub enum IndexFormat {
374    /// A PyPI-style index implementing the Simple Repository API.
375    #[default]
376    Simple,
377    /// A `--find-links`-style index containing a flat list of wheels and source distributions.
378    Flat,
379}
380
381impl Index {
382    /// Initialize an [`Index`] from a pip-style `--index-url`.
383    pub fn from_index_url(url: IndexUrl) -> Self {
384        Self {
385            url,
386            name: None,
387            explicit: false,
388            default: true,
389            origin: None,
390            format: IndexFormat::Simple,
391            publish_url: None,
392            authenticate: AuthPolicy::default(),
393            ignore_error_codes: None,
394            cache_control: None,
395            exclude_newer: None,
396        }
397    }
398
399    /// Initialize an [`Index`] from a pip-style `--extra-index-url`.
400    pub fn from_extra_index_url(url: IndexUrl) -> Self {
401        Self {
402            url,
403            name: None,
404            explicit: false,
405            default: false,
406            origin: None,
407            format: IndexFormat::Simple,
408            publish_url: None,
409            authenticate: AuthPolicy::default(),
410            ignore_error_codes: None,
411            cache_control: None,
412            exclude_newer: None,
413        }
414    }
415
416    /// Initialize an [`Index`] from a pip-style `--find-links`.
417    pub fn from_find_links(url: IndexUrl) -> Self {
418        Self {
419            url,
420            name: None,
421            explicit: false,
422            default: false,
423            origin: None,
424            format: IndexFormat::Flat,
425            publish_url: None,
426            authenticate: AuthPolicy::default(),
427            ignore_error_codes: None,
428            cache_control: None,
429            exclude_newer: None,
430        }
431    }
432
433    /// Set the [`Origin`] of the index.
434    #[must_use]
435    pub fn with_origin(mut self, origin: Origin) -> Self {
436        self.origin = Some(origin);
437        self
438    }
439
440    /// Return the [`IndexUrl`] of the index.
441    pub fn url(&self) -> &IndexUrl {
442        &self.url
443    }
444
445    /// Return the raw [`Url`] of the index.
446    pub fn raw_url(&self) -> &DisplaySafeUrl {
447        self.url.url()
448    }
449
450    /// Return the root [`Url`] of the index, if applicable.
451    ///
452    /// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
453    /// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
454    pub fn root_url(&self) -> Option<DisplaySafeUrl> {
455        self.url.root()
456    }
457
458    /// If credentials are available (via the URL or environment) and [`AuthPolicy`] is
459    /// [`AuthPolicy::Auto`], promote to [`AuthPolicy::Always`] so that future operations
460    /// (e.g., `uv tool upgrade`) know that authentication is required even after the credentials
461    /// are stripped from the stored URL.
462    #[must_use]
463    pub fn with_promoted_auth_policy(mut self) -> Self {
464        if matches!(self.authenticate, AuthPolicy::Auto) && self.has_credentials() {
465            self.authenticate = AuthPolicy::Always;
466        }
467        self
468    }
469
470    /// Return whether credentials are configured for the index.
471    ///
472    /// This only checks for the presence of credentials. It intentionally avoids decoding URL
473    /// credentials, since this is used to preserve the authentication policy after credentials are
474    /// removed from the stored URL; parsing errors are reported when the credentials are retrieved.
475    fn has_credentials(&self) -> bool {
476        if self
477            .name
478            .as_ref()
479            .is_some_and(|name| Credentials::from_env(name.to_env_var()).is_some())
480        {
481            return true;
482        }
483
484        let url = self.url.url();
485        !url.username().is_empty() || url.password().is_some()
486    }
487
488    /// Retrieve the credentials for the index, either from the environment, or from the URL itself.
489    pub fn credentials(&self) -> Result<Option<Credentials>, IndexCredentialsError> {
490        // If the index is named, and credentials are provided via the environment, prefer those.
491        if let Some(name) = self.name.as_ref() {
492            if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
493                return Ok(Some(credentials));
494            }
495        }
496
497        // Otherwise, extract the credentials from the URL.
498        Credentials::from_url(self.url.url()).map_err(|source| IndexCredentialsError {
499            url: self.url.url().clone(),
500            source,
501        })
502    }
503
504    /// Resolve the index relative to the given root directory.
505    pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
506        if let IndexUrl::Path(ref url) = self.url
507            && let Some(given) = url.given()
508        {
509            self.url = IndexUrl::parse(given, Some(root_dir))?;
510        }
511        Ok(self)
512    }
513
514    /// Return the [`IndexStatusCodeStrategy`] for this index.
515    pub(crate) fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
516        if let Some(ignore_error_codes) = &self.ignore_error_codes {
517            IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
518        } else {
519            IndexStatusCodeStrategy::from_index_url(self.url.url())
520        }
521    }
522
523    /// Return the cache control header for file requests to this index, if any.
524    pub(crate) fn artifact_cache_control(&self) -> Option<HeaderValue> {
525        self.cache_control
526            .as_ref()
527            .and_then(|cache_control| cache_control.files.clone())
528            .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
529    }
530
531    /// Return the cache control header for API requests to this index, if any.
532    pub(crate) fn simple_api_cache_control(&self) -> Option<HeaderValue> {
533        self.cache_control
534            .as_ref()
535            .and_then(|cache_control| cache_control.api.clone())
536            .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
537    }
538
539    /// Return the `exclude-newer` setting for this index.
540    pub(crate) fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
541        self.exclude_newer.as_ref()
542    }
543}
544
545impl From<IndexUrl> for Index {
546    fn from(value: IndexUrl) -> Self {
547        Self {
548            name: None,
549            url: value,
550            explicit: false,
551            default: false,
552            origin: None,
553            format: IndexFormat::Simple,
554            publish_url: None,
555            authenticate: AuthPolicy::default(),
556            ignore_error_codes: None,
557            cache_control: None,
558            exclude_newer: None,
559        }
560    }
561}
562
563impl FromStr for Index {
564    type Err = IndexSourceError;
565
566    fn from_str(s: &str) -> Result<Self, Self::Err> {
567        // Determine whether the source is prefixed with a name, as in `name=https://pypi.org/simple`.
568        if let Some((name, url)) = s.split_once('=')
569            && !name.chars().any(|c| c == ':')
570        {
571            let name = IndexName::from_str(name)?;
572            let url = IndexUrl::from_str(url)?;
573            return Ok(Self {
574                name: Some(name),
575                url,
576                explicit: false,
577                default: false,
578                origin: None,
579                format: IndexFormat::Simple,
580                publish_url: None,
581                authenticate: AuthPolicy::default(),
582                ignore_error_codes: None,
583                cache_control: None,
584                exclude_newer: None,
585            });
586        }
587
588        // Otherwise, assume the source is a URL.
589        let url = IndexUrl::from_str(s)?;
590        Ok(Self {
591            name: None,
592            url,
593            explicit: false,
594            default: false,
595            origin: None,
596            format: IndexFormat::Simple,
597            publish_url: None,
598            authenticate: AuthPolicy::default(),
599            ignore_error_codes: None,
600            cache_control: None,
601            exclude_newer: None,
602        })
603    }
604}
605
606/// An [`IndexUrl`] along with the metadata necessary to query the index.
607#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
608pub struct IndexMetadata {
609    /// The URL of the index.
610    pub url: IndexUrl,
611    /// The format used by the index.
612    pub format: IndexFormat,
613}
614
615impl IndexMetadata {
616    /// Consume the [`IndexMetadata`] and return the [`IndexUrl`].
617    pub fn into_url(self) -> IndexUrl {
618        self.url
619    }
620}
621
622/// A reference to an [`IndexMetadata`].
623#[derive(Debug, Copy, Clone)]
624pub struct IndexMetadataRef<'a> {
625    /// The URL of the index.
626    pub url: &'a IndexUrl,
627    /// The format used by the index.
628    pub format: IndexFormat,
629}
630
631impl IndexMetadata {
632    /// Return the [`IndexUrl`] of the index.
633    pub fn url(&self) -> &IndexUrl {
634        &self.url
635    }
636}
637
638impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
639    fn from(value: &'a Index) -> Self {
640        Self {
641            url: &value.url,
642            format: value.format,
643        }
644    }
645}
646
647impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
648    fn from(value: &'a IndexMetadata) -> Self {
649        Self {
650            url: &value.url,
651            format: value.format,
652        }
653    }
654}
655
656impl From<IndexUrl> for IndexMetadata {
657    fn from(value: IndexUrl) -> Self {
658        Self {
659            url: value,
660            format: IndexFormat::Simple,
661        }
662    }
663}
664
665impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
666    fn from(value: &'a IndexUrl) -> Self {
667        Self {
668            url: value,
669            format: IndexFormat::Simple,
670        }
671    }
672}
673
674/// Wire type for deserializing an [`Index`] with validation.
675#[derive(Deserialize)]
676#[serde(rename_all = "kebab-case")]
677struct IndexWire {
678    name: Option<IndexName>,
679    url: IndexUrl,
680    #[serde(default)]
681    explicit: bool,
682    #[serde(default)]
683    default: bool,
684    #[serde(default)]
685    format: IndexFormat,
686    publish_url: Option<DisplaySafeUrl>,
687    #[serde(default)]
688    authenticate: AuthPolicy,
689    #[serde(default)]
690    ignore_error_codes: Option<Vec<SerializableStatusCode>>,
691    #[serde(default)]
692    cache_control: Option<IndexCacheControl>,
693    #[serde(default)]
694    exclude_newer: Option<ExcludeNewerOverride>,
695}
696
697impl<'de> Deserialize<'de> for Index {
698    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
699    where
700        D: serde::Deserializer<'de>,
701    {
702        let wire = IndexWire::deserialize(deserializer)?;
703
704        if wire.explicit && wire.name.is_none() {
705            return Err(serde::de::Error::custom(format!(
706                "An index with `explicit = true` requires a `name`: {}",
707                wire.url
708            )));
709        }
710
711        Ok(Self {
712            name: wire.name,
713            url: wire.url,
714            explicit: wire.explicit,
715            default: wire.default,
716            origin: None,
717            format: wire.format,
718            publish_url: wire.publish_url,
719            authenticate: wire.authenticate,
720            ignore_error_codes: wire.ignore_error_codes,
721            cache_control: wire.cache_control,
722            exclude_newer: wire.exclude_newer,
723        })
724    }
725}
726
727/// An error that can occur when parsing an [`Index`].
728#[derive(Error, Debug)]
729pub enum IndexSourceError {
730    #[error(transparent)]
731    Url(#[from] IndexUrlError),
732    #[error(transparent)]
733    IndexName(#[from] IndexNameError),
734    #[error("Index included a name, but the name was empty")]
735    EmptyName,
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use http::HeaderValue;
742
743    #[test]
744    fn test_index_cache_control_headers() {
745        // Test that cache control headers are properly parsed from TOML
746        let toml_str = r#"
747            name = "test-index"
748            url = "https://test.example.com/simple"
749            cache-control = { api = "max-age=600", files = "max-age=3600" }
750        "#;
751
752        let index: Index = toml::from_str(toml_str).unwrap();
753        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
754        assert!(index.cache_control.is_some());
755        assert_eq!(index.exclude_newer, None);
756        let cache_control = index.cache_control.as_ref().unwrap();
757        assert_eq!(
758            cache_control.api,
759            Some(HeaderValue::from_static("max-age=600"))
760        );
761        assert_eq!(
762            cache_control.files,
763            Some(HeaderValue::from_static("max-age=3600"))
764        );
765    }
766
767    #[test]
768    fn test_index_without_cache_control() {
769        // Test that indexes work without cache control headers
770        let toml_str = r#"
771            name = "test-index"
772            url = "https://test.example.com/simple"
773        "#;
774
775        let index: Index = toml::from_str(toml_str).unwrap();
776        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
777        assert_eq!(index.cache_control, None);
778        assert_eq!(index.exclude_newer, None);
779    }
780
781    #[test]
782    fn test_index_partial_cache_control() {
783        // Test that cache control can have just one field
784        let toml_str = r#"
785            name = "test-index"
786            url = "https://test.example.com/simple"
787            cache-control = { api = "max-age=300" }
788        "#;
789
790        let index: Index = toml::from_str(toml_str).unwrap();
791        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
792        assert!(index.cache_control.is_some());
793        assert_eq!(index.exclude_newer, None);
794        let cache_control = index.cache_control.as_ref().unwrap();
795        assert_eq!(
796            cache_control.api,
797            Some(HeaderValue::from_static("max-age=300"))
798        );
799        assert_eq!(cache_control.files, None);
800    }
801
802    #[test]
803    fn test_index_invalid_api_cache_control() {
804        let toml_str = r#"
805            name = "test-index"
806            url = "https://test.example.com/simple"
807            cache-control = { api = "max-age=600\n" }
808        "#;
809
810        let err = toml::from_str::<Index>(toml_str).unwrap_err();
811        assert!(
812            err.to_string()
813                .contains("`cache-control.api` must be a valid HTTP header value")
814        );
815    }
816
817    #[test]
818    fn test_index_invalid_files_cache_control() {
819        let toml_str = r#"
820            name = "test-index"
821            url = "https://test.example.com/simple"
822            cache-control = { files = "max-age=3600\n" }
823        "#;
824
825        let err = toml::from_str::<Index>(toml_str).unwrap_err();
826        assert!(
827            err.to_string()
828                .contains("`cache-control.files` must be a valid HTTP header value")
829        );
830    }
831
832    #[test]
833    fn test_index_exclude_newer_disable() {
834        let toml_str = r#"
835            name = "internal"
836            url = "https://internal.example.com/simple"
837            exclude-newer = false
838        "#;
839
840        let index: Index = toml::from_str(toml_str).unwrap();
841        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
842        assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
843    }
844
845    #[test]
846    fn test_index_exclude_newer_relative() {
847        let toml_str = r#"
848            name = "internal"
849            url = "https://internal.example.com/simple"
850            exclude-newer = "7 days"
851        "#;
852
853        let index: Index = toml::from_str(toml_str).unwrap();
854        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
855        assert!(matches!(
856            index.exclude_newer,
857            Some(ExcludeNewerOverride::Enabled(_))
858        ));
859    }
860}