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::{SafeUrl, SafeUrlError};
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}
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
24#[rkyv(derive(Debug))]
25pub struct File {
26 pub dist_info_metadata: Option<HashDigests>,
28 pub filename: SmallString,
29 pub hashes: HashDigests,
30 pub requires_python: Option<Arc<VersionSpecifiers>>,
31 pub size: Option<u64>,
32 pub upload_time_utc_ms: Option<i64>,
37 pub url: FileLocation,
38 pub yanked: Option<Box<Yanked>>,
39}
40
41impl File {
42 pub fn try_from_pypi(
44 file: uv_pypi_types::PypiFile,
45 base: &SmallString,
46 ) -> Result<Self, FileConversionError> {
47 Ok(Self {
48 dist_info_metadata: Self::dist_info_metadata(file.core_metadata),
49 filename: file.filename,
50 hashes: HashDigests::from(file.hashes),
51 requires_python: file
52 .requires_python
53 .transpose()
54 .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
55 size: file.size,
56 upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
57 url: FileLocation::new(file.url, base),
58 yanked: file.yanked,
59 })
60 }
61
62 fn dist_info_metadata(metadata: Option<CoreMetadata>) -> Option<HashDigests> {
63 match metadata? {
64 CoreMetadata::Bool(false) => None,
65 CoreMetadata::Bool(true) => Some(HashDigests::empty()),
66 CoreMetadata::Hashes(hashes) => Some(HashDigests::from(hashes)),
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
73#[rkyv(derive(Debug))]
74pub enum FileLocation {
75 RelativeUrl(SmallString, SmallString),
77 AbsoluteUrl(UrlString),
79}
80
81impl FileLocation {
82 pub fn new(url: SmallString, base: &SmallString) -> Self {
87 match split_scheme(&url) {
88 Some(..) => Self::AbsoluteUrl(UrlString::new(url)),
89 None => Self::RelativeUrl(base.clone(), url),
90 }
91 }
92
93 pub fn raw_filename(&self) -> &str {
97 let path = match self {
98 Self::RelativeUrl(_, path) => path.as_ref(),
99 Self::AbsoluteUrl(url) => url.as_ref(),
100 };
101 let path = path.split_once(['?', '#']).map_or(path, |(path, _)| path);
102 path.rsplit_once('/').map_or(path, |(_, filename)| filename)
103 }
104
105 pub fn to_url(&self) -> Result<SafeUrl, ToUrlError> {
114 match self {
115 Self::RelativeUrl(base, path) => {
116 let base_url = SafeUrl::parse(base).map_err(|err| ToUrlError::InvalidBase {
117 base: base.to_string(),
118 err,
119 })?;
120 let joined = base_url.join(path).map_err(|err| ToUrlError::InvalidJoin {
121 base: base.to_string(),
122 path: path.to_string(),
123 err,
124 })?;
125 Ok(joined)
126 }
127 Self::AbsoluteUrl(absolute) => absolute.to_url(),
128 }
129 }
130}
131
132impl Display for FileLocation {
133 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
134 match self {
135 Self::RelativeUrl(_base, url) => Display::fmt(&url, f),
136 Self::AbsoluteUrl(url) => Display::fmt(&url.0, f),
137 }
138 }
139}
140
141#[derive(
145 Debug,
146 Clone,
147 PartialEq,
148 Eq,
149 PartialOrd,
150 Ord,
151 Hash,
152 Serialize,
153 Deserialize,
154 rkyv::Archive,
155 rkyv::Deserialize,
156 rkyv::Serialize,
157)]
158#[serde(transparent)]
159#[rkyv(derive(Debug))]
160pub struct UrlString(SmallString);
161
162impl UrlString {
163 fn new(url: SmallString) -> Self {
165 Self(url)
166 }
167
168 pub fn to_url(&self) -> Result<SafeUrl, ToUrlError> {
170 SafeUrl::from_str(&self.0).map_err(|err| ToUrlError::InvalidAbsolute {
171 absolute: self.0.to_string(),
172 err,
173 })
174 }
175
176 pub fn base_str(&self) -> &str {
178 self.as_ref()
179 .split_once(['?', '#'])
180 .map(|(path, _)| path)
181 .unwrap_or(self.as_ref())
182 }
183
184 #[must_use]
186 pub fn without_fragment(&self) -> Cow<'_, Self> {
187 self.as_ref()
188 .split_once('#')
189 .map(|(path, _)| Cow::Owned(Self(SmallString::from(path))))
190 .unwrap_or(Cow::Borrowed(self))
191 }
192}
193
194impl AsRef<str> for UrlString {
195 fn as_ref(&self) -> &str {
196 &self.0
197 }
198}
199
200impl From<SafeUrl> for UrlString {
201 fn from(value: SafeUrl) -> Self {
202 Self(value.as_str().into())
203 }
204}
205
206impl From<&SafeUrl> for UrlString {
207 fn from(value: &SafeUrl) -> Self {
208 Self(value.as_str().into())
209 }
210}
211
212impl Display for UrlString {
213 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
214 fmt::Display::fmt(&self.0, f)
215 }
216}
217
218#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
220pub enum ToUrlError {
221 #[error("Could not parse base URL `{base}` as a valid URL")]
224 InvalidBase {
225 base: String,
227 #[source]
229 err: SafeUrlError,
230 },
231 #[error("Could not join base URL `{base}` to relative path `{path}`")]
234 InvalidJoin {
235 base: String,
237 path: String,
239 #[source]
241 err: SafeUrlError,
242 },
243 #[error("Could not parse absolute URL `{absolute}` as a valid URL")]
246 InvalidAbsolute {
247 absolute: String,
249 #[source]
251 err: SafeUrlError,
252 },
253}
254
255#[cfg(test)]
256mod tests {
257 use std::assert_matches;
258
259 use super::*;
260
261 #[test]
262 fn raw_filename() {
263 let base = SmallString::from("https://example.com/simple/");
264
265 let location = FileLocation::new(
266 SmallString::from("files/example%20pkg.whl?download=1#fragment"),
267 &base,
268 );
269 assert_eq!(location.raw_filename(), "example%20pkg.whl");
270
271 let location = FileLocation::new(
272 SmallString::from("https://files.example.com/example.whl#sha256=digest"),
273 &base,
274 );
275 assert_eq!(location.raw_filename(), "example.whl");
276 }
277
278 #[test]
279 fn base_str() {
280 let url = UrlString("https://example.com/path?query#fragment".into());
281 assert_eq!(url.base_str(), "https://example.com/path");
282
283 let url = UrlString("https://example.com/path#fragment".into());
284 assert_eq!(url.base_str(), "https://example.com/path");
285
286 let url = UrlString("https://example.com/path#fragment?query".into());
287 assert_eq!(url.base_str(), "https://example.com/path");
288
289 let url = UrlString("https://example.com/path#fragment/part?query".into());
290 assert_eq!(url.base_str(), "https://example.com/path");
291
292 let url = UrlString("https://example.com/path".into());
293 assert_eq!(url.base_str(), "https://example.com/path");
294 }
295
296 #[test]
297 fn without_fragment() {
298 let url = UrlString("https://example.com/path".into());
300 assert_eq!(&*url.without_fragment(), &url);
301 assert_matches!(url.without_fragment(), Cow::Borrowed(_));
302
303 let url = UrlString("https://example.com/path?query#fragment".into());
305 assert_eq!(
306 &*url.without_fragment(),
307 &UrlString("https://example.com/path?query".into())
308 );
309 assert_matches!(url.without_fragment(), Cow::Owned(_));
310 }
311}