1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3
4use uv_cache_key::{CanonicalUrl, RepositoryUrl};
5use uv_fs::normalize_path;
6use uv_git_types::GitUrl;
7
8use uv_normalize::PackageName;
9use uv_pep440::Version;
10use uv_pypi_types::{HashDigest, ParsedUrl};
11use uv_redacted::DisplaySafeUrl;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub enum PackageId {
17 Name(PackageName),
19 Url(CanonicalUrl),
21}
22
23impl PackageId {
24 pub(crate) fn from_registry(name: PackageName) -> Self {
26 Self::Name(name)
27 }
28
29 pub(crate) fn from_url(url: DisplaySafeUrl) -> Self {
31 Self::Url(CanonicalUrl::new(url))
32 }
33}
34
35impl Display for PackageId {
36 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Name(name) => write!(f, "{name}"),
39 Self::Url(url) => write!(f, "{url}"),
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
51pub enum VersionId {
52 NameVersion(PackageName, Version),
54 ArchiveUrl {
57 location: CanonicalUrl,
58 subdirectory: Option<PathBuf>,
59 },
60 Git {
63 url: GitUrl,
64 subdirectory: Option<PathBuf>,
65 },
66 Path(PathBuf),
68 Directory(PathBuf),
70 Unknown(DisplaySafeUrl),
72}
73
74impl VersionId {
75 pub fn from_registry(name: PackageName, version: Version) -> Self {
77 Self::NameVersion(name, version)
78 }
79
80 pub fn from_parsed_url(url: ParsedUrl) -> Self {
82 match url {
83 ParsedUrl::Path(path) => Self::Path(path.install_path.into_path_buf()),
84 ParsedUrl::Directory(directory) => {
85 Self::Directory(directory.install_path.into_path_buf())
86 }
87 ParsedUrl::GitDirectory(git) => Self::Git {
88 url: git.url,
89 subdirectory: git.subdirectory.map(Path::into_path_buf),
90 },
91 ParsedUrl::GitPath(git) => Self::Git {
92 url: git.url,
93 subdirectory: Some(git.install_path),
94 },
95 ParsedUrl::Archive(archive) => {
96 Self::from_archive(archive.url, archive.subdirectory.map(Path::into_path_buf))
97 }
98 }
99 }
100
101 pub fn from_url(url: &DisplaySafeUrl) -> Self {
103 match ParsedUrl::try_from(url.clone()) {
104 Ok(parsed) => Self::from_parsed_url(parsed),
105 Err(_) => Self::Unknown(url.clone()),
106 }
107 }
108
109 pub fn from_archive(location: DisplaySafeUrl, subdirectory: Option<PathBuf>) -> Self {
111 let subdirectory = subdirectory
114 .map(|path| normalize_path(path).into_owned())
115 .filter(|path| !path.as_os_str().is_empty());
116 Self::ArchiveUrl {
117 location: CanonicalUrl::new(location),
118 subdirectory,
119 }
120 }
121
122 pub fn from_git(git: &GitUrl, subdirectory: Option<&Path>) -> Self {
124 Self::Git {
125 url: git.clone(),
126 subdirectory: subdirectory.map(Path::to_path_buf),
127 }
128 }
129
130 pub fn from_path(path: &Path) -> Self {
132 Self::Path(path.to_path_buf())
133 }
134
135 pub fn from_directory(path: &Path) -> Self {
137 Self::Directory(path.to_path_buf())
138 }
139}
140
141impl Display for VersionId {
142 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
143 match self {
144 Self::NameVersion(name, version) => write!(f, "{name}-{version}"),
145 Self::ArchiveUrl {
146 location,
147 subdirectory,
148 } => {
149 let mut location = DisplaySafeUrl::from(location.clone());
150 if let Some(subdirectory) = subdirectory {
151 location
152 .set_fragment(Some(&format!("subdirectory={}", subdirectory.display())));
153 }
154 write!(f, "{location}")
155 }
156 Self::Git { url, subdirectory } => {
157 let mut git_url = DisplaySafeUrl::parse(&format!("git+{}", url.url()))
158 .expect("Git URLs should be display-safe");
159 if let Some(precise) = url.precise() {
160 let path = format!("{}@{}", git_url.path(), precise);
161 git_url.set_path(&path);
162 } else if let Some(reference) = url.reference().as_str() {
163 let path = format!("{}@{}", git_url.path(), reference);
164 git_url.set_path(&path);
165 }
166
167 let mut fragments = Vec::new();
168 if let Some(subdirectory) = subdirectory {
169 fragments.push(format!("subdirectory={}", subdirectory.display()));
170 }
171 if url.lfs().enabled() {
172 fragments.push("lfs=true".to_string());
173 }
174 if !fragments.is_empty() {
175 git_url.set_fragment(Some(&fragments.join("&")));
176 }
177
178 write!(f, "{git_url}")
179 }
180 Self::Path(path) | Self::Directory(path) => {
181 if let Ok(url) = DisplaySafeUrl::from_file_path(path) {
182 write!(f, "{url}")
183 } else {
184 write!(f, "{}", path.display())
185 }
186 }
187 Self::Unknown(url) => write!(f, "{url}"),
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
206pub enum DistributionId {
207 Url(CanonicalUrl),
208 PathBuf(PathBuf),
209 Digest(HashDigest),
210 AbsoluteUrl(String),
211 RelativeUrl(String, String),
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
216pub enum ResourceId {
217 Url(RepositoryUrl),
218 PathBuf(PathBuf),
219 Digest(HashDigest),
220 AbsoluteUrl(String),
221 RelativeUrl(String, String),
222}
223
224impl From<&Self> for VersionId {
225 fn from(value: &Self) -> Self {
227 value.clone()
228 }
229}
230
231impl From<&Self> for DistributionId {
232 fn from(value: &Self) -> Self {
234 value.clone()
235 }
236}
237
238impl From<&Self> for ResourceId {
239 fn from(value: &Self) -> Self {
241 value.clone()
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use std::assert_matches;
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!(VersionId::from_url(&directory_url), VersionId::Directory(_));
332
333 fs::remove_file(file).unwrap();
334 fs::remove_dir_all(root).unwrap();
335 }
336
337 #[test]
338 fn version_id_uses_unknown_for_invalid_git_like_urls() {
339 let url =
340 DisplaySafeUrl::parse("git+ftp://example.com/pkg.git@main#subdirectory=foo").unwrap();
341
342 assert_matches!(VersionId::from_url(&url), VersionId::Unknown(_));
343 }
344}