1use std::path::Path;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6use url::Url;
7
8use uv_auth::{AuthPolicy, Credentials};
9use uv_redacted::DisplaySafeUrl;
10use uv_small_str::SmallString;
11
12use crate::index_name::{IndexName, IndexNameError};
13use crate::origin::Origin;
14use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode};
15
16#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Default)]
18#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
19#[serde(rename_all = "kebab-case")]
20pub struct IndexCacheControl {
21 pub api: Option<SmallString>,
23 pub files: Option<SmallString>,
25}
26
27impl IndexCacheControl {
28 pub fn simple_api_cache_control(_url: &Url) -> Option<&'static str> {
30 None
31 }
32
33 pub fn artifact_cache_control(url: &Url) -> Option<&'static str> {
35 let dominated_by_pytorch_or_nvidia = url.host_str().is_some_and(|host| {
36 host.eq_ignore_ascii_case("download.pytorch.org")
37 || host.eq_ignore_ascii_case("pypi.nvidia.com")
38 });
39 if dominated_by_pytorch_or_nvidia {
40 Some("max-age=365000000, immutable, public")
48 } else {
49 None
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize)]
55#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
56#[serde(rename_all = "kebab-case")]
57pub struct Index {
58 pub name: Option<IndexName>,
72 pub url: IndexUrl,
76 #[serde(default)]
91 pub explicit: bool,
92 #[serde(default)]
102 pub default: bool,
103 #[serde(skip)]
105 pub origin: Option<Origin>,
106 #[serde(default)]
112 pub format: IndexFormat,
113 pub publish_url: Option<DisplaySafeUrl>,
126 #[serde(default)]
135 pub authenticate: AuthPolicy,
136 #[serde(default)]
146 pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
147 #[serde(default)]
159 pub cache_control: Option<IndexCacheControl>,
160}
161
162impl PartialEq for Index {
163 fn eq(&self, other: &Self) -> bool {
164 let Self {
165 name,
166 url,
167 explicit,
168 default,
169 origin: _,
170 format,
171 publish_url,
172 authenticate,
173 ignore_error_codes,
174 cache_control,
175 } = self;
176 *url == other.url
177 && *name == other.name
178 && *explicit == other.explicit
179 && *default == other.default
180 && *format == other.format
181 && *publish_url == other.publish_url
182 && *authenticate == other.authenticate
183 && *ignore_error_codes == other.ignore_error_codes
184 && *cache_control == other.cache_control
185 }
186}
187
188impl Eq for Index {}
189
190impl PartialOrd for Index {
191 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
192 Some(self.cmp(other))
193 }
194}
195
196impl Ord for Index {
197 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
198 let Self {
199 name,
200 url,
201 explicit,
202 default,
203 origin: _,
204 format,
205 publish_url,
206 authenticate,
207 ignore_error_codes,
208 cache_control,
209 } = self;
210 url.cmp(&other.url)
211 .then_with(|| name.cmp(&other.name))
212 .then_with(|| explicit.cmp(&other.explicit))
213 .then_with(|| default.cmp(&other.default))
214 .then_with(|| format.cmp(&other.format))
215 .then_with(|| publish_url.cmp(&other.publish_url))
216 .then_with(|| authenticate.cmp(&other.authenticate))
217 .then_with(|| ignore_error_codes.cmp(&other.ignore_error_codes))
218 .then_with(|| cache_control.cmp(&other.cache_control))
219 }
220}
221
222impl std::hash::Hash for Index {
223 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
224 let Self {
225 name,
226 url,
227 explicit,
228 default,
229 origin: _,
230 format,
231 publish_url,
232 authenticate,
233 ignore_error_codes,
234 cache_control,
235 } = self;
236 url.hash(state);
237 name.hash(state);
238 explicit.hash(state);
239 default.hash(state);
240 format.hash(state);
241 publish_url.hash(state);
242 authenticate.hash(state);
243 ignore_error_codes.hash(state);
244 cache_control.hash(state);
245 }
246}
247
248#[derive(
249 Default,
250 Debug,
251 Copy,
252 Clone,
253 Hash,
254 Eq,
255 PartialEq,
256 Ord,
257 PartialOrd,
258 serde::Serialize,
259 serde::Deserialize,
260)]
261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
262#[serde(rename_all = "kebab-case")]
263pub enum IndexFormat {
264 #[default]
266 Simple,
267 Flat,
269}
270
271impl Index {
272 pub fn from_index_url(url: IndexUrl) -> Self {
274 Self {
275 url,
276 name: None,
277 explicit: false,
278 default: true,
279 origin: None,
280 format: IndexFormat::Simple,
281 publish_url: None,
282 authenticate: AuthPolicy::default(),
283 ignore_error_codes: None,
284 cache_control: None,
285 }
286 }
287
288 pub fn from_extra_index_url(url: IndexUrl) -> Self {
290 Self {
291 url,
292 name: None,
293 explicit: false,
294 default: false,
295 origin: None,
296 format: IndexFormat::Simple,
297 publish_url: None,
298 authenticate: AuthPolicy::default(),
299 ignore_error_codes: None,
300 cache_control: None,
301 }
302 }
303
304 pub fn from_find_links(url: IndexUrl) -> Self {
306 Self {
307 url,
308 name: None,
309 explicit: false,
310 default: false,
311 origin: None,
312 format: IndexFormat::Flat,
313 publish_url: None,
314 authenticate: AuthPolicy::default(),
315 ignore_error_codes: None,
316 cache_control: None,
317 }
318 }
319
320 #[must_use]
322 pub fn with_origin(mut self, origin: Origin) -> Self {
323 self.origin = Some(origin);
324 self
325 }
326
327 pub fn url(&self) -> &IndexUrl {
329 &self.url
330 }
331
332 pub fn into_url(self) -> IndexUrl {
334 self.url
335 }
336
337 pub fn raw_url(&self) -> &DisplaySafeUrl {
339 self.url.url()
340 }
341
342 pub fn root_url(&self) -> Option<DisplaySafeUrl> {
347 self.url.root()
348 }
349
350 #[must_use]
355 pub fn with_promoted_auth_policy(mut self) -> Self {
356 if matches!(self.authenticate, AuthPolicy::Auto) && self.credentials().is_some() {
357 self.authenticate = AuthPolicy::Always;
358 }
359 self
360 }
361
362 pub fn credentials(&self) -> Option<Credentials> {
364 if let Some(name) = self.name.as_ref() {
366 if let Some(credentials) = Credentials::from_env(name.to_env_var()) {
367 return Some(credentials);
368 }
369 }
370
371 Credentials::from_url(self.url.url())
373 }
374
375 pub fn relative_to(mut self, root_dir: &Path) -> Result<Self, IndexUrlError> {
377 if let IndexUrl::Path(ref url) = self.url {
378 if let Some(given) = url.given() {
379 self.url = IndexUrl::parse(given, Some(root_dir))?;
380 }
381 }
382 Ok(self)
383 }
384
385 pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
387 if let Some(ignore_error_codes) = &self.ignore_error_codes {
388 IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
389 } else {
390 IndexStatusCodeStrategy::from_index_url(self.url.url())
391 }
392 }
393
394 pub fn artifact_cache_control(&self) -> Option<&str> {
396 if let Some(artifact_cache_control) = self
397 .cache_control
398 .as_ref()
399 .and_then(|cache_control| cache_control.files.as_deref())
400 {
401 Some(artifact_cache_control)
402 } else {
403 IndexCacheControl::artifact_cache_control(self.url.url())
404 }
405 }
406
407 pub fn simple_api_cache_control(&self) -> Option<&str> {
409 if let Some(api_cache_control) = self
410 .cache_control
411 .as_ref()
412 .and_then(|cache_control| cache_control.api.as_deref())
413 {
414 Some(api_cache_control)
415 } else {
416 IndexCacheControl::simple_api_cache_control(self.url.url())
417 }
418 }
419}
420
421impl From<IndexUrl> for Index {
422 fn from(value: IndexUrl) -> Self {
423 Self {
424 name: None,
425 url: value,
426 explicit: false,
427 default: false,
428 origin: None,
429 format: IndexFormat::Simple,
430 publish_url: None,
431 authenticate: AuthPolicy::default(),
432 ignore_error_codes: None,
433 cache_control: None,
434 }
435 }
436}
437
438impl FromStr for Index {
439 type Err = IndexSourceError;
440
441 fn from_str(s: &str) -> Result<Self, Self::Err> {
442 if let Some((name, url)) = s.split_once('=') {
444 if !name.chars().any(|c| c == ':') {
445 let name = IndexName::from_str(name)?;
446 let url = IndexUrl::from_str(url)?;
447 return Ok(Self {
448 name: Some(name),
449 url,
450 explicit: false,
451 default: false,
452 origin: None,
453 format: IndexFormat::Simple,
454 publish_url: None,
455 authenticate: AuthPolicy::default(),
456 ignore_error_codes: None,
457 cache_control: None,
458 });
459 }
460 }
461
462 let url = IndexUrl::from_str(s)?;
464 Ok(Self {
465 name: None,
466 url,
467 explicit: false,
468 default: false,
469 origin: None,
470 format: IndexFormat::Simple,
471 publish_url: None,
472 authenticate: AuthPolicy::default(),
473 ignore_error_codes: None,
474 cache_control: None,
475 })
476 }
477}
478
479#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
481pub struct IndexMetadata {
482 pub url: IndexUrl,
484 pub format: IndexFormat,
486}
487
488impl IndexMetadata {
489 pub fn as_ref(&self) -> IndexMetadataRef<'_> {
491 let Self { url, format: kind } = self;
492 IndexMetadataRef { url, format: *kind }
493 }
494
495 pub fn into_url(self) -> IndexUrl {
497 self.url
498 }
499}
500
501#[derive(Debug, Copy, Clone)]
503pub struct IndexMetadataRef<'a> {
504 pub url: &'a IndexUrl,
506 pub format: IndexFormat,
508}
509
510impl IndexMetadata {
511 pub fn url(&self) -> &IndexUrl {
513 &self.url
514 }
515}
516
517impl IndexMetadataRef<'_> {
518 pub fn url(&self) -> &IndexUrl {
520 self.url
521 }
522}
523
524impl<'a> From<&'a Index> for IndexMetadataRef<'a> {
525 fn from(value: &'a Index) -> Self {
526 Self {
527 url: &value.url,
528 format: value.format,
529 }
530 }
531}
532
533impl<'a> From<&'a IndexMetadata> for IndexMetadataRef<'a> {
534 fn from(value: &'a IndexMetadata) -> Self {
535 Self {
536 url: &value.url,
537 format: value.format,
538 }
539 }
540}
541
542impl From<IndexUrl> for IndexMetadata {
543 fn from(value: IndexUrl) -> Self {
544 Self {
545 url: value,
546 format: IndexFormat::Simple,
547 }
548 }
549}
550
551impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
552 fn from(value: &'a IndexUrl) -> Self {
553 Self {
554 url: value,
555 format: IndexFormat::Simple,
556 }
557 }
558}
559
560#[derive(Deserialize)]
562#[serde(rename_all = "kebab-case")]
563struct IndexWire {
564 name: Option<IndexName>,
565 url: IndexUrl,
566 #[serde(default)]
567 explicit: bool,
568 #[serde(default)]
569 default: bool,
570 #[serde(default)]
571 format: IndexFormat,
572 publish_url: Option<DisplaySafeUrl>,
573 #[serde(default)]
574 authenticate: AuthPolicy,
575 #[serde(default)]
576 ignore_error_codes: Option<Vec<SerializableStatusCode>>,
577 #[serde(default)]
578 cache_control: Option<IndexCacheControl>,
579}
580
581impl<'de> Deserialize<'de> for Index {
582 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
583 where
584 D: serde::Deserializer<'de>,
585 {
586 let wire = IndexWire::deserialize(deserializer)?;
587
588 if wire.explicit && wire.name.is_none() {
589 return Err(serde::de::Error::custom(format!(
590 "An index with `explicit = true` requires a `name`: {}",
591 wire.url
592 )));
593 }
594
595 Ok(Self {
596 name: wire.name,
597 url: wire.url,
598 explicit: wire.explicit,
599 default: wire.default,
600 origin: None,
601 format: wire.format,
602 publish_url: wire.publish_url,
603 authenticate: wire.authenticate,
604 ignore_error_codes: wire.ignore_error_codes,
605 cache_control: wire.cache_control,
606 })
607 }
608}
609
610#[derive(Error, Debug)]
612pub enum IndexSourceError {
613 #[error(transparent)]
614 Url(#[from] IndexUrlError),
615 #[error(transparent)]
616 IndexName(#[from] IndexNameError),
617 #[error("Index included a name, but the name was empty")]
618 EmptyName,
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
626 fn test_index_cache_control_headers() {
627 let toml_str = r#"
629 name = "test-index"
630 url = "https://test.example.com/simple"
631 cache-control = { api = "max-age=600", files = "max-age=3600" }
632 "#;
633
634 let index: Index = toml::from_str(toml_str).unwrap();
635 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
636 assert!(index.cache_control.is_some());
637 let cache_control = index.cache_control.as_ref().unwrap();
638 assert_eq!(cache_control.api.as_deref(), Some("max-age=600"));
639 assert_eq!(cache_control.files.as_deref(), Some("max-age=3600"));
640 }
641
642 #[test]
643 fn test_index_without_cache_control() {
644 let toml_str = r#"
646 name = "test-index"
647 url = "https://test.example.com/simple"
648 "#;
649
650 let index: Index = toml::from_str(toml_str).unwrap();
651 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
652 assert_eq!(index.cache_control, None);
653 }
654
655 #[test]
656 fn test_index_partial_cache_control() {
657 let toml_str = r#"
659 name = "test-index"
660 url = "https://test.example.com/simple"
661 cache-control = { api = "max-age=300" }
662 "#;
663
664 let index: Index = toml::from_str(toml_str).unwrap();
665 assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index");
666 assert!(index.cache_control.is_some());
667 let cache_control = index.cache_control.as_ref().unwrap();
668 assert_eq!(cache_control.api.as_deref(), Some("max-age=300"));
669 assert_eq!(cache_control.files, None);
670 }
671}