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