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