uv_resolver/
exclude_newer.rs

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    /// A relative span changed to a new value
14    SpanChanged(ExcludeNewerSpan, ExcludeNewerSpan),
15    /// A relative span was added
16    SpanAdded(ExcludeNewerSpan),
17    /// A relative span was removed
18    SpanRemoved,
19    /// A relative span is present and the timestamp changed
20    RelativeTimestampChanged(Timestamp, Timestamp, ExcludeNewerSpan),
21    /// The timestamp changed and a relative span is not present
22    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    /// Whether the change is due to a change in a relative timestamp.
69    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/// A timestamp that excludes files newer than it.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ExcludeNewerValue {
132    /// The resolved timestamp.
133    timestamp: Timestamp,
134    /// The span used to derive the [`Timestamp`], if any.
135    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    /// Serialize to an ISO 8601 duration string.
184    ///
185    /// We use ISO 8601 format for serialization (rather than the "friendly" format).
186    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        // Support both a simple string ("2024-03-11T00:00:00Z") and a table
220        // ({ timestamp = "2024-03-11T00:00:00Z", span = "P2W" })
221        #[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    /// Return the [`Timestamp`] in milliseconds.
243    pub fn timestamp_millis(&self) -> i64 {
244        self.timestamp.as_millisecond()
245    }
246
247    /// Return the [`Timestamp`].
248    pub fn timestamp(&self) -> Timestamp {
249        self.timestamp
250    }
251
252    /// Return the [`ExcludeNewerSpan`] used to construct the [`Timestamp`], if any.
253    pub fn span(&self) -> Option<&ExcludeNewerSpan> {
254        self.span.as_ref()
255    }
256
257    /// Create a new [`ExcludeNewerTimestamp`].
258    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
272/// Determine what format the user likely intended and return an appropriate error message.
273fn 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    // Check for ISO 8601 duration (`[-+]?[Pp]`), e.g., "P2W", "+P1D", "-P30D"
281    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    // Check for friendly duration (`[-+]?\s*[0-9]+\s*[A-Za-z]`), e.g., "2 weeks", "-30 days",
287    // "1hour"
288    let after_sign_trimmed = after_sign.trim_start();
289    let mut chars = after_sign_trimmed.chars().peekable();
290
291    // Check if we start with a digit
292    if chars.peek().is_some_and(char::is_ascii_digit) {
293        // Skip digits
294        while chars.peek().is_some_and(char::is_ascii_digit) {
295            chars.next();
296        }
297        // Skip optional whitespace
298        while chars.peek().is_some_and(|c| c.is_whitespace()) {
299            chars.next();
300        }
301        // Check if next character is a letter (unit designator)
302        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    // Check for date/timestamp (`[-+]?[0-9]{4}-`), e.g., "2024-01-01", "2024-01-01T00:00:00Z"
308    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    // If we can't tell, return a generic error message
320    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    /// Parse an [`ExcludeNewerTimestamp`] from a string.
329    ///
330    /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format
331    /// (e.g., `2006-12-02`), "friendly" durations (e.g., `1 week`, `30 days`), and ISO 8601
332    /// durations (e.g., `PT24H`, `P7D`, `P30D`).
333    fn from_str(input: &str) -> Result<Self, Self::Err> {
334        // Try parsing as a timestamp first
335        if let Ok(timestamp) = input.parse::<Timestamp>() {
336            return Ok(Self::new(timestamp, None));
337        }
338
339        // Try parsing as a date
340        // In Jiff, if an RFC 3339 timestamp could be parsed, then it must necessarily be the case
341        // that a date can also be parsed. So we can collapse the error cases here. That is, if we
342        // fail to parse a timestamp and a date, then it should be sufficient to just report the
343        // error from parsing the date. If someone tried to write a timestamp but committed an error
344        // in the non-date portion, the date parsing below will still report a holistic error that
345        // will make sense to the user. (I added a snapshot test for that case.)
346        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        // Try parsing as a span
364        let span_err = match input.parse::<Span>() {
365            Ok(span) => {
366                // Allow overriding the current time in tests for deterministic snapshots
367                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                // We do not allow years and months as units, as the amount of time they represent
377                // is not fixed and can differ depending on the local time zone. We could allow this
378                // via the CLI in the future, but shouldn't allow it via persistent configuration.
379                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                // We're using a UTC timezone so there are no transitions (e.g., DST) and days are
403                // always 24 hours. This means that we can also allow weeks as a unit.
404                //
405                // Note we use `span.abs()` so `1 day ago` has the same effect as `1 day` instead
406                // of resulting in a future date.
407                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        // Return a targeted error message based on heuristics about what the user likely intended
417        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/// A package-specific exclude-newer entry.
428#[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    /// Parses a [`ExcludeNewerPackageEntry`] from a string in the format `PACKAGE=DATE`.
439    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    /// Convert to the inner `HashMap`.
510    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/// A setting that excludes files newer than a timestamp, at a global level or per-package.
541#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543pub struct ExcludeNewer {
544    /// Global timestamp that applies to all packages if no package-specific timestamp is set.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub global: Option<ExcludeNewerValue>,
547    /// Per-package timestamps that override the global timestamp.
548    #[serde(default, skip_serializing_if = "FxHashMap::is_empty")]
549    pub package: ExcludeNewerPackage,
550}
551
552impl ExcludeNewer {
553    /// Create a new exclude newer configuration with just a global timestamp.
554    pub fn global(global: ExcludeNewerValue) -> Self {
555        Self {
556            global: Some(global),
557            package: ExcludeNewerPackage::default(),
558        }
559    }
560
561    /// Create a new exclude newer configuration.
562    pub fn new(global: Option<ExcludeNewerValue>, package: ExcludeNewerPackage) -> Self {
563        Self { global, package }
564    }
565
566    /// Create from CLI arguments.
567    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    /// Returns the timestamp for a specific package, falling back to the global timestamp if set.
577    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    /// Returns true if this has any configuration (global or per-package).
585    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}