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