Skip to main content

uv_distribution_types/
index_url.rs

1use std::borrow::Cow;
2use std::fmt::{Display, Formatter};
3use std::ops::Deref;
4use std::path::Path;
5use std::str::FromStr;
6use std::sync::{Arc, LazyLock, RwLock};
7
8use http::StatusCode;
9use itertools::Either;
10use rustc_hash::{FxHashMap, FxHashSet};
11use thiserror::Error;
12use url::{ParseError, Url};
13use uv_auth::RealmRef;
14use uv_cache_key::CanonicalUrl;
15use uv_pep508::{Scheme, VerbatimUrl, VerbatimUrlError, split_scheme};
16use uv_redacted::DisplaySafeUrl;
17use uv_warnings::warn_user;
18
19use crate::{ExcludeNewerOverride, Index, IndexStatusCodeStrategy, Verbatim};
20
21pub static PYPI_URL: LazyLock<DisplaySafeUrl> =
22    LazyLock::new(|| DisplaySafeUrl::parse("https://pypi.org/simple").unwrap());
23
24static DEFAULT_INDEX: LazyLock<Index> = LazyLock::new(|| {
25    Index::from_index_url(IndexUrl::Pypi(Arc::new(VerbatimUrl::from_url(
26        PYPI_URL.clone(),
27    ))))
28});
29
30/// The URL of an index to use for fetching packages (e.g., PyPI).
31#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
32pub enum IndexUrl {
33    Pypi(Arc<VerbatimUrl>),
34    Url(Arc<VerbatimUrl>),
35    Path(Arc<VerbatimUrl>),
36}
37
38impl IndexUrl {
39    /// Parse an [`IndexUrl`] from a string, relative to an optional root directory.
40    ///
41    /// If no root directory is provided, relative paths are resolved against the current working
42    /// directory.
43    pub fn parse(path: &str, root_dir: Option<&Path>) -> Result<Self, IndexUrlError> {
44        let url = VerbatimUrl::from_url_or_path(path, root_dir)?;
45        Ok(Self::from(url))
46    }
47
48    /// Return the root [`Url`] of the index, if applicable.
49    ///
50    /// For indexes with a `/simple` endpoint, this is simply the URL with the final segment
51    /// removed. This is useful, e.g., for credential propagation to other endpoints on the index.
52    pub fn root(&self) -> Option<DisplaySafeUrl> {
53        let mut segments = self.url().path_segments()?;
54        let last = match segments.next_back()? {
55            // If the last segment is empty due to a trailing `/`, skip it (as in `pop_if_empty`)
56            "" => segments.next_back()?,
57            segment => segment,
58        };
59
60        // We also handle `/+simple` as it's used in devpi
61        if !(last.eq_ignore_ascii_case("simple") || last.eq_ignore_ascii_case("+simple")) {
62            return None;
63        }
64
65        let mut url = self.url().clone();
66        url.path_segments_mut().ok()?.pop_if_empty().pop();
67        Some(url)
68    }
69}
70
71#[cfg(feature = "schemars")]
72impl schemars::JsonSchema for IndexUrl {
73    fn schema_name() -> Cow<'static, str> {
74        Cow::Borrowed("IndexUrl")
75    }
76
77    fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
78        schemars::json_schema!({
79            "type": "string",
80            "description": "The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`), or a local path."
81        })
82    }
83}
84
85impl IndexUrl {
86    #[inline]
87    fn inner(&self) -> &VerbatimUrl {
88        match self {
89            Self::Pypi(url) | Self::Url(url) | Self::Path(url) => url,
90        }
91    }
92
93    /// Return the raw URL for the index.
94    pub fn url(&self) -> &DisplaySafeUrl {
95        self.inner().raw()
96    }
97
98    /// Convert the index URL into a [`DisplaySafeUrl`].
99    pub fn into_url(self) -> DisplaySafeUrl {
100        match self {
101            Self::Pypi(url) | Self::Url(url) | Self::Path(url) => {
102                Arc::unwrap_or_clone(url).into_url()
103            }
104        }
105    }
106
107    /// Return the redacted URL for the index, omitting any sensitive credentials.
108    pub fn without_credentials(&self) -> Cow<'_, DisplaySafeUrl> {
109        let url = self.url();
110        if url.username().is_empty() && url.password().is_none() {
111            Cow::Borrowed(url)
112        } else {
113            let mut url = url.clone();
114            let _ = url.set_username("");
115            let _ = url.set_password(None);
116            Cow::Owned(url)
117        }
118    }
119
120    /// Warn user if the given URL was provided as an ambiguous relative path.
121    ///
122    /// This is a temporary warning. Ambiguous values will not be
123    /// accepted in the future.
124    pub fn warn_on_disambiguated_relative_path(&self) {
125        let Self::Path(verbatim_url) = &self else {
126            return;
127        };
128
129        if let Some(path) = verbatim_url.given()
130            && !is_disambiguated_path(path)
131        {
132            if cfg!(windows) {
133                warn_user!(
134                    "Relative paths passed to `--index` or `--default-index` should be disambiguated from index names (use `.\\{path}` or `./{path}`). Support for ambiguous values will be removed in the future"
135                );
136            } else {
137                warn_user!(
138                    "Relative paths passed to `--index` or `--default-index` should be disambiguated from index names (use `./{path}`). Support for ambiguous values will be removed in the future"
139                );
140            }
141        }
142    }
143}
144
145impl Display for IndexUrl {
146    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
147        Display::fmt(self.inner(), f)
148    }
149}
150
151impl Verbatim for IndexUrl {
152    fn verbatim(&self) -> Cow<'_, str> {
153        self.inner().verbatim()
154    }
155}
156
157/// Checks if a path is disambiguated.
158///
159/// Disambiguated paths are absolute paths, paths with valid schemes,
160/// and paths starting with "./" or "../" on Unix or ".\\", "..\\",
161/// "./", or "../" on Windows.
162fn is_disambiguated_path(path: &str) -> bool {
163    if cfg!(windows) {
164        if path.starts_with(".\\") || path.starts_with("..\\") || path.starts_with('/') {
165            return true;
166        }
167    }
168    if path.starts_with("./") || path.starts_with("../") || Path::new(path).is_absolute() {
169        return true;
170    }
171    // Check if the path has a scheme (like `file://`)
172    if let Some((scheme, _)) = split_scheme(path) {
173        return Scheme::parse(scheme).is_some();
174    }
175    // This is an ambiguous relative path
176    false
177}
178
179/// An error that can occur when parsing an [`IndexUrl`].
180#[derive(Error, Debug)]
181pub enum IndexUrlError {
182    #[error(transparent)]
183    Io(#[from] std::io::Error),
184    #[error(transparent)]
185    Url(#[from] ParseError),
186    #[error(transparent)]
187    VerbatimUrl(#[from] VerbatimUrlError),
188}
189
190impl FromStr for IndexUrl {
191    type Err = IndexUrlError;
192
193    fn from_str(s: &str) -> Result<Self, Self::Err> {
194        Self::parse(s, None)
195    }
196}
197
198impl serde::ser::Serialize for IndexUrl {
199    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
200    where
201        S: serde::ser::Serializer,
202    {
203        self.inner().without_credentials().serialize(serializer)
204    }
205}
206
207impl<'de> serde::de::Deserialize<'de> for IndexUrl {
208    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
209    where
210        D: serde::de::Deserializer<'de>,
211    {
212        struct Visitor;
213
214        impl serde::de::Visitor<'_> for Visitor {
215            type Value = IndexUrl;
216
217            fn expecting(&self, f: &mut Formatter) -> std::fmt::Result {
218                f.write_str("a string")
219            }
220
221            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
222                IndexUrl::from_str(v).map_err(serde::de::Error::custom)
223            }
224        }
225
226        deserializer.deserialize_str(Visitor)
227    }
228}
229
230impl From<VerbatimUrl> for IndexUrl {
231    fn from(url: VerbatimUrl) -> Self {
232        if url.scheme() == "file" {
233            Self::Path(Arc::new(url))
234        } else if *url.raw() == *PYPI_URL {
235            Self::Pypi(Arc::new(url))
236        } else {
237            Self::Url(Arc::new(url))
238        }
239    }
240}
241
242impl From<IndexUrl> for DisplaySafeUrl {
243    fn from(index: IndexUrl) -> Self {
244        index.into_url()
245    }
246}
247
248impl Deref for IndexUrl {
249    type Target = Url;
250
251    fn deref(&self) -> &Self::Target {
252        self.inner()
253    }
254}
255
256/// The index locations to use for fetching packages. By default, uses the PyPI index.
257///
258/// This type merges the legacy `--index-url`, `--extra-index-url`, and `--find-links` options,
259/// along with the uv-specific `--index` and `--default-index`.
260#[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
261#[serde(rename_all = "kebab-case", deny_unknown_fields)]
262pub struct IndexLocations {
263    indexes: Vec<Index>,
264    flat_index: Vec<Index>,
265    no_index: bool,
266}
267
268impl IndexLocations {
269    /// Determine the index URLs to use for fetching packages.
270    pub fn new(indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
271        Self {
272            indexes,
273            flat_index,
274            no_index,
275        }
276    }
277
278    /// Combine a set of index locations.
279    ///
280    /// If either the current or the other index locations have `no_index` set, the result will
281    /// have `no_index` set.
282    ///
283    /// If the current index location has an `index` set, it will be preserved.
284    #[must_use]
285    pub fn combine(self, indexes: Vec<Index>, flat_index: Vec<Index>, no_index: bool) -> Self {
286        Self {
287            indexes: self.indexes.into_iter().chain(indexes).collect(),
288            flat_index: self.flat_index.into_iter().chain(flat_index).collect(),
289            no_index: self.no_index || no_index,
290        }
291    }
292
293    /// Returns `true` if no index configuration is set, i.e., the [`IndexLocations`] matches the
294    /// default configuration.
295    pub fn is_none(&self) -> bool {
296        *self == Self::default()
297    }
298}
299
300/// Returns `true` if two [`IndexUrl`]s refer to the same index.
301fn is_same_index(a: &IndexUrl, b: &IndexUrl) -> bool {
302    RealmRef::from(&**b.url()) == RealmRef::from(&**a.url())
303        && CanonicalUrl::new(a.url().clone()) == CanonicalUrl::new(b.url().clone())
304}
305
306impl<'a> IndexLocations {
307    /// Return the default [`Index`] entry.
308    ///
309    /// If `--no-index` is set, return `None`.
310    ///
311    /// If no index is provided, use the `PyPI` index.
312    pub fn default_index(&'a self) -> Option<&'a Index> {
313        if self.no_index {
314            None
315        } else {
316            let mut seen = FxHashSet::default();
317            self.indexes
318                .iter()
319                .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
320                .find(|index| index.default)
321                .or_else(|| Some(&DEFAULT_INDEX))
322        }
323    }
324
325    /// Return an iterator over the implicit [`Index`] entries.
326    ///
327    /// Default and explicit indexes are excluded.
328    pub fn implicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
329        if self.no_index {
330            Either::Left(std::iter::empty())
331        } else {
332            let mut seen = FxHashSet::default();
333            Either::Right(
334                self.indexes
335                    .iter()
336                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
337                    .filter(|index| !index.default && !index.explicit),
338            )
339        }
340    }
341
342    /// Return an iterator over the explicit [`Index`] entries.
343    ///
344    /// Explicit indexes are only used when pinned via `tool.uv.sources`.
345    pub fn explicit_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
346        if self.no_index {
347            Either::Left(std::iter::empty())
348        } else {
349            let mut seen = FxHashSet::default();
350            Either::Right(
351                self.indexes
352                    .iter()
353                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
354                    .filter(|index| index.explicit),
355            )
356        }
357    }
358
359    /// Return an iterator over all [`Index`] entries in order.
360    ///
361    /// Explicit indexes are excluded.
362    ///
363    /// Prioritizes the extra indexes over the default index.
364    ///
365    /// If `no_index` was enabled, then this always returns an empty
366    /// iterator.
367    pub fn indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
368        self.implicit_indexes()
369            .chain(self.default_index())
370            .filter(|index| !index.explicit)
371    }
372
373    /// Return an iterator over all [`Index`] entries to fetch in order.
374    ///
375    /// Unlike [`IndexLocations::indexes`], indexes with duplicate raw URLs are excluded.
376    pub fn fetch_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
377        let mut seen = FxHashSet::default();
378        self.indexes()
379            .filter(move |index| seen.insert(index.raw_url()))
380    }
381
382    /// Return an iterator over all simple [`Index`] entries in order.
383    ///
384    /// If `no_index` was enabled, then this always returns an empty iterator.
385    pub fn simple_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
386        if self.no_index {
387            Either::Left(std::iter::empty())
388        } else {
389            let mut seen = FxHashSet::default();
390            Either::Right(
391                self.indexes
392                    .iter()
393                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))),
394            )
395        }
396    }
397
398    /// Return an iterator over the [`FlatIndexLocation`] entries.
399    pub fn flat_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
400        self.flat_index.iter()
401    }
402
403    /// Return the `--no-index` flag.
404    pub fn no_index(&self) -> bool {
405        self.no_index
406    }
407
408    /// Return a vector containing all allowed [`Index`] entries.
409    ///
410    /// This includes explicit indexes, implicit indexes, flat indexes, and the default index.
411    ///
412    /// The indexes will be returned in the reverse of the order in which they were defined, such
413    /// that the last-defined index is the first item in the vector.
414    pub fn allowed_indexes(&'a self) -> Vec<&'a Index> {
415        if self.no_index {
416            self.flat_index.iter().rev().collect()
417        } else {
418            let mut indexes = vec![];
419
420            let mut seen = FxHashSet::default();
421            let mut default = false;
422            for index in {
423                self.indexes
424                    .iter()
425                    .chain(self.flat_index.iter())
426                    .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name)))
427            } {
428                if index.default {
429                    if default {
430                        continue;
431                    }
432                    default = true;
433                }
434                indexes.push(index);
435            }
436            if !default {
437                indexes.push(&*DEFAULT_INDEX);
438            }
439
440            indexes.reverse();
441            indexes
442        }
443    }
444
445    /// Return a vector containing all known [`Index`] entries.
446    ///
447    /// This includes explicit indexes, implicit indexes, flat indexes, and default indexes;
448    /// in short, it includes all defined indexes, even if they're overridden by some other index
449    /// definition.
450    ///
451    /// The indexes will be returned in the reverse of the order in which they were defined, such
452    /// that the last-defined index is the first item in the vector.
453    pub fn known_indexes(&'a self) -> impl Iterator<Item = &'a Index> {
454        if self.no_index {
455            Either::Left(self.flat_index.iter().rev())
456        } else {
457            Either::Right(
458                std::iter::once(&*DEFAULT_INDEX)
459                    .chain(self.flat_index.iter().rev())
460                    .chain(self.indexes.iter().rev()),
461            )
462        }
463    }
464
465    /// Return an iterator over all user-defined [`Index`] entries in order.
466    ///
467    /// Prioritizes the `[tool.uv.index]` definitions over the `--extra-index-url` definitions
468    /// over the `--index-url` definition.
469    ///
470    /// Unlike [`IndexLocations::indexes`], this includes explicit indexes and does _not_ insert
471    /// PyPI as a fallback default.
472    ///
473    /// If `no_index` was enabled, then this always returns an empty iterator.
474    pub fn defined_indexes(&'a self) -> impl Iterator<Item = &'a Index> + 'a {
475        if self.no_index {
476            return Either::Left(std::iter::empty());
477        }
478
479        let mut seen = FxHashSet::default();
480        let (non_default, default) = self
481            .indexes
482            .iter()
483            .filter(move |index| {
484                if let Some(name) = &index.name {
485                    seen.insert(name)
486                } else {
487                    true
488                }
489            })
490            .partition::<Vec<_>, _>(|index| !index.default);
491
492        Either::Right(non_default.into_iter().chain(default))
493    }
494
495    /// Return the configured index matching the given URL.
496    fn index_for_url(&self, url: &IndexUrl) -> Option<&Index> {
497        self.indexes
498            .iter()
499            .find(|index| is_same_index(index.url(), url))
500    }
501
502    /// Return the [`IndexStatusCodeStrategy`] for an [`IndexUrl`].
503    pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy {
504        self.index_for_url(url).map_or(
505            IndexStatusCodeStrategy::Default,
506            Index::status_code_strategy,
507        )
508    }
509
510    /// Return whether the given status code is explicitly ignored for an [`IndexUrl`].
511    pub fn ignores_error_code_for(&self, url: &IndexUrl, status_code: StatusCode) -> bool {
512        self.index_for_url(url)
513            .is_some_and(|index| index.ignores_error_code(status_code))
514    }
515
516    /// Return the Simple API cache control header for an [`IndexUrl`], if configured.
517    pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
518        self.index_for_url(url)
519            .and_then(Index::simple_api_cache_control)
520    }
521
522    /// Return the artifact cache control header for an [`IndexUrl`], if configured.
523    pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option<http::HeaderValue> {
524        self.index_for_url(url)
525            .and_then(Index::artifact_cache_control)
526    }
527
528    /// Return the `exclude-newer` setting for a given index, if the index is configured.
529    pub fn exclude_newer_for(&self, url: &IndexUrl) -> Option<&ExcludeNewerOverride> {
530        self.index_for_url(url).and_then(Index::exclude_newer)
531    }
532}
533
534impl From<&IndexLocations> for uv_auth::Indexes {
535    fn from(index_locations: &IndexLocations) -> Self {
536        Self::from_indexes(index_locations.allowed_indexes().into_iter().map(|index| {
537            let mut url = index.url().url().clone();
538            url.set_username("").ok();
539            url.set_password(None).ok();
540            let mut root_url = index.url().root().unwrap_or_else(|| url.clone());
541            root_url.set_username("").ok();
542            root_url.set_password(None).ok();
543            uv_auth::Index {
544                url,
545                root_url,
546                auth_policy: index.authenticate,
547            }
548        }))
549    }
550}
551
552bitflags::bitflags! {
553    #[derive(Debug, Copy, Clone)]
554    struct Flags: u8 {
555        /// Whether the index supports range requests.
556        const NO_RANGE_REQUESTS = 1;
557        /// Whether the index returned a `401 Unauthorized` status code.
558        const UNAUTHORIZED      = 1 << 2;
559        /// Whether the index returned a `403 Forbidden` status code.
560        const FORBIDDEN         = 1 << 1;
561    }
562}
563
564/// A map of [`IndexUrl`]s to their capabilities.
565///
566/// We only store indexes that lack capabilities (i.e., don't support range requests, aren't
567/// authorized). The benefit is that the map is almost always empty, so validating capabilities is
568/// extremely cheap.
569#[derive(Debug, Default, Clone)]
570pub struct IndexCapabilities(Arc<RwLock<FxHashMap<IndexUrl, Flags>>>);
571
572impl IndexCapabilities {
573    /// Returns `true` if the given [`IndexUrl`] supports range requests.
574    pub fn supports_range_requests(&self, index_url: &IndexUrl) -> bool {
575        !self
576            .0
577            .read()
578            .unwrap()
579            .get(index_url)
580            .is_some_and(|flags| flags.intersects(Flags::NO_RANGE_REQUESTS))
581    }
582
583    /// Mark an [`IndexUrl`] as not supporting range requests.
584    pub fn set_no_range_requests(&self, index_url: IndexUrl) {
585        self.0
586            .write()
587            .unwrap()
588            .entry(index_url)
589            .or_insert(Flags::empty())
590            .insert(Flags::NO_RANGE_REQUESTS);
591    }
592
593    /// Returns `true` if the given [`IndexUrl`] returns a `401 Unauthorized` status code.
594    pub fn unauthorized(&self, index_url: &IndexUrl) -> bool {
595        self.0
596            .read()
597            .unwrap()
598            .get(index_url)
599            .is_some_and(|flags| flags.intersects(Flags::UNAUTHORIZED))
600    }
601
602    /// Mark an [`IndexUrl`] as returning a `401 Unauthorized` status code.
603    pub(crate) fn set_unauthorized(&self, index_url: IndexUrl) {
604        self.0
605            .write()
606            .unwrap()
607            .entry(index_url)
608            .or_insert(Flags::empty())
609            .insert(Flags::UNAUTHORIZED);
610    }
611
612    /// Returns `true` if the given [`IndexUrl`] returns a `403 Forbidden` status code.
613    pub fn forbidden(&self, index_url: &IndexUrl) -> bool {
614        self.0
615            .read()
616            .unwrap()
617            .get(index_url)
618            .is_some_and(|flags| flags.intersects(Flags::FORBIDDEN))
619    }
620
621    /// Mark an [`IndexUrl`] as returning a `403 Forbidden` status code.
622    pub(crate) fn set_forbidden(&self, index_url: IndexUrl) {
623        self.0
624            .write()
625            .unwrap()
626            .entry(index_url)
627            .or_insert(Flags::empty())
628            .insert(Flags::FORBIDDEN);
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::{IndexCacheControl, IndexFormat, IndexName};
636    use http::HeaderValue;
637
638    #[test]
639    fn test_index_url_parse_valid_paths() {
640        // Absolute path
641        assert!(is_disambiguated_path("/absolute/path"));
642        // Relative path
643        assert!(is_disambiguated_path("./relative/path"));
644        assert!(is_disambiguated_path("../../relative/path"));
645        if cfg!(windows) {
646            // Windows absolute path
647            assert!(is_disambiguated_path("C:/absolute/path"));
648            // Windows relative path
649            assert!(is_disambiguated_path(".\\relative\\path"));
650            assert!(is_disambiguated_path("..\\..\\relative\\path"));
651        }
652    }
653
654    #[test]
655    fn test_index_url_parse_ambiguous_paths() {
656        // Test single-segment ambiguous path
657        assert!(!is_disambiguated_path("index"));
658        // Test multi-segment ambiguous path
659        assert!(!is_disambiguated_path("relative/path"));
660    }
661
662    #[test]
663    fn test_index_url_parse_with_schemes() {
664        assert!(is_disambiguated_path("file:///absolute/path"));
665        assert!(is_disambiguated_path("https://registry.com/simple/"));
666        assert!(is_disambiguated_path(
667            "git+https://github.com/example/repo.git"
668        ));
669    }
670
671    #[test]
672    fn fetch_indexes_deduplicates_raw_urls() {
673        let url = IndexUrl::from_str("https://index.example.com/simple").unwrap();
674        let mut first = Index::from(url.clone());
675        first.name = Some(IndexName::from_str("first").unwrap());
676        let mut second = Index::from(url);
677        second.name = Some(IndexName::from_str("second").unwrap());
678        second.default = true;
679        let locations = IndexLocations::new(vec![first, second], Vec::new(), false);
680
681        assert_eq!(locations.indexes().count(), 2);
682        assert_eq!(locations.fetch_indexes().count(), 1);
683    }
684
685    #[test]
686    fn test_cache_control_lookup() {
687        use std::str::FromStr;
688
689        use crate::IndexFormat;
690        use crate::index_name::IndexName;
691
692        let indexes = vec![
693            Index {
694                name: Some(IndexName::from_str("index1").unwrap()),
695                url: IndexUrl::from_str("https://index1.example.com/simple").unwrap(),
696                cache_control: Some(crate::IndexCacheControl {
697                    api: Some(HeaderValue::from_static("max-age=300")),
698                    files: Some(HeaderValue::from_static("max-age=1800")),
699                }),
700                explicit: false,
701                default: false,
702                origin: None,
703                format: IndexFormat::Simple,
704                publish_url: None,
705                authenticate: uv_auth::AuthPolicy::default(),
706                ignore_error_codes: None,
707                exclude_newer: None,
708            },
709            Index {
710                name: Some(IndexName::from_str("index2").unwrap()),
711                url: IndexUrl::from_str("https://index2.example.com/simple").unwrap(),
712                cache_control: None,
713                explicit: false,
714                default: false,
715                origin: None,
716                format: IndexFormat::Simple,
717                publish_url: None,
718                authenticate: uv_auth::AuthPolicy::default(),
719                ignore_error_codes: None,
720                exclude_newer: None,
721            },
722        ];
723
724        let index_locations = IndexLocations::new(indexes, Vec::new(), false);
725
726        let url1 = IndexUrl::from_str("https://index1.example.com/simple").unwrap();
727        assert_eq!(
728            index_locations.simple_api_cache_control_for(&url1),
729            Some(HeaderValue::from_static("max-age=300"))
730        );
731        assert_eq!(
732            index_locations.artifact_cache_control_for(&url1),
733            Some(HeaderValue::from_static("max-age=1800"))
734        );
735
736        let url2 = IndexUrl::from_str("https://index2.example.com/simple").unwrap();
737        assert_eq!(index_locations.simple_api_cache_control_for(&url2), None);
738        assert_eq!(index_locations.artifact_cache_control_for(&url2), None);
739
740        let url3 = IndexUrl::from_str("https://index3.example.com/simple").unwrap();
741        assert_eq!(index_locations.simple_api_cache_control_for(&url3), None);
742        assert_eq!(index_locations.artifact_cache_control_for(&url3), None);
743    }
744
745    #[test]
746    fn test_pytorch_default_cache_control() {
747        // Test that PyTorch indexes get default cache control from the getter methods
748        let indexes = vec![Index {
749            name: Some(IndexName::from_str("pytorch").unwrap()),
750            url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
751            cache_control: None, // No explicit cache control
752            explicit: false,
753            default: false,
754            origin: None,
755            format: IndexFormat::Simple,
756            publish_url: None,
757            authenticate: uv_auth::AuthPolicy::default(),
758            ignore_error_codes: None,
759            exclude_newer: None,
760        }];
761
762        let index_locations = IndexLocations::new(indexes, Vec::new(), false);
763
764        let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
765
766        assert_eq!(
767            index_locations.simple_api_cache_control_for(&pytorch_url),
768            None
769        );
770        assert_eq!(
771            index_locations.artifact_cache_control_for(&pytorch_url),
772            Some(HeaderValue::from_static(
773                "max-age=365000000, immutable, public",
774            ))
775        );
776    }
777
778    #[test]
779    fn test_pytorch_user_override_cache_control() {
780        // Test that user-specified cache control overrides PyTorch defaults
781        let indexes = vec![Index {
782            name: Some(IndexName::from_str("pytorch").unwrap()),
783            url: IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap(),
784            cache_control: Some(IndexCacheControl {
785                api: Some(HeaderValue::from_static("no-cache")),
786                files: Some(HeaderValue::from_static("max-age=3600")),
787            }),
788            explicit: false,
789            default: false,
790            origin: None,
791            format: IndexFormat::Simple,
792            publish_url: None,
793            authenticate: uv_auth::AuthPolicy::default(),
794            ignore_error_codes: None,
795            exclude_newer: None,
796        }];
797
798        let index_locations = IndexLocations::new(indexes, Vec::new(), false);
799
800        let pytorch_url = IndexUrl::from_str("https://download.pytorch.org/whl/cu118").unwrap();
801
802        assert_eq!(
803            index_locations.simple_api_cache_control_for(&pytorch_url),
804            Some(HeaderValue::from_static("no-cache"))
805        );
806        assert_eq!(
807            index_locations.artifact_cache_control_for(&pytorch_url),
808            Some(HeaderValue::from_static("max-age=3600"))
809        );
810    }
811
812    #[test]
813    fn test_nvidia_default_cache_control() {
814        // Test that NVIDIA indexes get default cache control from the getter methods
815        let indexes = vec![Index {
816            name: Some(IndexName::from_str("nvidia").unwrap()),
817            url: IndexUrl::from_str("https://pypi.nvidia.com").unwrap(),
818            cache_control: None, // No explicit cache control
819            explicit: false,
820            default: false,
821            origin: None,
822            format: IndexFormat::Simple,
823            publish_url: None,
824            authenticate: uv_auth::AuthPolicy::default(),
825            ignore_error_codes: None,
826            exclude_newer: None,
827        }];
828
829        let index_locations = IndexLocations::new(indexes, Vec::new(), false);
830
831        let nvidia_url = IndexUrl::from_str("https://pypi.nvidia.com").unwrap();
832
833        assert_eq!(
834            index_locations.simple_api_cache_control_for(&nvidia_url),
835            None
836        );
837        assert_eq!(
838            index_locations.artifact_cache_control_for(&nvidia_url),
839            Some(HeaderValue::from_static(
840                "max-age=365000000, immutable, public",
841            ))
842        );
843    }
844}