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