1use std::borrow::Cow;
2use std::{
3 ops::{Deref, DerefMut},
4 str::FromStr,
5};
6
7use jiff::{Span, Timestamp, ToSpan, Unit, tz::TimeZone};
8use rustc_hash::FxHashMap;
9use uv_normalize::PackageName;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ExcludeNewerValueChange {
13 SpanChanged(ExcludeNewerSpan, ExcludeNewerSpan),
15 SpanAdded(ExcludeNewerSpan),
17 SpanRemoved,
19 RelativeTimestampChanged(Timestamp, Timestamp, ExcludeNewerSpan),
21 AbsoluteTimestampChanged(Timestamp, Timestamp),
23}
24
25impl ExcludeNewerValueChange {
26 pub fn is_relative_timestamp_change(&self) -> bool {
27 matches!(self, Self::RelativeTimestampChanged(_, _, _))
28 }
29}
30
31impl std::fmt::Display for ExcludeNewerValueChange {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Self::SpanChanged(old, new) => {
35 write!(f, "change of exclude newer span from `{old}` to `{new}`")
36 }
37 Self::SpanAdded(span) => {
38 write!(f, "addition of exclude newer span `{span}`")
39 }
40 Self::SpanRemoved => {
41 write!(f, "removal of exclude newer span")
42 }
43 Self::RelativeTimestampChanged(old, new, span) => {
44 write!(
45 f,
46 "change of calculated ({span}) exclude newer timestamp from `{old}` to `{new}`"
47 )
48 }
49 Self::AbsoluteTimestampChanged(old, new) => {
50 write!(
51 f,
52 "change of exclude newer timestamp from `{old}` to `{new}`"
53 )
54 }
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum ExcludeNewerChange {
61 GlobalChanged(ExcludeNewerValueChange),
62 GlobalAdded(ExcludeNewerValue),
63 GlobalRemoved,
64 Package(ExcludeNewerPackageChange),
65}
66
67impl ExcludeNewerChange {
68 pub fn is_relative_timestamp_change(&self) -> bool {
70 match self {
71 Self::GlobalChanged(change) => change.is_relative_timestamp_change(),
72 Self::GlobalAdded(_) | Self::GlobalRemoved => false,
73 Self::Package(change) => change.is_relative_timestamp_change(),
74 }
75 }
76}
77
78impl std::fmt::Display for ExcludeNewerChange {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::GlobalChanged(change) => {
82 write!(f, "{change}")
83 }
84 Self::GlobalAdded(value) => {
85 write!(f, "addition of global exclude newer {value}")
86 }
87 Self::GlobalRemoved => write!(f, "removal of global exclude newer"),
88 Self::Package(change) => {
89 write!(f, "{change}")
90 }
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum ExcludeNewerPackageChange {
97 PackageAdded(PackageName, ExcludeNewerValue),
98 PackageRemoved(PackageName),
99 PackageChanged(PackageName, ExcludeNewerValueChange),
100}
101
102impl ExcludeNewerPackageChange {
103 pub fn is_relative_timestamp_change(&self) -> bool {
104 match self {
105 Self::PackageAdded(_, _) | Self::PackageRemoved(_) => false,
106 Self::PackageChanged(_, change) => change.is_relative_timestamp_change(),
107 }
108 }
109}
110
111impl std::fmt::Display for ExcludeNewerPackageChange {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::PackageAdded(name, value) => {
115 write!(
116 f,
117 "addition of exclude newer `{value}` for package `{name}`"
118 )
119 }
120 Self::PackageRemoved(name) => {
121 write!(f, "removal of exclude newer for package `{name}`")
122 }
123 Self::PackageChanged(name, change) => {
124 write!(f, "{change} for package `{name}`")
125 }
126 }
127 }
128}
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ExcludeNewerValue {
132 timestamp: Timestamp,
134 span: Option<ExcludeNewerSpan>,
136}
137
138impl ExcludeNewerValue {
139 pub fn into_parts(self) -> (Timestamp, Option<ExcludeNewerSpan>) {
140 (self.timestamp, self.span)
141 }
142
143 pub fn compare(&self, other: &Self) -> Option<ExcludeNewerValueChange> {
144 match (&self.span, &other.span) {
145 (None, Some(span)) => Some(ExcludeNewerValueChange::SpanAdded(*span)),
146 (Some(_), None) => Some(ExcludeNewerValueChange::SpanRemoved),
147 (Some(self_span), Some(other_span)) if self_span != other_span => Some(
148 ExcludeNewerValueChange::SpanChanged(*self_span, *other_span),
149 ),
150 (Some(_), Some(span)) if self.timestamp != other.timestamp => {
151 Some(ExcludeNewerValueChange::RelativeTimestampChanged(
152 self.timestamp,
153 other.timestamp,
154 *span,
155 ))
156 }
157 (None, None) if self.timestamp != other.timestamp => Some(
158 ExcludeNewerValueChange::AbsoluteTimestampChanged(self.timestamp, other.timestamp),
159 ),
160 (Some(_), Some(_)) | (None, None) => None,
161 }
162 }
163}
164
165#[derive(Debug, Copy, Clone)]
166pub struct ExcludeNewerSpan(Span);
167
168impl std::fmt::Display for ExcludeNewerSpan {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 self.0.fmt(f)
171 }
172}
173
174impl PartialEq for ExcludeNewerSpan {
175 fn eq(&self, other: &Self) -> bool {
176 self.0.fieldwise() == other.0.fieldwise()
177 }
178}
179
180impl Eq for ExcludeNewerSpan {}
181
182impl serde::Serialize for ExcludeNewerSpan {
183 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
187 where
188 S: serde::Serializer,
189 {
190 serializer.serialize_str(&self.0.to_string())
191 }
192}
193
194impl<'de> serde::Deserialize<'de> for ExcludeNewerSpan {
195 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
196 where
197 D: serde::Deserializer<'de>,
198 {
199 let s = <Cow<'_, str>>::deserialize(deserializer)?;
200 let span: Span = s.parse().map_err(serde::de::Error::custom)?;
201 Ok(Self(span))
202 }
203}
204
205impl serde::Serialize for ExcludeNewerValue {
206 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
207 where
208 S: serde::Serializer,
209 {
210 self.timestamp.serialize(serializer)
211 }
212}
213
214impl<'de> serde::Deserialize<'de> for ExcludeNewerValue {
215 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
216 where
217 D: serde::Deserializer<'de>,
218 {
219 #[derive(serde::Deserialize)]
222 struct TableForm {
223 timestamp: Timestamp,
224 span: Option<ExcludeNewerSpan>,
225 }
226
227 #[derive(serde::Deserialize)]
228 #[serde(untagged)]
229 enum Helper {
230 String(String),
231 Table(Box<TableForm>),
232 }
233
234 match Helper::deserialize(deserializer)? {
235 Helper::String(s) => Self::from_str(&s).map_err(serde::de::Error::custom),
236 Helper::Table(table) => Ok(Self::new(table.timestamp, table.span)),
237 }
238 }
239}
240
241impl ExcludeNewerValue {
242 pub fn timestamp_millis(&self) -> i64 {
244 self.timestamp.as_millisecond()
245 }
246
247 pub fn timestamp(&self) -> Timestamp {
249 self.timestamp
250 }
251
252 pub fn span(&self) -> Option<&ExcludeNewerSpan> {
254 self.span.as_ref()
255 }
256
257 pub fn new(timestamp: Timestamp, span: Option<ExcludeNewerSpan>) -> Self {
259 Self { timestamp, span }
260 }
261}
262
263impl From<Timestamp> for ExcludeNewerValue {
264 fn from(timestamp: Timestamp) -> Self {
265 Self {
266 timestamp,
267 span: None,
268 }
269 }
270}
271
272fn format_exclude_newer_error(
274 input: &str,
275 date_err: &jiff::Error,
276 span_err: &jiff::Error,
277) -> String {
278 let trimmed = input.trim();
279
280 let after_sign = trimmed.trim_start_matches(['+', '-']);
282 if after_sign.starts_with('P') || after_sign.starts_with('p') {
283 return format!("`{input}` could not be parsed as an ISO 8601 duration: {span_err}");
284 }
285
286 let after_sign_trimmed = after_sign.trim_start();
289 let mut chars = after_sign_trimmed.chars().peekable();
290
291 if chars.peek().is_some_and(char::is_ascii_digit) {
293 while chars.peek().is_some_and(char::is_ascii_digit) {
295 chars.next();
296 }
297 while chars.peek().is_some_and(|c| c.is_whitespace()) {
299 chars.next();
300 }
301 if chars.peek().is_some_and(char::is_ascii_alphabetic) {
303 return format!("`{input}` could not be parsed as a duration: {span_err}");
304 }
305 }
306
307 let mut chars = after_sign.chars();
309 let looks_like_date = chars.next().is_some_and(|c| c.is_ascii_digit())
310 && 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 == '-');
314
315 if looks_like_date {
316 return format!("`{input}` could not be parsed as a valid date: {date_err}");
317 }
318
319 format!(
321 "`{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`)"
322 )
323}
324
325impl FromStr for ExcludeNewerValue {
326 type Err = String;
327
328 fn from_str(input: &str) -> Result<Self, Self::Err> {
334 if let Ok(timestamp) = input.parse::<Timestamp>() {
336 return Ok(Self::new(timestamp, None));
337 }
338
339 let date_err = match input.parse::<jiff::civil::Date>() {
347 Ok(date) => {
348 let timestamp = date
349 .checked_add(1.day())
350 .and_then(|date| date.to_zoned(TimeZone::system()))
351 .map(|zdt| zdt.timestamp())
352 .map_err(|err| {
353 format!(
354 "`{input}` parsed to date `{date}`, but could not \
355 be converted to a timestamp: {err}",
356 )
357 })?;
358 return Ok(Self::new(timestamp, None));
359 }
360 Err(err) => err,
361 };
362
363 let span_err = match input.parse::<Span>() {
365 Ok(span) => {
366 let now = if let Ok(test_time) = std::env::var("UV_TEST_CURRENT_TIMESTAMP") {
368 test_time
369 .parse::<Timestamp>()
370 .expect("UV_TEST_CURRENT_TIMESTAMP must be a valid RFC 3339 timestamp")
371 .to_zoned(TimeZone::UTC)
372 } else {
373 Timestamp::now().to_zoned(TimeZone::UTC)
374 };
375
376 if span.get_years() != 0 {
380 let years = span
381 .total((Unit::Year, &now))
382 .map(f64::ceil)
383 .unwrap_or(1.0)
384 .abs();
385 let days = years * 365.0;
386 return Err(format!(
387 "Duration `{input}` uses unit 'years' which is not allowed; use days instead, e.g., `{days:.0} days`.",
388 ));
389 }
390 if span.get_months() != 0 {
391 let months = span
392 .total((Unit::Month, &now))
393 .map(f64::ceil)
394 .unwrap_or(1.0)
395 .abs();
396 let days = months * 30.0;
397 return Err(format!(
398 "Duration `{input}` uses 'months' which is not allowed; use days instead, e.g., `{days:.0} days`."
399 ));
400 }
401
402 let cutoff = now.checked_sub(span.abs()).map_err(|err| {
408 format!("Duration `{input}` is too large to subtract from current time: {err}")
409 })?;
410
411 return Ok(Self::new(cutoff.into(), Some(ExcludeNewerSpan(span))));
412 }
413 Err(err) => err,
414 };
415
416 Err(format_exclude_newer_error(input, &date_err, &span_err))
418 }
419}
420
421impl std::fmt::Display for ExcludeNewerValue {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 self.timestamp.fmt(f)
424 }
425}
426
427#[derive(Debug, Clone, PartialEq, Eq)]
429#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
430pub struct ExcludeNewerPackageEntry {
431 pub package: PackageName,
432 pub timestamp: ExcludeNewerValue,
433}
434
435impl FromStr for ExcludeNewerPackageEntry {
436 type Err = String;
437
438 fn from_str(s: &str) -> Result<Self, Self::Err> {
440 let Some((package, date)) = s.split_once('=') else {
441 return Err(format!(
442 "Invalid `exclude-newer-package` value `{s}`: expected format `PACKAGE=DATE`"
443 ));
444 };
445
446 let package = PackageName::from_str(package).map_err(|err| {
447 format!("Invalid `exclude-newer-package` package name `{package}`: {err}")
448 })?;
449 let timestamp = ExcludeNewerValue::from_str(date)
450 .map_err(|err| format!("Invalid `exclude-newer-package` timestamp `{date}`: {err}"))?;
451
452 Ok(Self { package, timestamp })
453 }
454}
455
456impl From<(PackageName, ExcludeNewerValue)> for ExcludeNewerPackageEntry {
457 fn from((package, timestamp): (PackageName, ExcludeNewerValue)) -> Self {
458 Self { package, timestamp }
459 }
460}
461
462#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
463#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
464pub struct ExcludeNewerPackage(FxHashMap<PackageName, ExcludeNewerValue>);
465
466impl Deref for ExcludeNewerPackage {
467 type Target = FxHashMap<PackageName, ExcludeNewerValue>;
468
469 fn deref(&self) -> &Self::Target {
470 &self.0
471 }
472}
473
474impl DerefMut for ExcludeNewerPackage {
475 fn deref_mut(&mut self) -> &mut Self::Target {
476 &mut self.0
477 }
478}
479
480impl FromIterator<ExcludeNewerPackageEntry> for ExcludeNewerPackage {
481 fn from_iter<T: IntoIterator<Item = ExcludeNewerPackageEntry>>(iter: T) -> Self {
482 Self(
483 iter.into_iter()
484 .map(|entry| (entry.package, entry.timestamp))
485 .collect(),
486 )
487 }
488}
489
490impl IntoIterator for ExcludeNewerPackage {
491 type Item = (PackageName, ExcludeNewerValue);
492 type IntoIter = std::collections::hash_map::IntoIter<PackageName, ExcludeNewerValue>;
493
494 fn into_iter(self) -> Self::IntoIter {
495 self.0.into_iter()
496 }
497}
498
499impl<'a> IntoIterator for &'a ExcludeNewerPackage {
500 type Item = (&'a PackageName, &'a ExcludeNewerValue);
501 type IntoIter = std::collections::hash_map::Iter<'a, PackageName, ExcludeNewerValue>;
502
503 fn into_iter(self) -> Self::IntoIter {
504 self.0.iter()
505 }
506}
507
508impl ExcludeNewerPackage {
509 pub fn into_inner(self) -> FxHashMap<PackageName, ExcludeNewerValue> {
511 self.0
512 }
513
514 pub fn compare(&self, other: &Self) -> Option<ExcludeNewerPackageChange> {
515 for (package, timestamp) in self {
516 let Some(other_timestamp) = other.get(package) else {
517 return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone()));
518 };
519 if let Some(change) = timestamp.compare(other_timestamp) {
520 return Some(ExcludeNewerPackageChange::PackageChanged(
521 package.clone(),
522 change,
523 ));
524 }
525 }
526
527 for (package, value) in other {
528 if !self.contains_key(package) {
529 return Some(ExcludeNewerPackageChange::PackageAdded(
530 package.clone(),
531 value.clone(),
532 ));
533 }
534 }
535
536 None
537 }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543pub struct ExcludeNewer {
544 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub global: Option<ExcludeNewerValue>,
547 #[serde(default, skip_serializing_if = "FxHashMap::is_empty")]
549 pub package: ExcludeNewerPackage,
550}
551
552impl ExcludeNewer {
553 pub fn global(global: ExcludeNewerValue) -> Self {
555 Self {
556 global: Some(global),
557 package: ExcludeNewerPackage::default(),
558 }
559 }
560
561 pub fn new(global: Option<ExcludeNewerValue>, package: ExcludeNewerPackage) -> Self {
563 Self { global, package }
564 }
565
566 pub fn from_args(
568 global: Option<ExcludeNewerValue>,
569 package: Vec<ExcludeNewerPackageEntry>,
570 ) -> Self {
571 let package: ExcludeNewerPackage = package.into_iter().collect();
572
573 Self { global, package }
574 }
575
576 pub fn exclude_newer_package(&self, package_name: &PackageName) -> Option<ExcludeNewerValue> {
578 self.package
579 .get(package_name)
580 .cloned()
581 .or(self.global.clone())
582 }
583
584 pub fn is_empty(&self) -> bool {
586 self.global.is_none() && self.package.is_empty()
587 }
588
589 pub fn compare(&self, other: &Self) -> Option<ExcludeNewerChange> {
590 match (&self.global, &other.global) {
591 (Some(self_global), Some(other_global)) => {
592 if let Some(change) = self_global.compare(other_global) {
593 return Some(ExcludeNewerChange::GlobalChanged(change));
594 }
595 }
596 (None, Some(global)) => {
597 return Some(ExcludeNewerChange::GlobalAdded(global.clone()));
598 }
599 (Some(_), None) => return Some(ExcludeNewerChange::GlobalRemoved),
600 (None, None) => (),
601 }
602 self.package
603 .compare(&other.package)
604 .map(ExcludeNewerChange::Package)
605 }
606}
607
608impl std::fmt::Display for ExcludeNewer {
609 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610 if let Some(global) = &self.global {
611 write!(f, "global: {global}")?;
612 if !self.package.is_empty() {
613 write!(f, ", ")?;
614 }
615 }
616 let mut first = true;
617 for (name, timestamp) in &self.package {
618 if !first {
619 write!(f, ", ")?;
620 }
621 write!(f, "{name}: {timestamp}")?;
622 first = false;
623 }
624 Ok(())
625 }
626}
627
628#[cfg(feature = "schemars")]
629impl schemars::JsonSchema for ExcludeNewerValue {
630 fn schema_name() -> Cow<'static, str> {
631 Cow::Borrowed("ExcludeNewerTimestamp")
632 }
633
634 fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
635 schemars::json_schema!({
636 "type": "string",
637 "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.",
638 })
639 }
640}