diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index bf605198f..f7f740f89 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -990,7 +990,7 @@ fn parse_find_links(input: &str) -> Result, String> { if input.is_empty() { Ok(Maybe::None) } else { - IndexUrl::from_str(input) + IndexUrl::parse_preserving_trailing_slash(input) .map(Index::from_find_links) .map(|index| Index { origin: Some(Origin::Cli), diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index 0290018f1..25d2d53d9 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -41,6 +41,30 @@ impl IndexUrl { /// /// Normalizes non-file URLs by removing trailing slashes for consistency. pub fn parse(path: &str, root_dir: Option<&Path>) -> Result { + Self::parse_with_trailing_slash_policy(path, root_dir, TrailingSlashPolicy::Remove) + } + + /// Parse an [`IndexUrl`] from a string, relative to an optional root directory. + /// + /// If no root directory is provided, relative paths are resolved against the current working + /// directory. + /// + /// Preserves trailing slash if present in `path`. + pub fn parse_preserving_trailing_slash(path: &str) -> Result { + Self::parse_with_trailing_slash_policy(path, None, TrailingSlashPolicy::Preserve) + } + + /// Parse an [`IndexUrl`] from a string, relative to an optional root directory. + /// + /// If no root directory is provided, relative paths are resolved against the current working + /// directory. + /// + /// Applies trailing slash policy to non-file URLs. + fn parse_with_trailing_slash_policy( + path: &str, + root_dir: Option<&Path>, + slash_policy: TrailingSlashPolicy, + ) -> Result { let url = match split_scheme(path) { Some((scheme, ..)) => { match Scheme::parse(scheme) { @@ -67,7 +91,10 @@ impl IndexUrl { } } }; - Ok(Self::from(url.with_given(path))) + Ok(Self::from_verbatim_url_with_trailing_slash_policy( + url.with_given(path), + slash_policy, + )) } /// Return the root [`Url`] of the index, if applicable. @@ -91,6 +118,34 @@ impl IndexUrl { url.path_segments_mut().ok()?.pop_if_empty().pop(); Some(url) } + + /// Construct an [`IndexUrl`] from a [`VerbatimUrl`], preserving a trailing + /// slash if present. + pub fn from_verbatim_url_preserving_trailing_slash(url: VerbatimUrl) -> Self { + Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Preserve) + } + + /// Construct an [`IndexUrl`] from a [`VerbatimUrl`], applying a [`TrailingSlashPolicy`] + /// to non-file URLs. + fn from_verbatim_url_with_trailing_slash_policy( + mut url: VerbatimUrl, + slash_policy: TrailingSlashPolicy, + ) -> Self { + if url.scheme() == "file" { + return Self::Path(Arc::new(url)); + } + + if slash_policy == TrailingSlashPolicy::Remove { + if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() { + path_segments.pop_if_empty(); + } + } + if *url.raw() == *PYPI_URL { + Self::Pypi(Arc::new(url)) + } else { + Self::Url(Arc::new(url)) + } + } } #[cfg(feature = "schemars")] @@ -184,6 +239,16 @@ impl Verbatim for IndexUrl { } } +/// Whether to preserve or remove a trailing slash from a non-file URL. +#[derive(Default, Clone, Copy, Eq, PartialEq)] +enum TrailingSlashPolicy { + /// Preserve trailing slash if present. + #[default] + Preserve, + /// Remove trailing slash if present. + Remove, +} + /// Checks if a path is disambiguated. /// /// Disambiguated paths are absolute paths, paths with valid schemes, @@ -258,21 +323,10 @@ impl<'de> serde::de::Deserialize<'de> for IndexUrl { } impl From for IndexUrl { - fn from(mut url: VerbatimUrl) -> Self { - if url.scheme() == "file" { - Self::Path(Arc::new(url)) - } else { - // Remove trailing slashes for consistency. They'll be re-added if necessary when - // querying the Simple API. - if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() { - path_segments.pop_if_empty(); - } - if *url.raw() == *PYPI_URL { - Self::Pypi(Arc::new(url)) - } else { - Self::Url(Arc::new(url)) - } - } + fn from(url: VerbatimUrl) -> Self { + // Remove trailing slashes for consistency. They'll be re-added if necessary when + // querying the Simple API. + Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Remove) } } @@ -726,4 +780,42 @@ mod tests { "git+https://github.com/example/repo.git" )); } + + #[test] + fn test_index_url_trailing_slash_policies() { + let url_with_trailing_slash = DisplaySafeUrl::parse("https://example.com/path/").unwrap(); + let url_without_trailing_slash = DisplaySafeUrl::parse("https://example.com/path").unwrap(); + let verbatim_url_with_trailing_slash = + VerbatimUrl::from_url(url_with_trailing_slash.clone()); + let verbatim_url_without_trailing_slash = + VerbatimUrl::from_url(url_without_trailing_slash.clone()); + + // Test `From` implementation. + // Trailing slash should be removed if present. + assert_eq!( + IndexUrl::from(verbatim_url_with_trailing_slash.clone()).url(), + &url_without_trailing_slash + ); + assert_eq!( + IndexUrl::from(verbatim_url_without_trailing_slash.clone()).url(), + &url_without_trailing_slash + ); + + // Test `from_verbatim_url_preserving_trailing_slash`. + // Trailing slash should be preserved if present. + assert_eq!( + IndexUrl::from_verbatim_url_preserving_trailing_slash( + verbatim_url_with_trailing_slash.clone() + ) + .url(), + &url_with_trailing_slash + ); + assert_eq!( + IndexUrl::from_verbatim_url_preserving_trailing_slash( + verbatim_url_without_trailing_slash.clone() + ) + .url(), + &url_without_trailing_slash + ); + } } diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 4c5741392..e637c6012 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -37,9 +37,8 @@ use tracing::instrument; use uv_cache_key::CanonicalUrl; use uv_client::BaseClientBuilder; use uv_configuration::{DependencyGroups, NoBinary, NoBuild}; -use uv_distribution_types::Requirement; use uv_distribution_types::{ - IndexUrl, NameRequirementSpecification, UnresolvedRequirement, + IndexUrl, NameRequirementSpecification, Requirement, UnresolvedRequirement, UnresolvedRequirementSpecification, }; use uv_fs::{CWD, Simplified}; @@ -152,7 +151,7 @@ impl RequirementsSpecification { find_links: requirements_txt .find_links .into_iter() - .map(IndexUrl::from) + .map(IndexUrl::from_verbatim_url_preserving_trailing_slash) .collect(), no_binary: requirements_txt.no_binary, no_build: requirements_txt.only_binary,