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