1use std::borrow::Cow;
2use std::fmt::{self, Display, Formatter};
3use std::str::FromStr;
4use std::sync::Arc;
5
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8
9use uv_pep440::{VersionSpecifiers, VersionSpecifiersParseError};
10use uv_pep508::split_scheme;
11use uv_pypi_types::{CoreMetadata, HashDigests, Yanked};
12use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
13use uv_small_str::SmallString;
14
15#[derive(Debug, thiserror::Error)]
17pub enum FileConversionError {
18 #[error("Failed to parse `requires-python`: `{0}`")]
19 RequiresPython(String, #[source] VersionSpecifiersParseError),
20 #[error("Failed to parse URL: {0}")]
21 Url(String, #[source] url::ParseError),
22 #[error("Failed to parse filename from URL: {0}")]
23 MissingPathSegments(String),
24 #[error(transparent)]
25 Utf8(#[from] std::str::Utf8Error),
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
30#[rkyv(derive(Debug))]
31pub struct File {
32 pub dist_info_metadata: bool,
33 pub filename: SmallString,
34 pub hashes: HashDigests,
35 pub requires_python: Option<Arc<VersionSpecifiers>>,
36 pub size: Option<u64>,
37 pub upload_time_utc_ms: Option<i64>,
42 pub url: FileLocation,
43 pub yanked: Option<Box<Yanked>>,
44 pub zstd: Option<Box<Zstd>>,
45}
46
47impl File {
48 pub fn try_from_pypi(
50 file: uv_pypi_types::PypiFile,
51 base: &SmallString,
52 ) -> Result<Self, FileConversionError> {
53 Ok(Self {
54 dist_info_metadata: file
55 .core_metadata
56 .as_ref()
57 .is_some_and(CoreMetadata::is_available),
58 filename: file.filename,
59 hashes: HashDigests::from(file.hashes),
60 requires_python: file
61 .requires_python
62 .transpose()
63 .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
64 size: file.size,
65 upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
66 url: FileLocation::new(file.url, base),
67 yanked: file.yanked,
68 zstd: None,
69 })
70 }
71
72 pub fn try_from_pyx(
73 file: uv_pypi_types::PyxFile,
74 base: &SmallString,
75 ) -> Result<Self, FileConversionError> {
76 let filename = if let Some(filename) = file.filename {
77 filename
78 } else {
79 let base_url = file
81 .url
82 .as_ref()
83 .split_once('?')
84 .or_else(|| file.url.as_ref().split_once('#'))
85 .map(|(path, _)| path)
86 .unwrap_or(file.url.as_ref());
87
88 let last = base_url
90 .split('/')
91 .next_back()
92 .ok_or_else(|| FileConversionError::MissingPathSegments(file.url.to_string()))?;
93
94 let filename = percent_encoding::percent_decode_str(last).decode_utf8()?;
96
97 SmallString::from(filename)
98 };
99 Ok(Self {
100 filename,
101 dist_info_metadata: file
102 .core_metadata
103 .as_ref()
104 .is_some_and(CoreMetadata::is_available),
105 hashes: HashDigests::from(file.hashes),
106 requires_python: file
107 .requires_python
108 .transpose()
109 .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
110 size: file.size,
111 upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
112 url: FileLocation::new(file.url, base),
113 yanked: file.yanked,
114 zstd: file
115 .zstd
116 .map(|zstd| Zstd {
117 hashes: HashDigests::from(zstd.hashes),
118 size: zstd.size,
119 })
120 .map(Box::new),
121 })
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
127#[rkyv(derive(Debug))]
128pub enum FileLocation {
129 RelativeUrl(SmallString, SmallString),
131 AbsoluteUrl(UrlString),
133}
134
135impl FileLocation {
136 pub fn new(url: SmallString, base: &SmallString) -> Self {
141 match split_scheme(&url) {
142 Some(..) => Self::AbsoluteUrl(UrlString::new(url)),
143 None => Self::RelativeUrl(base.clone(), url),
144 }
145 }
146
147 pub fn raw_filename(&self) -> &str {
151 let path = match self {
152 Self::RelativeUrl(_, path) => path.as_ref(),
153 Self::AbsoluteUrl(url) => url.as_ref(),
154 };
155 let path = path.split_once(['?', '#']).map_or(path, |(path, _)| path);
156 path.rsplit_once('/').map_or(path, |(_, filename)| filename)
157 }
158
159 pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
171 match self {
172 Self::RelativeUrl(base, path) => {
173 let base_url =
174 DisplaySafeUrl::parse(base).map_err(|err| ToUrlError::InvalidBase {
175 base: base.to_string(),
176 err,
177 })?;
178 let joined = base_url.join(path).map_err(|err| ToUrlError::InvalidJoin {
179 base: base.to_string(),
180 path: path.to_string(),
181 err,
182 })?;
183 Ok(joined)
184 }
185 Self::AbsoluteUrl(absolute) => absolute.to_url(),
186 }
187 }
188}
189
190impl Display for FileLocation {
191 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
192 match self {
193 Self::RelativeUrl(_base, url) => Display::fmt(&url, f),
194 Self::AbsoluteUrl(url) => Display::fmt(&url.0, f),
195 }
196 }
197}
198
199#[derive(
203 Debug,
204 Clone,
205 PartialEq,
206 Eq,
207 PartialOrd,
208 Ord,
209 Hash,
210 Serialize,
211 Deserialize,
212 rkyv::Archive,
213 rkyv::Deserialize,
214 rkyv::Serialize,
215)]
216#[serde(transparent)]
217#[rkyv(derive(Debug))]
218pub struct UrlString(SmallString);
219
220impl UrlString {
221 fn new(url: SmallString) -> Self {
223 Self(url)
224 }
225
226 pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
228 DisplaySafeUrl::from_str(&self.0).map_err(|err| ToUrlError::InvalidAbsolute {
229 absolute: self.0.to_string(),
230 err,
231 })
232 }
233
234 pub fn base_str(&self) -> &str {
236 self.as_ref()
237 .split_once('?')
238 .or_else(|| self.as_ref().split_once('#'))
239 .map(|(path, _)| path)
240 .unwrap_or(self.as_ref())
241 }
242
243 #[must_use]
245 pub fn without_fragment(&self) -> Cow<'_, Self> {
246 self.as_ref()
247 .split_once('#')
248 .map(|(path, _)| Cow::Owned(Self(SmallString::from(path))))
249 .unwrap_or(Cow::Borrowed(self))
250 }
251}
252
253impl AsRef<str> for UrlString {
254 fn as_ref(&self) -> &str {
255 &self.0
256 }
257}
258
259impl From<DisplaySafeUrl> for UrlString {
260 fn from(value: DisplaySafeUrl) -> Self {
261 Self(value.as_str().into())
262 }
263}
264
265impl From<&DisplaySafeUrl> for UrlString {
266 fn from(value: &DisplaySafeUrl) -> Self {
267 Self(value.as_str().into())
268 }
269}
270
271impl Display for UrlString {
272 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
273 fmt::Display::fmt(&self.0, f)
274 }
275}
276
277#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
279pub enum ToUrlError {
280 #[error("Could not parse base URL `{base}` as a valid URL")]
283 InvalidBase {
284 base: String,
286 #[source]
288 err: DisplaySafeUrlError,
289 },
290 #[error("Could not join base URL `{base}` to relative path `{path}`")]
293 InvalidJoin {
294 base: String,
296 path: String,
298 #[source]
300 err: DisplaySafeUrlError,
301 },
302 #[error("Could not parse absolute URL `{absolute}` as a valid URL")]
305 InvalidAbsolute {
306 absolute: String,
308 #[source]
310 err: DisplaySafeUrlError,
311 },
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
315pub struct Zstd {
316 pub hashes: HashDigests,
317 pub size: Option<u64>,
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn raw_filename() {
326 let base = SmallString::from("https://example.com/simple/");
327
328 let location = FileLocation::new(
329 SmallString::from("files/example%20pkg.whl?download=1#fragment"),
330 &base,
331 );
332 assert_eq!(location.raw_filename(), "example%20pkg.whl");
333
334 let location = FileLocation::new(
335 SmallString::from("https://files.example.com/example.whl#sha256=digest"),
336 &base,
337 );
338 assert_eq!(location.raw_filename(), "example.whl");
339 }
340
341 #[test]
342 fn base_str() {
343 let url = UrlString("https://example.com/path?query#fragment".into());
344 assert_eq!(url.base_str(), "https://example.com/path");
345
346 let url = UrlString("https://example.com/path#fragment".into());
347 assert_eq!(url.base_str(), "https://example.com/path");
348
349 let url = UrlString("https://example.com/path".into());
350 assert_eq!(url.base_str(), "https://example.com/path");
351 }
352
353 #[test]
354 fn without_fragment() {
355 let url = UrlString("https://example.com/path".into());
357 assert_eq!(&*url.without_fragment(), &url);
358 assert!(matches!(url.without_fragment(), Cow::Borrowed(_)));
359
360 let url = UrlString("https://example.com/path?query#fragment".into());
362 assert_eq!(
363 &*url.without_fragment(),
364 &UrlString("https://example.com/path?query".into())
365 );
366 assert!(matches!(url.without_fragment(), Cow::Owned(_)));
367 }
368}