Skip to main content

uv_resolver/
exclude_newer.rs

1use std::{
2    collections::BTreeMap,
3    ops::{Deref, DerefMut},
4    str::FromStr,
5};
6
7use jiff::Timestamp;
8use rustc_hash::FxHashSet;
9use serde::ser::SerializeMap;
10use uv_distribution_types::{ExcludeNewerOverride, ExcludeNewerSpan, ExcludeNewerValue};
11use uv_normalize::PackageName;
12use uv_preview::PreviewFeature;
13use uv_warnings::warn_user_once;
14
15/// The configuration layer that supplied the effective `exclude-newer` cutoff for a package.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum EffectiveExcludeNewerSource {
18    /// The global `exclude-newer` setting.
19    Global,
20    /// A package-specific `exclude-newer-package` override.
21    Package,
22    /// An index-specific `[[tool.uv.index]].exclude-newer` override.
23    Index,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ExcludeNewerValueChange {
28    /// A relative span changed to a new value
29    SpanChanged(ExcludeNewerSpan, ExcludeNewerSpan),
30    /// A relative span was added
31    SpanAdded(ExcludeNewerSpan),
32    /// A relative span was removed
33    SpanRemoved,
34    /// A relative span is present and the timestamp changed
35    RelativeTimestampChanged(Timestamp, Timestamp, ExcludeNewerSpan),
36    /// The timestamp changed and a relative span is not present
37    AbsoluteTimestampChanged(Timestamp, Timestamp),
38}
39
40impl ExcludeNewerValueChange {
41    fn is_relative_timestamp_change(&self) -> bool {
42        matches!(self, Self::RelativeTimestampChanged(_, _, _))
43    }
44}
45
46impl std::fmt::Display for ExcludeNewerValueChange {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::SpanChanged(old, new) => {
50                write!(f, "change of exclude newer span from `{old}` to `{new}`")
51            }
52            Self::SpanAdded(span) => {
53                write!(f, "addition of exclude newer span `{span}`")
54            }
55            Self::SpanRemoved => {
56                write!(f, "removal of exclude newer span")
57            }
58            Self::RelativeTimestampChanged(old, new, span) => {
59                write!(
60                    f,
61                    "change of calculated ({span}) exclude newer timestamp from `{old}` to `{new}`"
62                )
63            }
64            Self::AbsoluteTimestampChanged(old, new) => {
65                write!(
66                    f,
67                    "change of exclude newer timestamp from `{old}` to `{new}`"
68                )
69            }
70        }
71    }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ExcludeNewerChange {
76    GlobalChanged(ExcludeNewerValueChange),
77    GlobalAdded(ExcludeNewerValue),
78    GlobalRemoved,
79    Package(ExcludeNewerPackageChange),
80}
81
82impl ExcludeNewerChange {
83    /// Whether the change is due to a change in a relative timestamp.
84    pub fn is_relative_timestamp_change(&self) -> bool {
85        match self {
86            Self::GlobalChanged(change) => change.is_relative_timestamp_change(),
87            Self::GlobalAdded(_) | Self::GlobalRemoved => false,
88            Self::Package(change) => change.is_relative_timestamp_change(),
89        }
90    }
91}
92
93impl std::fmt::Display for ExcludeNewerChange {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            Self::GlobalChanged(change) => {
97                write!(f, "{change}")
98            }
99            Self::GlobalAdded(value) => {
100                write!(f, "addition of global exclude newer {value}")
101            }
102            Self::GlobalRemoved => write!(f, "removal of global exclude newer"),
103            Self::Package(change) => {
104                write!(f, "{change}")
105            }
106        }
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum ExcludeNewerPackageChange {
112    PackageAdded(PackageName, ExcludeNewerOverride),
113    PackageRemoved(PackageName),
114    PackageChanged(PackageName, Box<ExcludeNewerOverrideChange>),
115}
116
117impl ExcludeNewerPackageChange {
118    fn is_relative_timestamp_change(&self) -> bool {
119        match self {
120            Self::PackageAdded(_, _) | Self::PackageRemoved(_) => false,
121            Self::PackageChanged(_, change) => change.is_relative_timestamp_change(),
122        }
123    }
124}
125
126impl std::fmt::Display for ExcludeNewerPackageChange {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::PackageAdded(name, ExcludeNewerOverride::Enabled(value)) => {
130                write!(
131                    f,
132                    "addition of exclude newer `{}` for package `{name}`",
133                    value.as_ref()
134                )
135            }
136            Self::PackageAdded(name, ExcludeNewerOverride::Disabled) => {
137                write!(
138                    f,
139                    "addition of exclude newer exclusion for package `{name}`"
140                )
141            }
142            Self::PackageRemoved(name) => {
143                write!(f, "removal of exclude newer for package `{name}`")
144            }
145            Self::PackageChanged(name, change) => write!(f, "{change} for package `{name}`"),
146        }
147    }
148}
149
150fn compare_exclude_newer_value(
151    this: &ExcludeNewerValue,
152    other: &ExcludeNewerValue,
153) -> Option<ExcludeNewerValueChange> {
154    match (this.span(), other.span()) {
155        (None, Some(span)) => Some(ExcludeNewerValueChange::SpanAdded(*span)),
156        (Some(_), None) => Some(ExcludeNewerValueChange::SpanRemoved),
157        (Some(self_span), Some(other_span)) if self_span != other_span => Some(
158            ExcludeNewerValueChange::SpanChanged(*self_span, *other_span),
159        ),
160        (Some(_), Some(span)) if this.timestamp() != other.timestamp() => {
161            Some(ExcludeNewerValueChange::RelativeTimestampChanged(
162                this.timestamp(),
163                other.timestamp(),
164                *span,
165            ))
166        }
167        (None, None) if other.timestamp() < this.timestamp() => Some(
168            ExcludeNewerValueChange::AbsoluteTimestampChanged(this.timestamp(), other.timestamp()),
169        ),
170        (Some(_), Some(_)) | (None, None) => None,
171    }
172}
173
174pub struct ExcludeNewerValueWithSpanRef<'a>(pub &'a ExcludeNewerValue);
175
176impl serde::Serialize for ExcludeNewerValueWithSpanRef<'_> {
177    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
178    where
179        S: serde::Serializer,
180    {
181        if let Some(span) = self.0.span() {
182            let mut map = serializer.serialize_map(Some(2))?;
183            map.serialize_entry("timestamp", &self.0.timestamp())?;
184            map.serialize_entry("span", span)?;
185            map.end()
186        } else {
187            self.0.timestamp().serialize(serializer)
188        }
189    }
190}
191
192/// A package-specific exclude-newer entry.
193#[derive(Debug, Clone, PartialEq, Eq)]
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195pub struct ExcludeNewerPackageEntry {
196    package: PackageName,
197    setting: ExcludeNewerOverride,
198}
199
200impl FromStr for ExcludeNewerPackageEntry {
201    type Err = String;
202
203    /// Parses a [`ExcludeNewerPackageEntry`] from a string in the format `PACKAGE=DATE` or `PACKAGE=false`.
204    fn from_str(s: &str) -> Result<Self, Self::Err> {
205        let Some((package, value)) = s.split_once('=') else {
206            return Err(format!(
207                "Invalid `exclude-newer-package` value `{s}`: expected format `PACKAGE=DATE` or `PACKAGE=false`"
208            ));
209        };
210
211        let package = PackageName::from_str(package).map_err(|err| {
212            format!("Invalid `exclude-newer-package` package name `{package}`: {err}")
213        })?;
214
215        let setting = if value == "false" {
216            ExcludeNewerOverride::Disabled
217        } else {
218            ExcludeNewerOverride::Enabled(Box::new(ExcludeNewerValue::from_str(value).map_err(
219                |err| format!("Invalid `exclude-newer-package` value `{value}`: {err}"),
220            )?))
221        };
222
223        Ok(Self { package, setting })
224    }
225}
226
227impl From<(PackageName, ExcludeNewerOverride)> for ExcludeNewerPackageEntry {
228    fn from((package, setting): (PackageName, ExcludeNewerOverride)) -> Self {
229        Self { package, setting }
230    }
231}
232
233impl From<(PackageName, ExcludeNewerValue)> for ExcludeNewerPackageEntry {
234    fn from((package, timestamp): (PackageName, ExcludeNewerValue)) -> Self {
235        Self {
236            package,
237            setting: ExcludeNewerOverride::Enabled(Box::new(timestamp)),
238        }
239    }
240}
241
242pub fn serialize_exclude_newer_package_with_spans<S>(
243    value: &Option<ExcludeNewerPackage>,
244    serializer: S,
245) -> Result<S::Ok, S::Error>
246where
247    S: serde::Serializer,
248{
249    let Some(value) = value else {
250        return serializer.serialize_none();
251    };
252
253    let mut map = serializer.serialize_map(Some(value.len()))?;
254    for (name, setting) in value {
255        match setting {
256            ExcludeNewerOverride::Disabled => map.serialize_entry(name, &false)?,
257            ExcludeNewerOverride::Enabled(value) => {
258                map.serialize_entry(name, &ExcludeNewerValueWithSpanRef(value.as_ref()))?;
259            }
260        }
261    }
262    map.end()
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum ExcludeNewerOverrideChange {
267    Enabled { now: ExcludeNewerValue },
268    TimestampChanged(ExcludeNewerValueChange),
269}
270
271impl ExcludeNewerOverrideChange {
272    fn is_relative_timestamp_change(&self) -> bool {
273        match self {
274            Self::Enabled { .. } => false,
275            Self::TimestampChanged(change) => change.is_relative_timestamp_change(),
276        }
277    }
278}
279
280impl std::fmt::Display for ExcludeNewerOverrideChange {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        match self {
283            Self::Enabled { now } => {
284                write!(f, "remove exclude newer exclusion (now `{now}`)")
285            }
286            Self::TimestampChanged(change) => write!(f, "{change}"),
287        }
288    }
289}
290
291/// Package-specific `exclude-newer` settings.
292///
293/// Entries are stored in package-name order for deterministic serialization.
294#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
295#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
296pub struct ExcludeNewerPackage(BTreeMap<PackageName, ExcludeNewerOverride>);
297
298impl Deref for ExcludeNewerPackage {
299    type Target = BTreeMap<PackageName, ExcludeNewerOverride>;
300
301    fn deref(&self) -> &Self::Target {
302        &self.0
303    }
304}
305
306impl DerefMut for ExcludeNewerPackage {
307    fn deref_mut(&mut self) -> &mut Self::Target {
308        &mut self.0
309    }
310}
311
312impl FromIterator<ExcludeNewerPackageEntry> for ExcludeNewerPackage {
313    fn from_iter<T: IntoIterator<Item = ExcludeNewerPackageEntry>>(iter: T) -> Self {
314        Self(
315            iter.into_iter()
316                .map(|entry| (entry.package, entry.setting))
317                .collect(),
318        )
319    }
320}
321
322impl IntoIterator for ExcludeNewerPackage {
323    type Item = (PackageName, ExcludeNewerOverride);
324    type IntoIter = std::collections::btree_map::IntoIter<PackageName, ExcludeNewerOverride>;
325
326    fn into_iter(self) -> Self::IntoIter {
327        self.0.into_iter()
328    }
329}
330
331impl<'a> IntoIterator for &'a ExcludeNewerPackage {
332    type Item = (&'a PackageName, &'a ExcludeNewerOverride);
333    type IntoIter = std::collections::btree_map::Iter<'a, PackageName, ExcludeNewerOverride>;
334
335    fn into_iter(self) -> Self::IntoIter {
336        self.0.iter()
337    }
338}
339
340impl ExcludeNewerPackage {
341    /// Returns true if this map is empty (no package-specific settings).
342    pub fn is_empty(&self) -> bool {
343        self.0.is_empty()
344    }
345
346    fn compare(&self, other: &Self) -> Option<ExcludeNewerPackageChange> {
347        for (package, setting) in self {
348            match (setting, other.get(package)) {
349                (
350                    ExcludeNewerOverride::Enabled(self_timestamp),
351                    Some(ExcludeNewerOverride::Enabled(other_timestamp)),
352                ) => {
353                    if let Some(change) =
354                        compare_exclude_newer_value(self_timestamp, other_timestamp)
355                    {
356                        return Some(ExcludeNewerPackageChange::PackageChanged(
357                            package.clone(),
358                            Box::new(ExcludeNewerOverrideChange::TimestampChanged(change)),
359                        ));
360                    }
361                }
362                (ExcludeNewerOverride::Enabled(_), Some(ExcludeNewerOverride::Disabled)) => {}
363                (
364                    ExcludeNewerOverride::Disabled,
365                    Some(ExcludeNewerOverride::Enabled(other_timestamp)),
366                ) => {
367                    return Some(ExcludeNewerPackageChange::PackageChanged(
368                        package.clone(),
369                        Box::new(ExcludeNewerOverrideChange::Enabled {
370                            now: other_timestamp.as_ref().clone(),
371                        }),
372                    ));
373                }
374                (ExcludeNewerOverride::Disabled, Some(ExcludeNewerOverride::Disabled)) => {}
375                (_, None) => {
376                    return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone()));
377                }
378            }
379        }
380
381        for (package, value) in other {
382            if !self.contains_key(package)
383                && let ExcludeNewerOverride::Enabled(_) = value
384            {
385                return Some(ExcludeNewerPackageChange::PackageAdded(
386                    package.clone(),
387                    value.clone(),
388                ));
389            }
390        }
391
392        None
393    }
394}
395
396/// A setting that excludes files newer than a timestamp, at a global level or per-package.
397#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
398#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
399pub struct ExcludeNewer {
400    /// Global timestamp that applies to all packages if no package-specific timestamp is set.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub global: Option<ExcludeNewerValue>,
403    /// Per-package timestamps that override the global timestamp.
404    #[serde(default, skip_serializing_if = "ExcludeNewerPackage::is_empty")]
405    pub package: ExcludeNewerPackage,
406}
407
408impl ExcludeNewer {
409    /// Create a new exclude newer configuration with just a global timestamp.
410    pub fn global(global: ExcludeNewerValue) -> Self {
411        Self {
412            global: Some(global),
413            package: ExcludeNewerPackage::default(),
414        }
415    }
416
417    fn warn_index_exclude_newer_preview() {
418        if !uv_preview::is_enabled(PreviewFeature::IndexExcludeNewer) {
419            warn_user_once!(
420                "Setting `exclude-newer` on configured indexes is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.",
421                PreviewFeature::IndexExcludeNewer
422            );
423        }
424    }
425
426    /// Create from CLI arguments.
427    pub fn from_args(
428        global: Option<ExcludeNewerOverride>,
429        package: Vec<ExcludeNewerPackageEntry>,
430    ) -> Self {
431        let global = global.and_then(ExcludeNewerOverride::into_value);
432        let package: ExcludeNewerPackage = package.into_iter().collect();
433
434        Self { global, package }
435    }
436
437    /// Returns the effective exclude-newer timestamp for a specific package, falling back to the
438    /// global value if no package-specific setting exists.
439    pub(crate) fn exclude_newer_package(&self, package_name: &PackageName) -> Option<Timestamp> {
440        match self.package.get(package_name) {
441            Some(ExcludeNewerOverride::Enabled(value)) => Some(value.timestamp()),
442            Some(ExcludeNewerOverride::Disabled) => None,
443            None => self.global.as_ref().map(ExcludeNewerValue::timestamp),
444        }
445    }
446
447    /// Returns the effective exclude-newer timestamp for a package resolved from a specific index.
448    pub fn exclude_newer_package_for_index(
449        &self,
450        package_name: &PackageName,
451        index: Option<&ExcludeNewerOverride>,
452    ) -> Option<Timestamp> {
453        self.exclude_newer_package_for_index_with_source(package_name, index)
454            .map(|(timestamp, _)| timestamp)
455    }
456
457    /// Returns the effective exclude-newer timestamp and its source for a package resolved from a
458    /// specific index.
459    pub(crate) fn exclude_newer_package_for_index_with_source(
460        &self,
461        package_name: &PackageName,
462        index: Option<&ExcludeNewerOverride>,
463    ) -> Option<(Timestamp, EffectiveExcludeNewerSource)> {
464        match self.package.get(package_name) {
465            Some(ExcludeNewerOverride::Enabled(value)) => {
466                Some((value.timestamp(), EffectiveExcludeNewerSource::Package))
467            }
468            Some(ExcludeNewerOverride::Disabled) => None,
469            None => match index {
470                Some(ExcludeNewerOverride::Disabled) => {
471                    Self::warn_index_exclude_newer_preview();
472                    None
473                }
474                Some(ExcludeNewerOverride::Enabled(value)) => Some((
475                    {
476                        Self::warn_index_exclude_newer_preview();
477                        value.timestamp()
478                    },
479                    EffectiveExcludeNewerSource::Index,
480                )),
481                None => self
482                    .global
483                    .as_ref()
484                    .map(|value| (value.timestamp(), EffectiveExcludeNewerSource::Global)),
485            },
486        }
487    }
488
489    /// Returns true if this has any configuration (global or per-package).
490    pub(crate) fn is_empty(&self) -> bool {
491        self.global.is_none() && self.package.is_empty()
492    }
493
494    /// Filter package-specific settings to packages in the resolution.
495    #[must_use]
496    pub fn filter_packages<'a>(self, packages: impl IntoIterator<Item = &'a PackageName>) -> Self {
497        let packages = packages.into_iter().collect::<FxHashSet<_>>();
498        Self {
499            global: self.global,
500            package: ExcludeNewerPackage(
501                self.package
502                    .into_iter()
503                    .filter(|(package, _)| packages.contains(package))
504                    .collect(),
505            ),
506        }
507    }
508
509    /// Compare against current configuration when deciding whether a lockfile may be reused.
510    ///
511    /// A later absolute timestamp permits every artifact in an existing lockfile, so it does not
512    /// invalidate that lockfile. It will be recorded when another change triggers a resolution.
513    pub fn compare(&self, other: &Self) -> Option<ExcludeNewerChange> {
514        match (&self.global, &other.global) {
515            (Some(self_global), Some(other_global)) => {
516                if let Some(change) = compare_exclude_newer_value(self_global, other_global) {
517                    if !change.is_relative_timestamp_change() {
518                        return Some(ExcludeNewerChange::GlobalChanged(change));
519                    }
520                }
521            }
522            (None, Some(global)) => {
523                return Some(ExcludeNewerChange::GlobalAdded(global.clone()));
524            }
525            (Some(_), None) => return Some(ExcludeNewerChange::GlobalRemoved),
526            (None, None) => (),
527        }
528        self.package
529            .compare(&other.package)
530            .map(ExcludeNewerChange::Package)
531    }
532}
533
534impl std::fmt::Display for ExcludeNewer {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        if let Some(global) = &self.global {
537            write!(f, "global: {global}")?;
538            if !self.package.is_empty() {
539                write!(f, ", ")?;
540            }
541        }
542        let mut first = true;
543        for (name, setting) in &self.package {
544            if !first {
545                write!(f, ", ")?;
546            }
547            match setting {
548                ExcludeNewerOverride::Enabled(timestamp) => {
549                    write!(f, "{name}: {}", timestamp.as_ref())?;
550                }
551                ExcludeNewerOverride::Disabled => {
552                    write!(f, "{name}: disabled")?;
553                }
554            }
555            first = false;
556        }
557        Ok(())
558    }
559}