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 to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
159 match self {
160 Self::RelativeUrl(base, path) => {
161 let base_url =
162 DisplaySafeUrl::parse(base).map_err(|err| ToUrlError::InvalidBase {
163 base: base.to_string(),
164 err,
165 })?;
166 let joined = base_url.join(path).map_err(|err| ToUrlError::InvalidJoin {
167 base: base.to_string(),
168 path: path.to_string(),
169 err,
170 })?;
171 Ok(joined)
172 }
173 Self::AbsoluteUrl(absolute) => absolute.to_url(),
174 }
175 }
176}
177
178impl Display for FileLocation {
179 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
180 match self {
181 Self::RelativeUrl(_base, url) => Display::fmt(&url, f),
182 Self::AbsoluteUrl(url) => Display::fmt(&url.0, f),
183 }
184 }
185}
186
187#[derive(
191 Debug,
192 Clone,
193 PartialEq,
194 Eq,
195 PartialOrd,
196 Ord,
197 Hash,
198 Serialize,
199 Deserialize,
200 rkyv::Archive,
201 rkyv::Deserialize,
202 rkyv::Serialize,
203)]
204#[serde(transparent)]
205#[rkyv(derive(Debug))]
206pub struct UrlString(SmallString);
207
208impl UrlString {
209 fn new(url: SmallString) -> Self {
211 Self(url)
212 }
213
214 pub fn to_url(&self) -> Result<DisplaySafeUrl, ToUrlError> {
216 DisplaySafeUrl::from_str(&self.0).map_err(|err| ToUrlError::InvalidAbsolute {
217 absolute: self.0.to_string(),
218 err,
219 })
220 }
221
222 pub fn base_str(&self) -> &str {
224 self.as_ref()
225 .split_once('?')
226 .or_else(|| self.as_ref().split_once('#'))
227 .map(|(path, _)| path)
228 .unwrap_or(self.as_ref())
229 }
230
231 #[must_use]
233 pub fn without_fragment(&self) -> Cow<'_, Self> {
234 self.as_ref()
235 .split_once('#')
236 .map(|(path, _)| Cow::Owned(Self(SmallString::from(path))))
237 .unwrap_or(Cow::Borrowed(self))
238 }
239}
240
241impl AsRef<str> for UrlString {
242 fn as_ref(&self) -> &str {
243 &self.0
244 }
245}
246
247impl From<DisplaySafeUrl> for UrlString {
248 fn from(value: DisplaySafeUrl) -> Self {
249 Self(value.as_str().into())
250 }
251}
252
253impl From<&DisplaySafeUrl> for UrlString {
254 fn from(value: &DisplaySafeUrl) -> Self {
255 Self(value.as_str().into())
256 }
257}
258
259impl Display for UrlString {
260 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
261 fmt::Display::fmt(&self.0, f)
262 }
263}
264
265#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
267pub enum ToUrlError {
268 #[error("Could not parse base URL `{base}` as a valid URL")]
271 InvalidBase {
272 base: String,
274 #[source]
276 err: DisplaySafeUrlError,
277 },
278 #[error("Could not join base URL `{base}` to relative path `{path}`")]
281 InvalidJoin {
282 base: String,
284 path: String,
286 #[source]
288 err: DisplaySafeUrlError,
289 },
290 #[error("Could not parse absolute URL `{absolute}` as a valid URL")]
293 InvalidAbsolute {
294 absolute: String,
296 #[source]
298 err: DisplaySafeUrlError,
299 },
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
303pub struct Zstd {
304 pub hashes: HashDigests,
305 pub size: Option<u64>,
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn base_str() {
314 let url = UrlString("https://example.com/path?query#fragment".into());
315 assert_eq!(url.base_str(), "https://example.com/path");
316
317 let url = UrlString("https://example.com/path#fragment".into());
318 assert_eq!(url.base_str(), "https://example.com/path");
319
320 let url = UrlString("https://example.com/path".into());
321 assert_eq!(url.base_str(), "https://example.com/path");
322 }
323
324 #[test]
325 fn without_fragment() {
326 let url = UrlString("https://example.com/path".into());
328 assert_eq!(&*url.without_fragment(), &url);
329 assert!(matches!(url.without_fragment(), Cow::Borrowed(_)));
330
331 let url = UrlString("https://example.com/path?query#fragment".into());
333 assert_eq!(
334 &*url.without_fragment(),
335 &UrlString("https://example.com/path?query".into())
336 );
337 assert!(matches!(url.without_fragment(), Cow::Owned(_)));
338 }
339}