1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3
4use uv_cache_key::{CanonicalUrl, RepositoryUrl};
5use uv_git_types::GitUrl;
6
7use uv_normalize::PackageName;
8use uv_pep440::Version;
9use uv_pypi_types::{
10 HashDigest, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl,
11};
12use uv_redacted::DisplaySafeUrl;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub enum PackageId {
18 Name(PackageName),
20 Url(CanonicalUrl),
22}
23
24impl PackageId {
25 pub fn from_registry(name: PackageName) -> Self {
27 Self::Name(name)
28 }
29
30 pub fn from_url(url: &DisplaySafeUrl) -> Self {
32 Self::Url(CanonicalUrl::new(url))
33 }
34}
35
36impl Display for PackageId {
37 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::Name(name) => write!(f, "{name}"),
40 Self::Url(url) => write!(f, "{url}"),
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
52pub enum VersionId {
53 NameVersion(PackageName, Version),
55 ArchiveUrl {
58 location: CanonicalUrl,
59 subdirectory: Option<PathBuf>,
60 },
61 Git {
64 url: GitUrl,
65 subdirectory: Option<PathBuf>,
66 },
67 Path(PathBuf),
69 Directory(PathBuf),
71 Unknown(DisplaySafeUrl),
73}
74
75impl VersionId {
76 pub fn from_registry(name: PackageName, version: Version) -> Self {
78 Self::NameVersion(name, version)
79 }
80
81 pub fn from_parsed_url(url: &ParsedUrl) -> Self {
83 match url {
84 ParsedUrl::Path(path) => Self::from_path_url(path),
85 ParsedUrl::Directory(directory) => Self::from_directory_url(directory),
86 ParsedUrl::Git(git) => Self::from_git_url(git),
87 ParsedUrl::Archive(archive) => Self::from_archive_url(archive),
88 }
89 }
90
91 pub fn from_url(url: &DisplaySafeUrl) -> Self {
93 match ParsedUrl::try_from(url.clone()) {
94 Ok(parsed) => Self::from_parsed_url(&parsed),
95 Err(_) => Self::Unknown(url.clone()),
96 }
97 }
98
99 pub fn from_archive(location: &DisplaySafeUrl, subdirectory: Option<&Path>) -> Self {
101 Self::ArchiveUrl {
102 location: CanonicalUrl::new(location),
103 subdirectory: subdirectory.map(Path::to_path_buf),
104 }
105 }
106
107 pub fn from_git(git: &GitUrl, subdirectory: Option<&Path>) -> Self {
109 Self::Git {
110 url: git.clone(),
111 subdirectory: subdirectory.map(Path::to_path_buf),
112 }
113 }
114
115 pub fn from_path(path: &Path) -> Self {
117 Self::Path(path.to_path_buf())
118 }
119
120 pub fn from_directory(path: &Path) -> Self {
122 Self::Directory(path.to_path_buf())
123 }
124
125 fn from_archive_url(archive: &ParsedArchiveUrl) -> Self {
126 Self::from_archive(&archive.url, archive.subdirectory.as_deref())
127 }
128
129 fn from_path_url(path: &ParsedPathUrl) -> Self {
130 Self::from_path(path.install_path.as_ref())
131 }
132
133 fn from_directory_url(directory: &ParsedDirectoryUrl) -> Self {
134 Self::from_directory(directory.install_path.as_ref())
135 }
136
137 fn from_git_url(git: &ParsedGitUrl) -> Self {
138 Self::from_git(&git.url, git.subdirectory.as_deref())
139 }
140}
141
142impl Display for VersionId {
143 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
144 match self {
145 Self::NameVersion(name, version) => write!(f, "{name}-{version}"),
146 Self::ArchiveUrl {
147 location,
148 subdirectory,
149 } => {
150 let mut location = DisplaySafeUrl::from(location.clone());
151 if let Some(subdirectory) = subdirectory {
152 location
153 .set_fragment(Some(&format!("subdirectory={}", subdirectory.display())));
154 }
155 write!(f, "{location}")
156 }
157 Self::Git { url, subdirectory } => {
158 let mut git_url = DisplaySafeUrl::parse(&format!("git+{}", url.url()))
159 .expect("Git URLs should be display-safe");
160 if let Some(precise) = url.precise() {
161 let path = format!("{}@{}", git_url.path(), precise);
162 git_url.set_path(&path);
163 } else if let Some(reference) = url.reference().as_str() {
164 let path = format!("{}@{}", git_url.path(), reference);
165 git_url.set_path(&path);
166 }
167
168 let mut fragments = Vec::new();
169 if let Some(subdirectory) = subdirectory {
170 fragments.push(format!("subdirectory={}", subdirectory.display()));
171 }
172 if url.lfs().enabled() {
173 fragments.push("lfs=true".to_string());
174 }
175 if !fragments.is_empty() {
176 git_url.set_fragment(Some(&fragments.join("&")));
177 }
178
179 write!(f, "{git_url}")
180 }
181 Self::Path(path) | Self::Directory(path) => {
182 if let Ok(url) = DisplaySafeUrl::from_file_path(path) {
183 write!(f, "{url}")
184 } else {
185 write!(f, "{}", path.display())
186 }
187 }
188 Self::Unknown(url) => write!(f, "{url}"),
189 }
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
207pub enum DistributionId {
208 Url(CanonicalUrl),
209 PathBuf(PathBuf),
210 Digest(HashDigest),
211 AbsoluteUrl(String),
212 RelativeUrl(String, String),
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
217pub enum ResourceId {
218 Url(RepositoryUrl),
219 PathBuf(PathBuf),
220 Digest(HashDigest),
221 AbsoluteUrl(String),
222 RelativeUrl(String, String),
223}
224
225impl From<&Self> for VersionId {
226 fn from(value: &Self) -> Self {
228 value.clone()
229 }
230}
231
232impl From<&Self> for DistributionId {
233 fn from(value: &Self) -> Self {
235 value.clone()
236 }
237}
238
239impl From<&Self> for ResourceId {
240 fn from(value: &Self) -> Self {
242 value.clone()
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use std::time::{SystemTime, UNIX_EPOCH};
249
250 use fs_err as fs;
251
252 use super::VersionId;
253 use uv_redacted::DisplaySafeUrl;
254
255 #[test]
256 fn version_id_ignores_hash_fragments() {
257 let first = DisplaySafeUrl::parse(
258 "https://example.com/pkg-0.1.0.whl#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
259 )
260 .unwrap();
261 let second = DisplaySafeUrl::parse(
262 "https://example.com/pkg-0.1.0.whl#sha512=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
263 )
264 .unwrap();
265
266 assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
267 }
268
269 #[test]
270 fn version_id_preserves_non_hash_fragments() {
271 let first =
272 DisplaySafeUrl::parse("https://example.com/pkg-0.1.0.tar.gz#subdirectory=foo").unwrap();
273 let second =
274 DisplaySafeUrl::parse("https://example.com/pkg-0.1.0.tar.gz#subdirectory=bar").unwrap();
275
276 assert_ne!(VersionId::from_url(&first), VersionId::from_url(&second));
277 }
278
279 #[test]
280 fn version_id_ignores_hash_fragments_with_subdirectory() {
281 let first = DisplaySafeUrl::parse(
282 "https://example.com/pkg-0.1.0.tar.gz#subdirectory=foo&sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
283 )
284 .unwrap();
285 let second = DisplaySafeUrl::parse(
286 "https://example.com/pkg-0.1.0.tar.gz#sha512=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&subdirectory=foo",
287 )
288 .unwrap();
289
290 assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
291 }
292
293 #[test]
294 fn version_id_preserves_non_archive_fragments() {
295 let first =
296 DisplaySafeUrl::parse("git+https://example.com/pkg.git#subdirectory=foo").unwrap();
297 let second =
298 DisplaySafeUrl::parse("git+https://example.com/pkg.git#subdirectory=bar").unwrap();
299
300 assert_ne!(VersionId::from_url(&first), VersionId::from_url(&second));
301 }
302
303 #[test]
304 fn version_id_ignores_irrelevant_git_fragments() {
305 let first =
306 DisplaySafeUrl::parse("git+https://example.com/pkg.git@main#egg=pkg&subdirectory=foo")
307 .unwrap();
308 let second =
309 DisplaySafeUrl::parse("git+https://example.com/pkg.git@main#subdirectory=foo").unwrap();
310
311 assert_eq!(VersionId::from_url(&first), VersionId::from_url(&second));
312 }
313
314 #[test]
315 fn version_id_uses_file_kinds() {
316 let nonce = SystemTime::now()
317 .duration_since(UNIX_EPOCH)
318 .unwrap()
319 .as_nanos();
320 let root = std::env::temp_dir().join(format!("uv-version-id-{nonce}"));
321 let file = root.join("pkg-0.1.0.whl");
322 let directory = root.join("pkg");
323
324 fs::create_dir_all(&directory).unwrap();
325 fs::write(&file, b"wheel").unwrap();
326
327 let file_url = DisplaySafeUrl::from_file_path(&file).unwrap();
328 let directory_url = DisplaySafeUrl::from_file_path(&directory).unwrap();
329
330 assert!(matches!(VersionId::from_url(&file_url), VersionId::Path(_)));
331 assert!(matches!(
332 VersionId::from_url(&directory_url),
333 VersionId::Directory(_)
334 ));
335
336 fs::remove_file(file).unwrap();
337 fs::remove_dir_all(root).unwrap();
338 }
339
340 #[test]
341 fn version_id_uses_unknown_for_invalid_git_like_urls() {
342 let url =
343 DisplaySafeUrl::parse("git+ftp://example.com/pkg.git@main#subdirectory=foo").unwrap();
344
345 assert!(matches!(VersionId::from_url(&url), VersionId::Unknown(_)));
346 }
347}