1#[cfg(feature = "schemars")]
2use std::borrow::Cow;
3use std::{
4 ops::{Deref, DerefMut},
5 str::FromStr,
6};
7
8use jiff::{Span, Timestamp, ToSpan, Unit, tz::TimeZone};
9use rustc_hash::FxHashMap;
10use uv_normalize::PackageName;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ExcludeNewerValueChange {
14 SpanChanged(ExcludeNewerSpan, ExcludeNewerSpan),
16 SpanAdded(ExcludeNewerSpan),
18 SpanRemoved,
20 RelativeTimestampChanged(Timestamp, Timestamp, ExcludeNewerSpan),
22 AbsoluteTimestampChanged(Timestamp, Timestamp),
24}
25
26impl ExcludeNewerValueChange {
27 pub fn is_relative_timestamp_change(&self) -> bool {
28 matches!(self, Self::RelativeTimestampChanged(_, _, _))
29 }
30}
31
32impl std::fmt::Display for ExcludeNewerValueChange {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::SpanChanged(old, new) => {
36 write!(f, "change of exclude newer span from `{old}` to `{new}`")
37 }
38 Self::SpanAdded(span) => {
39 write!(f, "addition of exclude newer span `{span}`")
40 }
41 Self::SpanRemoved => {
42 write!(f, "removal of exclude newer span")
43 }
44 Self::RelativeTimestampChanged(old, new, span) => {
45 write!(
46 f,
47 "change of calculated ({span}) exclude newer timestamp from `{old}` to `{new}`"
48 )
49 }
50 Self::AbsoluteTimestampChanged(old, new) => {
51 write!(
52 f,
53 "change of exclude newer timestamp from `{old}` to `{new}`"
54 )
55 }
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum ExcludeNewerChange {
62 GlobalChanged(ExcludeNewerValueChange),
63 GlobalAdded(ExcludeNewerValue),
64 GlobalRemoved,
65 Package(ExcludeNewerPackageChange),
66}
67
68impl ExcludeNewerChange {
69 pub fn is_relative_timestamp_change(&self) -> bool {
71 match self {
72 Self::GlobalChanged(change) => change.is_relative_timestamp_change(),
73 Self::GlobalAdded(_) | Self::GlobalRemoved => false,
74 Self::Package(change) => change.is_relative_timestamp_change(),
75 }
76 }
77}
78
79impl std::fmt::Display for ExcludeNewerChange {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Self::GlobalChanged(change) => {
83 write!(f, "{change}")
84 }
85 Self::GlobalAdded(value) => {
86 write!(f, "addition of global exclude newer {value}")
87 }
88 Self::GlobalRemoved => write!(f, "removal of global exclude newer"),
89 Self::Package(change) => {
90 write!(f, "{change}")
91 }
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum ExcludeNewerPackageChange {
98 PackageAdded(PackageName, ExcludeNewerValue),
99 PackageRemoved(PackageName),
100 PackageChanged(PackageName, ExcludeNewerValueChange),
101}
102
103impl ExcludeNewerPackageChange {
104 pub fn is_relative_timestamp_change(&self) -> bool {
105 match self {
106 Self::PackageAdded(_, _) | Self::PackageRemoved(_) => false,
107 Self::PackageChanged(_, change) => change.is_relative_timestamp_change(),
108 }
109 }
110}
111
112impl std::fmt::Display for ExcludeNewerPackageChange {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Self::PackageAdded(name, value) => {
116 write!(
117 f,
118 "addition of exclude newer `{value}` for package `{name}`"
119 )
120 }
121 Self::PackageRemoved(name) => {
122 write!(f, "removal of exclude newer for package `{name}`")
123 }
124 Self::PackageChanged(name, change) => {
125 write!(f, "{change} for package `{name}`")
126 }
127 }
128 }
129}
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ExcludeNewerValue {
133 timestamp: Timestamp,
135 span: Option<ExcludeNewerSpan>,
137}
138
139impl ExcludeNewerValue {
140 pub fn into_parts(self) -> (Timestamp, Option<ExcludeNewerSpan>) {
141 (self.timestamp, self.span)
142 }
143
144 pub fn compare(&self, other: &Self) -> Option<ExcludeNewerValueChange> {
145 match (&self.span, &other.span) {
146 (None, Some(span)) => Some(ExcludeNewerValueChange::SpanAdded(*span)),
147 (Some(_), None) => Some(ExcludeNewerValueChange::SpanRemoved),
148 (Some(self_span), Some(other_span)) if self_span != other_span => Some(
149 ExcludeNewerValueChange::SpanChanged(*self_span, *other_span),
150 ),
151 (Some(_), Some(span)) if self.timestamp != other.timestamp => {
152 Some(ExcludeNewerValueChange::RelativeTimestampChanged(
153 self.timestamp,
154 other.timestamp,
155 *span,
156 ))
157 }
158 (None, None) if self.timestamp != other.timestamp => Some(
159 ExcludeNewerValueChange::AbsoluteTimestampChanged(self.timestamp, other.timestamp),
160 ),
161 (Some(_), Some(_)) | (None, None) => None,
162 }
163 }
164}
165
166#[derive(Debug, Copy, Clone)]
167pub struct ExcludeNewerSpan(Span);
168
169impl std::fmt::Display for ExcludeNewerSpan {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 self.0.fmt(f)
172 }
173}
174
175impl PartialEq for ExcludeNewerSpan {
176 fn eq(&self, other: &Self) -> bool {
177 self.0.fieldwise() == other.0.fieldwise()
178 }
179}
180
181impl Eq for ExcludeNewerSpan {}
182
183impl serde::Serialize for ExcludeNewerSpan {
184 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
188 where
189 S: serde::Serializer,
190 {
191 serializer.serialize_str(&self.0.to_string())
192 }
193}
194
195impl<'de> serde::Deserialize<'de> for ExcludeNewerSpan {
196 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
197 where
198 D: serde::Deserializer<'de>,
199 {
200 let s = String::deserialize(deserializer)?;
201 let span: Span = s.parse().map_err(serde::de::Error::custom)?;
202 Ok(Self(span))
203 }
204}
205
206impl serde::Serialize for ExcludeNewerValue {
207 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
208 where
209 S: serde::Serializer,
210 {
211 self.timestamp.serialize(serializer)
212 }
213}
214
215impl<'de> serde::Deserialize<'de> for ExcludeNewerValue {
216 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
217 where
218 D: serde::Deserializer<'de>,
219 {
220 #[derive(serde::Deserialize)]
223 struct TableForm {
224 timestamp: Timestamp,
225 span: Option<ExcludeNewerSpan>,
226 }
227
228 #[derive(serde::Deserialize)]
229 #[serde(untagged)]
230 enum Helper {
231 String(String),
232 Table(Box<TableForm>),
233 }
234
235 match Helper::deserialize(deserializer)? {
236 Helper::String(s) => Self::from_str(&s).map_err(serde::de::Error::custom),
237 Helper::Table(table) => Ok(Self::new(table.timestamp, table.span)),
238 }
239 }
240}
241
242impl ExcludeNewerValue {
243 pub fn timestamp_millis(&self) -> i64 {
245 self.timestamp.as_millisecond()
246 }
247
248 pub fn timestamp(&self) -> Timestamp {
250 self.timestamp
251 }
252
253 pub fn span(&self) -> Option<&ExcludeNewerSpan> {
255 self.span.as_ref()
256 }
257
258 pub fn new(timestamp: Timestamp, span: Option<ExcludeNewerSpan>) -> Self {
260 Self { timestamp, span }
261 }
262}
263
264impl From<Timestamp> for ExcludeNewerValue {
265 fn from(timestamp: Timestamp) -> Self {
266 Self {
267 timestamp,
268 span: None,
269 }
270 }
271}
272
273fn format_exclude_newer_error(
275 input: &str,
276 date_err: &jiff::Error,
277 span_err: &jiff::Error,
278) -> String {
279 let trimmed = input.trim();
280
281 let after_sign = trimmed.trim_start_matches(['+', '-']);
283 if after_sign.starts_with('P') || after_sign.starts_with('p') {
284 return format!("`{input}` could not be parsed as an ISO 8601 duration: {span_err}");
285 }
286
287 let after_sign_trimmed = after_sign.trim_start();
290 let mut chars = after_sign_trimmed.chars().peekable();
291
292 if chars.peek().is_some_and(char::is_ascii_digit) {
294 while chars.peek().is_some_and(char::is_ascii_digit) {
296 chars.next();
297 }
298 while chars.peek().is_some_and(|c| c.is_whitespace()) {
300 chars.next();
301 }
302 if chars.peek().is_some_and(char::is_ascii_alphabetic) {
304 return format!("`{input}` could not be parsed as a duration: {span_err}");
305 }
306 }
307
308 let mut chars = after_sign.chars();
310 let looks_like_date = chars.next().is_some_and(|c| c.is_ascii_digit())
311 && chars.next().is_some_and(|c| c.is_ascii_digit())
312 && chars.next().is_some_and(|c| c.is_ascii_digit())
313 && chars.next().is_some_and(|c| c.is_ascii_digit())
314 && chars.next().is_some_and(|c| c == '-');
315
316 if looks_like_date {
317 return format!("`{input}` could not be parsed as a valid date: {date_err}");
318 }
319
320 format!(
322 "`{input}` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`)"
323 )
324}
325
326impl FromStr for ExcludeNewerValue {
327 type Err = String;
328
329 fn from_str(input: &str) -> Result<Self, Self::Err> {
335 if let Ok(timestamp) = input.parse::<Timestamp>() {
337 return Ok(Self::new(timestamp, None));
338 }
339
340 let date_err = match input.parse::<jiff::civil::Date>() {
348 Ok(date) => {
349 let timestamp = date
350 .checked_add(1.day())
351 .and_then(|date| date.to_zoned(TimeZone::system()))
352 .map(|zdt| zdt.timestamp())
353 .map_err(|err| {
354 format!(
355 "`{input}` parsed to date `{date}`, but could not \
356 be converted to a timestamp: {err}",
357 )
358 })?;
359 return Ok(Self::new(timestamp, None));
360 }
361 Err(err) => err,
362 };
363
364 let span_err = match input.parse::<Span>() {
366 Ok(span) => {
367 let now = if let Ok(test_time) = std::env::var("UV_TEST_CURRENT_TIMESTAMP") {
369 test_time
370 .parse::<Timestamp>()
371 .expect("UV_TEST_CURRENT_TIMESTAMP must be a valid RFC 3339 timestamp")
372 .to_zoned(TimeZone::UTC)
373 } else {
374 Timestamp::now().to_zoned(TimeZone::UTC)
375 };
376
377 if span.get_years() != 0 {
381 let years = span
382 .total((Unit::Year, &now))
383 .map(f64::ceil)
384 .unwrap_or(1.0)
385 .abs();
386 let days = years * 365.0;
387 return Err(format!(
388 "Duration `{input}` uses unit 'years' which is not allowed; use days instead, e.g., `{days:.0} days`.",
389 ));
390 }
391 if span.get_months() != 0 {
392 let months = span
393 .total((Unit::Month, &now))
394 .map(f64::ceil)
395 .unwrap_or(1.0)
396 .abs();
397 let days = months * 30.0;
398 return Err(format!(
399 "Duration `{input}` uses 'months' which is not allowed; use days instead, e.g., `{days:.0} days`."
400 ));
401 }
402
403 let cutoff = now.checked_sub(span.abs()).map_err(|err| {
409 format!("Duration `{input}` is too large to subtract from current time: {err}")
410 })?;
411
412 return Ok(Self::new(cutoff.into(), Some(ExcludeNewerSpan(span))));
413 }
414 Err(err) => err,
415 };
416
417 Err(format_exclude_newer_error(input, &date_err, &span_err))
419 }
420}
421
422impl std::fmt::Display for ExcludeNewerValue {
423 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424 self.timestamp.fmt(f)
425 }
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
430#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
431pub struct ExcludeNewerPackageEntry {
432 pub package: PackageName,
433 pub timestamp: ExcludeNewerValue,
434}
435
436impl FromStr for ExcludeNewerPackageEntry {
437 type Err = String;
438
439 fn from_str(s: &str) -> Result<Self, Self::Err> {
441 let Some((package, date)) = s.split_once('=') else {
442 return Err(format!(
443 "Invalid `exclude-newer-package` value `{s}`: expected format `PACKAGE=DATE`"
444 ));
445 };
446
447 let package = PackageName::from_str(package).map_err(|err| {
448 format!("Invalid `exclude-newer-package` package name `{package}`: {err}")
449 })?;
450 let timestamp = ExcludeNewerValue::from_str(date)
451 .map_err(|err| format!("Invalid `exclude-newer-package` timestamp `{date}`: {err}"))?;
452
453 Ok(Self { package, timestamp })
454 }
455}
456
457impl From<(PackageName, ExcludeNewerValue)> for ExcludeNewerPackageEntry {
458 fn from((package, timestamp): (PackageName, ExcludeNewerValue)) -> Self {
459 Self { package, timestamp }
460 }
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
464#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
465pub struct ExcludeNewerPackage(FxHashMap<PackageName, ExcludeNewerValue>);
466
467impl Deref for ExcludeNewerPackage {
468 type Target = FxHashMap<PackageName, ExcludeNewerValue>;
469
470 fn deref(&self) -> &Self::Target {
471 &self.0
472 }
473}
474
475impl DerefMut for ExcludeNewerPackage {
476 fn deref_mut(&mut self) -> &mut Self::Target {
477 &mut self.0
478 }
479}
480
481impl FromIterator<ExcludeNewerPackageEntry> for ExcludeNewerPackage {
482 fn from_iter<T: IntoIterator<Item = ExcludeNewerPackageEntry>>(iter: T) -> Self {
483 Self(
484 iter.into_iter()
485 .map(|entry| (entry.package, entry.timestamp))
486 .collect(),
487 )
488 }
489}
490
491impl IntoIterator for ExcludeNewerPackage {
492 type Item = (PackageName, ExcludeNewerValue);
493 type IntoIter = std::collections::hash_map::IntoIter<PackageName, ExcludeNewerValue>;
494
495 fn into_iter(self) -> Self::IntoIter {
496 self.0.into_iter()
497 }
498}
499
500impl<'a> IntoIterator for &'a ExcludeNewerPackage {
501 type Item = (&'a PackageName, &'a ExcludeNewerValue);
502 type IntoIter = std::collections::hash_map::Iter<'a, PackageName, ExcludeNewerValue>;
503
504 fn into_iter(self) -> Self::IntoIter {
505 self.0.iter()
506 }
507}
508
509impl ExcludeNewerPackage {
510 pub fn into_inner(self) -> FxHashMap<PackageName, ExcludeNewerValue> {
512 self.0
513 }
514
515 pub fn compare(&self, other: &Self) -> Option<ExcludeNewerPackageChange> {
516 for (package, timestamp) in self {
517 let Some(other_timestamp) = other.get(package) else {
518 return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone()));
519 };
520 if let Some(change) = timestamp.compare(other_timestamp) {
521 return Some(ExcludeNewerPackageChange::PackageChanged(
522 package.clone(),
523 change,
524 ));
525 }
526 }
527
528 for (package, value) in other {
529 if !self.contains_key(package) {
530 return Some(ExcludeNewerPackageChange::PackageAdded(
531 package.clone(),
532 value.clone(),
533 ));
534 }
535 }
536
537 None
538 }
539}
540
541#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
543#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
544pub struct ExcludeNewer {
545 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub global: Option<ExcludeNewerValue>,
548 #[serde(default, skip_serializing_if = "FxHashMap::is_empty")]
550 pub package: ExcludeNewerPackage,
551}
552
553impl ExcludeNewer {
554 pub fn global(global: ExcludeNewerValue) -> Self {
556 Self {
557 global: Some(global),
558 package: ExcludeNewerPackage::default(),
559 }
560 }
561
562 pub fn new(global: Option<ExcludeNewerValue>, package: ExcludeNewerPackage) -> Self {
564 Self { global, package }
565 }
566
567 pub fn from_args(
569 global: Option<ExcludeNewerValue>,
570 package: Vec<ExcludeNewerPackageEntry>,
571 ) -> Self {
572 let package: ExcludeNewerPackage = package.into_iter().collect();
573
574 Self { global, package }
575 }
576
577 pub fn exclude_newer_package(&self, package_name: &PackageName) -> Option<ExcludeNewerValue> {
579 self.package
580 .get(package_name)
581 .cloned()
582 .or(self.global.clone())
583 }
584
585 pub fn is_empty(&self) -> bool {
587 self.global.is_none() && self.package.is_empty()
588 }
589
590 pub fn compare(&self, other: &Self) -> Option<ExcludeNewerChange> {
591 match (&self.global, &other.global) {
592 (Some(self_global), Some(other_global)) => {
593 if let Some(change) = self_global.compare(other_global) {
594 return Some(ExcludeNewerChange::GlobalChanged(change));
595 }
596 }
597 (None, Some(global)) => {
598 return Some(ExcludeNewerChange::GlobalAdded(global.clone()));
599 }
600 (Some(_), None) => return Some(ExcludeNewerChange::GlobalRemoved),
601 (None, None) => (),
602 }
603 self.package
604 .compare(&other.package)
605 .map(ExcludeNewerChange::Package)
606 }
607}
608
609impl std::fmt::Display for ExcludeNewer {
610 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611 if let Some(global) = &self.global {
612 write!(f, "global: {global}")?;
613 if !self.package.is_empty() {
614 write!(f, ", ")?;
615 }
616 }
617 let mut first = true;
618 for (name, timestamp) in &self.package {
619 if !first {
620 write!(f, ", ")?;
621 }
622 write!(f, "{name}: {timestamp}")?;
623 first = false;
624 }
625 Ok(())
626 }
627}
628
629#[cfg(feature = "schemars")]
630impl schemars::JsonSchema for ExcludeNewerValue {
631 fn schema_name() -> Cow<'static, str> {
632 Cow::Borrowed("ExcludeNewerTimestamp")
633 }
634
635 fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
636 schemars::json_schema!({
637 "type": "string",
638 "pattern": r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2}))?$",
639 "description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`), as well as relative durations (e.g., `1 week`, `30 days`, `6 months`). Relative durations are resolved to a timestamp at lock time.",
640 })
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use std::str::FromStr;
648
649 #[test]
650 fn test_exclude_newer_timestamp_absolute() {
651 let timestamp = ExcludeNewerValue::from_str("2023-01-01T00:00:00Z").unwrap();
653 assert!(timestamp.to_string().contains("2023-01-01"));
654
655 let timestamp = ExcludeNewerValue::from_str("2023-06-15").unwrap();
657 assert!(timestamp.to_string().contains("2023-06-16")); }
659
660 #[test]
661 fn test_exclude_newer_timestamp_relative() {
662 let timestamp = ExcludeNewerValue::from_str("1 hour").unwrap();
664 let now = jiff::Timestamp::now();
665 let diff = now.as_second() - timestamp.timestamp.as_second();
666 assert!(
668 (3550..=3650).contains(&diff),
669 "Expected ~3600 seconds, got {diff}"
670 );
671
672 assert!(timestamp.timestamp < now, "Timestamp should be in the past");
674
675 assert!(ExcludeNewerValue::from_str("2 days").is_ok());
677 assert!(ExcludeNewerValue::from_str("1 week").is_ok());
678 assert!(ExcludeNewerValue::from_str("30 days").is_ok());
679 }
680
681 #[test]
682 fn test_exclude_newer_timestamp_invalid() {
683 assert!(ExcludeNewerValue::from_str("invalid").is_err());
685 assert!(ExcludeNewerValue::from_str("not a date").is_err());
686 assert!(ExcludeNewerValue::from_str("").is_err());
687 }
688
689 #[test]
690 fn test_exclude_newer_package_entry() {
691 let entry = ExcludeNewerPackageEntry::from_str("numpy=2023-01-01T00:00:00Z").unwrap();
692 assert_eq!(entry.package.as_ref(), "numpy");
693 assert!(entry.timestamp.to_string().contains("2023-01-01"));
694
695 let entry = ExcludeNewerPackageEntry::from_str("requests=7 days").unwrap();
697 assert_eq!(entry.package.as_ref(), "requests");
698 }
700}