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_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 to continue resolution after a
210    /// request to this index fails.
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 whether the given status code is explicitly ignored for this index.
524    pub(crate) fn ignores_error_code(&self, status_code: StatusCode) -> bool {
525        self.ignore_error_codes.as_ref().is_some_and(|codes| {
526            codes
527                .iter()
528                .any(|ignored_status_code| **ignored_status_code == status_code)
529        })
530    }
531
532    /// Return the cache control header for file requests to this index, if any.
533    pub(crate) fn artifact_cache_control(&self) -> Option<HeaderValue> {
534        self.cache_control
535            .as_ref()
536            .and_then(|cache_control| cache_control.files.clone())
537            .or_else(|| IndexCacheControl::artifact_cache_control(self.url.url()))
538    }
539
540    /// Return the cache control header for API requests to this index, if any.
541    pub(crate) fn simple_api_cache_control(&self) -> Option<HeaderValue> {
542        self.cache_control
543            .as_ref()
544            .and_then(|cache_control| cache_control.api.clone())
545            .or_else(|| IndexCacheControl::simple_api_cache_control(self.url.url()))
546    }
547
548    /// Return the `exclude-newer` setting for this index.
549    pub(crate) fn exclude_newer(&self) -> Option<&ExcludeNewerOverride> {
550        self.exclude_newer.as_ref()
551    }
552}
553
554impl From<IndexUrl> for Index {
555    fn from(value: IndexUrl) -> Self {
556        Self {
557            name: None,
558            url: value,
559            explicit: false,
560            default: false,
561            origin: None,
562            format: IndexFormat::Simple,
563            publish_url: None,
564            authenticate: AuthPolicy::default(),
565            ignore_error_codes: None,
566            cache_control: None,
567            exclude_newer: None,
568        }
569    }
570}
571
572impl FromStr for Index {
573    type Err = IndexSourceError;
574
575    fn from_str(s: &str) -> Result<Self, Self::Err> {
576        // Determine whether the source is prefixed with a name, as in `name=https://pypi.org/simple`.
577        if let Some((name, url)) = s.split_once('=')
578            && !name.chars().any(|c| c == ':')
579        {
580            let name = IndexName::from_str(name)?;
581            let url = IndexUrl::from_str(url)?;
582            return Ok(Self {
583                name: Some(name),
584                url,
585                explicit: false,
586                default: false,
587                origin: None,
588                format: IndexFormat::Simple,
589                publish_url: None,
590                authenticate: AuthPolicy::default(),
591                ignore_error_codes: None,
592                cache_control: None,
593                exclude_newer: None,
594            });
595        }
596
597        // Otherwise, assume the source is a URL.
598        let url = IndexUrl::from_str(s)?;
599        Ok(Self {
600            name: None,
601            url,
602            explicit: false,
603            default: false,
604            origin: None,
605            format: IndexFormat::Simple,
606            publish_url: None,
607            authenticate: AuthPolicy::default(),
608            ignore_error_codes: None,
609            cache_control: None,
610            exclude_newer: None,
611        })
612    }
613}
614
615/// An [`IndexUrl`] along with the metadata necessary to query the index.
616#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
617pub struct IndexMetadata {
618    /// The URL of the index.
619    pub url: IndexUrl,
620    /// The format used by the index.
621    pub format: IndexFormat,
622}
623
624impl IndexMetadata {
625    /// Consume the [`IndexMetadata`] and return the [`IndexUrl`].
626    pub fn into_url(self) -> IndexUrl {
627        self.url
628    }
629}
630
631/// A reference to an [`IndexMetadata`].
632#[derive(Debug, Copy, Clone)]
633pub struct IndexMetadataRef<'a> {
634    /// The URL of the index.
635    pub url: &'a IndexUrl,
636    /// The format used by the index.
637    pub format: IndexFormat,
638}
639
640impl IndexMetadata {
641    /// Return the [`IndexUrl`] of the index.
642    pub fn url(&self) -> &IndexUrl {
643        &self.url
644    }
645}
646
647impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
648    fn from(value: &'a Index) -> Self {
649        Self {
650            url: &value.url,
651            format: value.format,
652        }
653    }
654}
655
656impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
657    fn from(value: &'a IndexMetadata) -> Self {
658        Self {
659            url: &value.url,
660            format: value.format,
661        }
662    }
663}
664
665impl From<IndexUrl> for IndexMetadata {
666    fn from(value: IndexUrl) -> Self {
667        Self {
668            url: value,
669            format: IndexFormat::Simple,
670        }
671    }
672}
673
674impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
675    fn from(value: &'a IndexUrl) -> Self {
676        Self {
677            url: value,
678            format: IndexFormat::Simple,
679        }
680    }
681}
682
683/// Wire type for deserializing an [`Index`] with validation.
684#[derive(Deserialize)]
685#[serde(rename_all = "kebab-case")]
686struct IndexWire {
687    name: Option<IndexName>,
688    url: IndexUrl,
689    #[serde(default)]
690    explicit: bool,
691    #[serde(default)]
692    default: bool,
693    #[serde(default)]
694    format: IndexFormat,
695    publish_url: Option<DisplaySafeUrl>,
696    #[serde(default)]
697    authenticate: AuthPolicy,
698    #[serde(default)]
699    ignore_error_codes: Option<Vec<SerializableStatusCode>>,
700    #[serde(default)]
701    cache_control: Option<IndexCacheControl>,
702    #[serde(default)]
703    exclude_newer: Option<ExcludeNewerOverride>,
704}
705
706impl<'de> Deserialize<'de> for Index {
707    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
708    where
709        D: serde::Deserializer<'de>,
710    {
711        let wire = IndexWire::deserialize(deserializer)?;
712
713        if wire.explicit && wire.name.is_none() {
714            return Err(serde::de::Error::custom(format!(
715                "An index with `explicit = true` requires a `name`: {}",
716                wire.url
717            )));
718        }
719
720        Ok(Self {
721            name: wire.name,
722            url: wire.url,
723            explicit: wire.explicit,
724            default: wire.default,
725            origin: None,
726            format: wire.format,
727            publish_url: wire.publish_url,
728            authenticate: wire.authenticate,
729            ignore_error_codes: wire.ignore_error_codes,
730            cache_control: wire.cache_control,
731            exclude_newer: wire.exclude_newer,
732        })
733    }
734}
735
736/// An error that can occur when parsing an [`Index`].
737#[derive(Error, Debug)]
738pub enum IndexSourceError {
739    #[error(transparent)]
740    Url(#[from] IndexUrlError),
741    #[error(transparent)]
742    IndexName(#[from] IndexNameError),
743    #[error("Index included a name, but the name was empty")]
744    EmptyName,
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use http::HeaderValue;
751
752    #[test]
753    fn test_index_cache_control_headers() {
754        // Test that cache control headers are properly parsed from TOML
755        let toml_str = r#"
756            name = "test-index"
757            url = "https://test.example.com/simple"
758            cache-control = { api = "max-age=600", files = "max-age=3600" }
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=600"))
769        );
770        assert_eq!(
771            cache_control.files,
772            Some(HeaderValue::from_static("max-age=3600"))
773        );
774    }
775
776    #[test]
777    fn test_index_without_cache_control() {
778        // Test that indexes work without cache control headers
779        let toml_str = r#"
780            name = "test-index"
781            url = "https://test.example.com/simple"
782        "#;
783
784        let index: Index = toml::from_str(toml_str).unwrap();
785        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
786        assert_eq!(index.cache_control, None);
787        assert_eq!(index.exclude_newer, None);
788    }
789
790    #[test]
791    fn test_index_partial_cache_control() {
792        // Test that cache control can have just one field
793        let toml_str = r#"
794            name = "test-index"
795            url = "https://test.example.com/simple"
796            cache-control = { api = "max-age=300" }
797        "#;
798
799        let index: Index = toml::from_str(toml_str).unwrap();
800        assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
801        assert!(index.cache_control.is_some());
802        assert_eq!(index.exclude_newer, None);
803        let cache_control = index.cache_control.as_ref().unwrap();
804        assert_eq!(
805            cache_control.api,
806            Some(HeaderValue::from_static("max-age=300"))
807        );
808        assert_eq!(cache_control.files, None);
809    }
810
811    #[test]
812    fn test_index_invalid_api_cache_control() {
813        let toml_str = r#"
814            name = "test-index"
815            url = "https://test.example.com/simple"
816            cache-control = { api = "max-age=600\n" }
817        "#;
818
819        let err = toml::from_str::<Index>(toml_str).unwrap_err();
820        assert!(
821            err.to_string()
822                .contains("`cache-control.api` must be a valid HTTP header value")
823        );
824    }
825
826    #[test]
827    fn test_index_invalid_files_cache_control() {
828        let toml_str = r#"
829            name = "test-index"
830            url = "https://test.example.com/simple"
831            cache-control = { files = "max-age=3600\n" }
832        "#;
833
834        let err = toml::from_str::<Index>(toml_str).unwrap_err();
835        assert!(
836            err.to_string()
837                .contains("`cache-control.files` must be a valid HTTP header value")
838        );
839    }
840
841    #[test]
842    fn test_index_exclude_newer_disable() {
843        let toml_str = r#"
844            name = "internal"
845            url = "https://internal.example.com/simple"
846            exclude-newer = false
847        "#;
848
849        let index: Index = toml::from_str(toml_str).unwrap();
850        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
851        assert_eq!(index.exclude_newer, Some(ExcludeNewerOverride::Disabled));
852    }
853
854    #[test]
855    fn test_index_exclude_newer_relative() {
856        let toml_str = r#"
857            name = "internal"
858            url = "https://internal.example.com/simple"
859            exclude-newer = "7 days"
860        "#;
861
862        let index: Index = toml::from_str(toml_str).unwrap();
863        assert_eq!(index.name.as_ref().unwrap().as_ref(), "internal");
864        assert!(matches!(
865            index.exclude_newer,
866            Some(ExcludeNewerOverride::Enabled(_))
867        ));
868    }
869}