Skip to main content

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 serde::Deserialize;
10use serde::de::value::MapAccessDeserializer;
11use uv_normalize::PackageName;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ExcludeNewerValueChange {
15    /// A relative span changed to a new value
16    SpanChanged(ExcludeNewerSpan, ExcludeNewerSpan),
17    /// A relative span was added
18    SpanAdded(ExcludeNewerSpan),
19    /// A relative span was removed
20    SpanRemoved,
21    /// A relative span is present and the timestamp changed
22    RelativeTimestampChanged(Timestamp, Timestamp, ExcludeNewerSpan),
23    /// The timestamp changed and a relative span is not present
24    AbsoluteTimestampChanged(Timestamp, Timestamp),
25}
26
27impl ExcludeNewerValueChange {
28    pub fn is_relative_timestamp_change(&self) -> bool {
29        matches!(self, Self::RelativeTimestampChanged(_, _, _))
30    }
31}
32
33impl std::fmt::Display for ExcludeNewerValueChange {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::SpanChanged(old, new) => {
37                write!(f, "change of exclude newer span from `{old}` to `{new}`")
38            }
39            Self::SpanAdded(span) => {
40                write!(f, "addition of exclude newer span `{span}`")
41            }
42            Self::SpanRemoved => {
43                write!(f, "removal of exclude newer span")
44            }
45            Self::RelativeTimestampChanged(old, new, span) => {
46                write!(
47                    f,
48                    "change of calculated ({span}) exclude newer timestamp from `{old}` to `{new}`"
49                )
50            }
51            Self::AbsoluteTimestampChanged(old, new) => {
52                write!(
53                    f,
54                    "change of exclude newer timestamp from `{old}` to `{new}`"
55                )
56            }
57        }
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum ExcludeNewerChange {
63    GlobalChanged(ExcludeNewerValueChange),
64    GlobalAdded(ExcludeNewerValue),
65    GlobalRemoved,
66    Package(ExcludeNewerPackageChange),
67}
68
69impl ExcludeNewerChange {
70    /// Whether the change is due to a change in a relative timestamp.
71    pub fn is_relative_timestamp_change(&self) -> bool {
72        match self {
73            Self::GlobalChanged(change) => change.is_relative_timestamp_change(),
74            Self::GlobalAdded(_) | Self::GlobalRemoved => false,
75            Self::Package(change) => change.is_relative_timestamp_change(),
76        }
77    }
78}
79
80impl std::fmt::Display for ExcludeNewerChange {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            Self::GlobalChanged(change) => {
84                write!(f, "{change}")
85            }
86            Self::GlobalAdded(value) => {
87                write!(f, "addition of global exclude newer {value}")
88            }
89            Self::GlobalRemoved => write!(f, "removal of global exclude newer"),
90            Self::Package(change) => {
91                write!(f, "{change}")
92            }
93        }
94    }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ExcludeNewerPackageChange {
99    PackageAdded(PackageName, PackageExcludeNewer),
100    PackageRemoved(PackageName),
101    PackageChanged(PackageName, Box<PackageExcludeNewerChange>),
102}
103
104impl ExcludeNewerPackageChange {
105    pub fn is_relative_timestamp_change(&self) -> bool {
106        match self {
107            Self::PackageAdded(_, _) | Self::PackageRemoved(_) => false,
108            Self::PackageChanged(_, change) => change.is_relative_timestamp_change(),
109        }
110    }
111}
112
113impl std::fmt::Display for ExcludeNewerPackageChange {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            Self::PackageAdded(name, PackageExcludeNewer::Enabled(value)) => {
117                write!(
118                    f,
119                    "addition of exclude newer `{}` for package `{name}`",
120                    value.as_ref()
121                )
122            }
123            Self::PackageAdded(name, PackageExcludeNewer::Disabled) => {
124                write!(
125                    f,
126                    "addition of exclude newer exclusion for package `{name}`"
127                )
128            }
129            Self::PackageRemoved(name) => {
130                write!(f, "removal of exclude newer for package `{name}`")
131            }
132            Self::PackageChanged(name, change) => write!(f, "{change} for package `{name}`"),
133        }
134    }
135}
136/// A timestamp that excludes files newer than it.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ExcludeNewerValue {
139    /// The resolved timestamp.
140    timestamp: Timestamp,
141    /// The span used to derive the [`Timestamp`], if any.
142    span: Option<ExcludeNewerSpan>,
143}
144
145impl ExcludeNewerValue {
146    pub fn into_parts(self) -> (Timestamp, Option<ExcludeNewerSpan>) {
147        (self.timestamp, self.span)
148    }
149
150    pub fn compare(&self, other: &Self) -> Option<ExcludeNewerValueChange> {
151        match (&self.span, &other.span) {
152            (None, Some(span)) => Some(ExcludeNewerValueChange::SpanAdded(*span)),
153            (Some(_), None) => Some(ExcludeNewerValueChange::SpanRemoved),
154            (Some(self_span), Some(other_span)) if self_span != other_span => Some(
155                ExcludeNewerValueChange::SpanChanged(*self_span, *other_span),
156            ),
157            (Some(_), Some(span)) if self.timestamp != other.timestamp => {
158                Some(ExcludeNewerValueChange::RelativeTimestampChanged(
159                    self.timestamp,
160                    other.timestamp,
161                    *span,
162                ))
163            }
164            (None, None) if self.timestamp != other.timestamp => Some(
165                ExcludeNewerValueChange::AbsoluteTimestampChanged(self.timestamp, other.timestamp),
166            ),
167            (Some(_), Some(_)) | (None, None) => None,
168        }
169    }
170}
171
172#[derive(Debug, Copy, Clone)]
173pub struct ExcludeNewerSpan(Span);
174
175impl std::fmt::Display for ExcludeNewerSpan {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        self.0.fmt(f)
178    }
179}
180
181impl PartialEq for ExcludeNewerSpan {
182    fn eq(&self, other: &Self) -> bool {
183        self.0.fieldwise() == other.0.fieldwise()
184    }
185}
186
187impl Eq for ExcludeNewerSpan {}
188
189impl serde::Serialize for ExcludeNewerSpan {
190    /// Serialize to an ISO 8601 duration string.
191    ///
192    /// We use ISO 8601 format for serialization (rather than the "friendly" format).
193    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
194    where
195        S: serde::Serializer,
196    {
197        serializer.serialize_str(&self.0.to_string())
198    }
199}
200
201impl<'de> serde::Deserialize<'de> for ExcludeNewerSpan {
202    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
203    where
204        D: serde::Deserializer<'de>,
205    {
206        let s = <Cow<'_, str>>::deserialize(deserializer)?;
207        let span: Span = s.parse().map_err(serde::de::Error::custom)?;
208        Ok(Self(span))
209    }
210}
211
212impl serde::Serialize for ExcludeNewerValue {
213    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
214    where
215        S: serde::Serializer,
216    {
217        self.timestamp.serialize(serializer)
218    }
219}
220
221impl<'de> serde::Deserialize<'de> for ExcludeNewerValue {
222    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
223    where
224        D: serde::Deserializer<'de>,
225    {
226        // Support both a simple string ("2024-03-11T00:00:00Z") and a table
227        // ({ timestamp = "2024-03-11T00:00:00Z", span = "P2W" })
228        #[derive(serde::Deserialize)]
229        struct TableForm {
230            timestamp: Timestamp,
231            span: Option<ExcludeNewerSpan>,
232        }
233
234        #[derive(serde::Deserialize)]
235        #[serde(untagged)]
236        enum Helper {
237            String(String),
238            Table(Box<TableForm>),
239        }
240
241        match Helper::deserialize(deserializer)? {
242            Helper::String(s) => Self::from_str(&s).map_err(serde::de::Error::custom),
243            Helper::Table(table) => Ok(Self::new(table.timestamp, table.span)),
244        }
245    }
246}
247
248impl ExcludeNewerValue {
249    /// Return the [`Timestamp`] in milliseconds.
250    pub fn timestamp_millis(&self) -> i64 {
251        self.timestamp.as_millisecond()
252    }
253
254    /// Return the [`Timestamp`].
255    pub fn timestamp(&self) -> Timestamp {
256        self.timestamp
257    }
258
259    /// Return the [`ExcludeNewerSpan`] used to construct the [`Timestamp`], if any.
260    pub fn span(&self) -> Option<&ExcludeNewerSpan> {
261        self.span.as_ref()
262    }
263
264    /// Create a new [`ExcludeNewerValue`].
265    pub fn new(timestamp: Timestamp, span: Option<ExcludeNewerSpan>) -> Self {
266        Self { timestamp, span }
267    }
268}
269
270impl From<Timestamp> for ExcludeNewerValue {
271    fn from(timestamp: Timestamp) -> Self {
272        Self {
273            timestamp,
274            span: None,
275        }
276    }
277}
278
279/// Determine what format the user likely intended and return an appropriate error message.
280fn format_exclude_newer_error(
281    input: &str,
282    date_err: &jiff::Error,
283    span_err: &jiff::Error,
284) -> String {
285    let trimmed = input.trim();
286
287    // Check for ISO 8601 duration (`[-+]?[Pp]`), e.g., "P2W", "+P1D", "-P30D"
288    let after_sign = trimmed.trim_start_matches(['+', '-']);
289    if after_sign.starts_with('P') || after_sign.starts_with('p') {
290        return format!("`{input}` could not be parsed as an ISO 8601 duration: {span_err}");
291    }
292
293    // Check for friendly duration (`[-+]?\s*[0-9]+\s*[A-Za-z]`), e.g., "2 weeks", "-30 days",
294    // "1hour"
295    let after_sign_trimmed = after_sign.trim_start();
296    let mut chars = after_sign_trimmed.chars().peekable();
297
298    // Check if we start with a digit
299    if chars.peek().is_some_and(char::is_ascii_digit) {
300        // Skip digits
301        while chars.peek().is_some_and(char::is_ascii_digit) {
302            chars.next();
303        }
304        // Skip optional whitespace
305        while chars.peek().is_some_and(|c| c.is_whitespace()) {
306            chars.next();
307        }
308        // Check if next character is a letter (unit designator)
309        if chars.peek().is_some_and(char::is_ascii_alphabetic) {
310            return format!("`{input}` could not be parsed as a duration: {span_err}");
311        }
312    }
313
314    // Check for date/timestamp (`[-+]?[0-9]{4}-`), e.g., "2024-01-01", "2024-01-01T00:00:00Z"
315    let mut chars = after_sign.chars();
316    let looks_like_date = chars.next().is_some_and(|c| c.is_ascii_digit())
317        && chars.next().is_some_and(|c| c.is_ascii_digit())
318        && chars.next().is_some_and(|c| c.is_ascii_digit())
319        && chars.next().is_some_and(|c| c.is_ascii_digit())
320        && chars.next().is_some_and(|c| c == '-');
321
322    if looks_like_date {
323        return format!("`{input}` could not be parsed as a valid date: {date_err}");
324    }
325
326    // If we can't tell, return a generic error message
327    format!(
328        "`{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`)"
329    )
330}
331
332impl FromStr for ExcludeNewerValue {
333    type Err = String;
334
335    /// Parse an [`ExcludeNewerValue`] from a string.
336    ///
337    /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format
338    /// (e.g., `2006-12-02`), "friendly" durations (e.g., `1 week`, `30 days`), and ISO 8601
339    /// durations (e.g., `PT24H`, `P7D`, `P30D`).
340    fn from_str(input: &str) -> Result<Self, Self::Err> {
341        // Try parsing as a timestamp first
342        if let Ok(timestamp) = input.parse::<Timestamp>() {
343            return Ok(Self::new(timestamp, None));
344        }
345
346        // Try parsing as a date
347        // In Jiff, if an RFC 3339 timestamp could be parsed, then it must necessarily be the case
348        // that a date can also be parsed. So we can collapse the error cases here. That is, if we
349        // fail to parse a timestamp and a date, then it should be sufficient to just report the
350        // error from parsing the date. If someone tried to write a timestamp but committed an error
351        // in the non-date portion, the date parsing below will still report a holistic error that
352        // will make sense to the user. (I added a snapshot test for that case.)
353        let date_err = match input.parse::<jiff::civil::Date>() {
354            Ok(date) => {
355                let timestamp = date
356                    .checked_add(1.day())
357                    .and_then(|date| date.to_zoned(TimeZone::system()))
358                    .map(|zdt| zdt.timestamp())
359                    .map_err(|err| {
360                        format!(
361                            "`{input}` parsed to date `{date}`, but could not \
362                         be converted to a timestamp: {err}",
363                        )
364                    })?;
365                return Ok(Self::new(timestamp, None));
366            }
367            Err(err) => err,
368        };
369
370        // Try parsing as a span
371        let span_err = match input.parse::<Span>() {
372            Ok(span) => {
373                // Allow overriding the current time in tests for deterministic snapshots
374                let now = if let Ok(test_time) = std::env::var("UV_TEST_CURRENT_TIMESTAMP") {
375                    test_time
376                        .parse::<Timestamp>()
377                        .expect("UV_TEST_CURRENT_TIMESTAMP must be a valid RFC 3339 timestamp")
378                        .to_zoned(TimeZone::UTC)
379                } else {
380                    Timestamp::now().to_zoned(TimeZone::UTC)
381                };
382
383                // We do not allow years and months as units, as the amount of time they represent
384                // is not fixed and can differ depending on the local time zone. We could allow this
385                // via the CLI in the future, but shouldn't allow it via persistent configuration.
386                if span.get_years() != 0 {
387                    let years = span
388                        .total((Unit::Year, &now))
389                        .map(f64::ceil)
390                        .unwrap_or(1.0)
391                        .abs();
392                    let days = years * 365.0;
393                    return Err(format!(
394                        "Duration `{input}` uses unit 'years' which is not allowed; use days instead, e.g., `{days:.0} days`.",
395                    ));
396                }
397                if span.get_months() != 0 {
398                    let months = span
399                        .total((Unit::Month, &now))
400                        .map(f64::ceil)
401                        .unwrap_or(1.0)
402                        .abs();
403                    let days = months * 30.0;
404                    return Err(format!(
405                        "Duration `{input}` uses 'months' which is not allowed; use days instead, e.g., `{days:.0} days`."
406                    ));
407                }
408
409                // We're using a UTC timezone so there are no transitions (e.g., DST) and days are
410                // always 24 hours. This means that we can also allow weeks as a unit.
411                //
412                // Note we use `span.abs()` so `1 day ago` has the same effect as `1 day` instead
413                // of resulting in a future date.
414                let cutoff = now.checked_sub(span.abs()).map_err(|err| {
415                    format!("Duration `{input}` is too large to subtract from current time: {err}")
416                })?;
417
418                return Ok(Self::new(cutoff.into(), Some(ExcludeNewerSpan(span))));
419            }
420            Err(err) => err,
421        };
422
423        // Return a targeted error message based on heuristics about what the user likely intended
424        Err(format_exclude_newer_error(input, &date_err, &span_err))
425    }
426}
427
428impl std::fmt::Display for ExcludeNewerValue {
429    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430        self.timestamp.fmt(f)
431    }
432}
433
434/// Per-package exclude-newer setting.
435///
436/// This enum represents whether exclude-newer should be disabled for a package,
437/// or if a specific cutoff (absolute or relative) should be used.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum PackageExcludeNewer {
440    /// Disable exclude-newer for this package (allow all versions regardless of upload date).
441    Disabled,
442    /// Enable exclude-newer with this cutoff for this package.
443    Enabled(Box<ExcludeNewerValue>),
444}
445
446#[cfg(feature = "schemars")]
447impl schemars::JsonSchema for PackageExcludeNewer {
448    fn schema_name() -> Cow<'static, str> {
449        Cow::Borrowed("PackageExcludeNewer")
450    }
451
452    fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
453        schemars::json_schema!({
454            "oneOf": [
455                {
456                    "type": "boolean",
457                    "const": false,
458                    "description": "Disable exclude-newer for this package."
459                },
460                generator.subschema_for::<ExcludeNewerValue>(),
461            ]
462        })
463    }
464}
465
466/// A package-specific exclude-newer entry.
467#[derive(Debug, Clone, PartialEq, Eq)]
468#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
469pub struct ExcludeNewerPackageEntry {
470    pub package: PackageName,
471    pub setting: PackageExcludeNewer,
472}
473
474impl FromStr for ExcludeNewerPackageEntry {
475    type Err = String;
476
477    /// Parses a [`ExcludeNewerPackageEntry`] from a string in the format `PACKAGE=DATE` or `PACKAGE=false`.
478    fn from_str(s: &str) -> Result<Self, Self::Err> {
479        let Some((package, value)) = s.split_once('=') else {
480            return Err(format!(
481                "Invalid `exclude-newer-package` value `{s}`: expected format `PACKAGE=DATE` or `PACKAGE=false`"
482            ));
483        };
484
485        let package = PackageName::from_str(package).map_err(|err| {
486            format!("Invalid `exclude-newer-package` package name `{package}`: {err}")
487        })?;
488
489        let setting = if value == "false" {
490            PackageExcludeNewer::Disabled
491        } else {
492            PackageExcludeNewer::Enabled(Box::new(ExcludeNewerValue::from_str(value).map_err(
493                |err| format!("Invalid `exclude-newer-package` value `{value}`: {err}"),
494            )?))
495        };
496
497        Ok(Self { package, setting })
498    }
499}
500
501impl From<(PackageName, PackageExcludeNewer)> for ExcludeNewerPackageEntry {
502    fn from((package, setting): (PackageName, PackageExcludeNewer)) -> Self {
503        Self { package, setting }
504    }
505}
506
507impl From<(PackageName, ExcludeNewerValue)> for ExcludeNewerPackageEntry {
508    fn from((package, timestamp): (PackageName, ExcludeNewerValue)) -> Self {
509        Self {
510            package,
511            setting: PackageExcludeNewer::Enabled(Box::new(timestamp)),
512        }
513    }
514}
515
516impl<'de> serde::Deserialize<'de> for PackageExcludeNewer {
517    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
518    where
519        D: serde::Deserializer<'de>,
520    {
521        struct Visitor;
522
523        impl<'de> serde::de::Visitor<'de> for Visitor {
524            type Value = PackageExcludeNewer;
525
526            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
527                formatter.write_str(
528                    "a date/timestamp/duration string, false to disable exclude-newer, or a table \
529                     with timestamp/span",
530                )
531            }
532
533            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
534            where
535                E: serde::de::Error,
536            {
537                ExcludeNewerValue::from_str(v)
538                    .map(|ts| PackageExcludeNewer::Enabled(Box::new(ts)))
539                    .map_err(|e| E::custom(format!("failed to parse exclude-newer value: {e}")))
540            }
541
542            fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
543            where
544                E: serde::de::Error,
545            {
546                if v {
547                    Err(E::custom(
548                        "expected false to disable exclude-newer, got true",
549                    ))
550                } else {
551                    Ok(PackageExcludeNewer::Disabled)
552                }
553            }
554
555            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
556            where
557                A: serde::de::MapAccess<'de>,
558            {
559                Ok(PackageExcludeNewer::Enabled(Box::new(
560                    ExcludeNewerValue::deserialize(MapAccessDeserializer::new(map))?,
561                )))
562            }
563        }
564
565        deserializer.deserialize_any(Visitor)
566    }
567}
568
569impl serde::Serialize for PackageExcludeNewer {
570    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
571    where
572        S: serde::Serializer,
573    {
574        match self {
575            Self::Enabled(timestamp) => timestamp.to_string().serialize(serializer),
576            Self::Disabled => serializer.serialize_bool(false),
577        }
578    }
579}
580
581#[derive(Debug, Clone, PartialEq, Eq)]
582pub enum PackageExcludeNewerChange {
583    Disabled { was: ExcludeNewerValue },
584    Enabled { now: ExcludeNewerValue },
585    TimestampChanged(ExcludeNewerValueChange),
586}
587
588impl PackageExcludeNewerChange {
589    pub fn is_relative_timestamp_change(&self) -> bool {
590        match self {
591            Self::Disabled { .. } | Self::Enabled { .. } => false,
592            Self::TimestampChanged(change) => change.is_relative_timestamp_change(),
593        }
594    }
595}
596
597impl std::fmt::Display for PackageExcludeNewerChange {
598    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599        match self {
600            Self::Disabled { was } => {
601                write!(f, "add exclude newer exclusion (was `{was}`)")
602            }
603            Self::Enabled { now } => {
604                write!(f, "remove exclude newer exclusion (now `{now}`)")
605            }
606            Self::TimestampChanged(change) => write!(f, "{change}"),
607        }
608    }
609}
610
611#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
612#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
613pub struct ExcludeNewerPackage(FxHashMap<PackageName, PackageExcludeNewer>);
614
615impl Deref for ExcludeNewerPackage {
616    type Target = FxHashMap<PackageName, PackageExcludeNewer>;
617
618    fn deref(&self) -> &Self::Target {
619        &self.0
620    }
621}
622
623impl DerefMut for ExcludeNewerPackage {
624    fn deref_mut(&mut self) -> &mut Self::Target {
625        &mut self.0
626    }
627}
628
629impl FromIterator<ExcludeNewerPackageEntry> for ExcludeNewerPackage {
630    fn from_iter<T: IntoIterator<Item = ExcludeNewerPackageEntry>>(iter: T) -> Self {
631        Self(
632            iter.into_iter()
633                .map(|entry| (entry.package, entry.setting))
634                .collect(),
635        )
636    }
637}
638
639impl IntoIterator for ExcludeNewerPackage {
640    type Item = (PackageName, PackageExcludeNewer);
641    type IntoIter = std::collections::hash_map::IntoIter<PackageName, PackageExcludeNewer>;
642
643    fn into_iter(self) -> Self::IntoIter {
644        self.0.into_iter()
645    }
646}
647
648impl<'a> IntoIterator for &'a ExcludeNewerPackage {
649    type Item = (&'a PackageName, &'a PackageExcludeNewer);
650    type IntoIter = std::collections::hash_map::Iter<'a, PackageName, PackageExcludeNewer>;
651
652    fn into_iter(self) -> Self::IntoIter {
653        self.0.iter()
654    }
655}
656
657impl ExcludeNewerPackage {
658    /// Convert to the inner `HashMap`.
659    pub fn into_inner(self) -> FxHashMap<PackageName, PackageExcludeNewer> {
660        self.0
661    }
662
663    /// Returns true if this map is empty (no package-specific settings).
664    pub fn is_empty(&self) -> bool {
665        self.0.is_empty()
666    }
667
668    pub fn compare(&self, other: &Self) -> Option<ExcludeNewerPackageChange> {
669        for (package, setting) in self {
670            match (setting, other.get(package)) {
671                (
672                    PackageExcludeNewer::Enabled(self_timestamp),
673                    Some(PackageExcludeNewer::Enabled(other_timestamp)),
674                ) => {
675                    if let Some(change) = self_timestamp.compare(other_timestamp) {
676                        return Some(ExcludeNewerPackageChange::PackageChanged(
677                            package.clone(),
678                            Box::new(PackageExcludeNewerChange::TimestampChanged(change)),
679                        ));
680                    }
681                }
682                (
683                    PackageExcludeNewer::Enabled(self_timestamp),
684                    Some(PackageExcludeNewer::Disabled),
685                ) => {
686                    return Some(ExcludeNewerPackageChange::PackageChanged(
687                        package.clone(),
688                        Box::new(PackageExcludeNewerChange::Disabled {
689                            was: self_timestamp.as_ref().clone(),
690                        }),
691                    ));
692                }
693                (
694                    PackageExcludeNewer::Disabled,
695                    Some(PackageExcludeNewer::Enabled(other_timestamp)),
696                ) => {
697                    return Some(ExcludeNewerPackageChange::PackageChanged(
698                        package.clone(),
699                        Box::new(PackageExcludeNewerChange::Enabled {
700                            now: other_timestamp.as_ref().clone(),
701                        }),
702                    ));
703                }
704                (PackageExcludeNewer::Disabled, Some(PackageExcludeNewer::Disabled)) => {}
705                (_, None) => {
706                    return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone()));
707                }
708            }
709        }
710
711        for (package, value) in other {
712            if !self.contains_key(package) {
713                return Some(ExcludeNewerPackageChange::PackageAdded(
714                    package.clone(),
715                    value.clone(),
716                ));
717            }
718        }
719
720        None
721    }
722}
723
724/// A setting that excludes files newer than a timestamp, at a global level or per-package.
725#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
726#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
727pub struct ExcludeNewer {
728    /// Global timestamp that applies to all packages if no package-specific timestamp is set.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub global: Option<ExcludeNewerValue>,
731    /// Per-package timestamps that override the global timestamp.
732    #[serde(default, skip_serializing_if = "FxHashMap::is_empty")]
733    pub package: ExcludeNewerPackage,
734}
735
736impl ExcludeNewer {
737    /// Create a new exclude newer configuration with just a global timestamp.
738    pub fn global(global: ExcludeNewerValue) -> Self {
739        Self {
740            global: Some(global),
741            package: ExcludeNewerPackage::default(),
742        }
743    }
744
745    /// Create a new exclude newer configuration.
746    pub fn new(global: Option<ExcludeNewerValue>, package: ExcludeNewerPackage) -> Self {
747        Self { global, package }
748    }
749
750    /// Create from CLI arguments.
751    pub fn from_args(
752        global: Option<ExcludeNewerValue>,
753        package: Vec<ExcludeNewerPackageEntry>,
754    ) -> Self {
755        let package: ExcludeNewerPackage = package.into_iter().collect();
756
757        Self { global, package }
758    }
759
760    /// Returns the exclude-newer value for a specific package, returning `Some(value)` if the
761    /// package has a package-specific setting or falls back to the global value if set, or `None`
762    /// if exclude-newer is explicitly disabled for the package (set to `false`) or if no
763    /// exclude-newer is configured.
764    pub fn exclude_newer_package(&self, package_name: &PackageName) -> Option<ExcludeNewerValue> {
765        match self.package.get(package_name) {
766            Some(PackageExcludeNewer::Enabled(timestamp)) => Some(timestamp.as_ref().clone()),
767            Some(PackageExcludeNewer::Disabled) => None,
768            None => self.global.clone(),
769        }
770    }
771
772    /// Returns true if this has any configuration (global or per-package).
773    pub fn is_empty(&self) -> bool {
774        self.global.is_none() && self.package.is_empty()
775    }
776
777    pub fn compare(&self, other: &Self) -> Option<ExcludeNewerChange> {
778        match (&self.global, &other.global) {
779            (Some(self_global), Some(other_global)) => {
780                if let Some(change) = self_global.compare(other_global) {
781                    return Some(ExcludeNewerChange::GlobalChanged(change));
782                }
783            }
784            (None, Some(global)) => {
785                return Some(ExcludeNewerChange::GlobalAdded(global.clone()));
786            }
787            (Some(_), None) => return Some(ExcludeNewerChange::GlobalRemoved),
788            (None, None) => (),
789        }
790        self.package
791            .compare(&other.package)
792            .map(ExcludeNewerChange::Package)
793    }
794}
795
796impl std::fmt::Display for ExcludeNewer {
797    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
798        if let Some(global) = &self.global {
799            write!(f, "global: {global}")?;
800            if !self.package.is_empty() {
801                write!(f, ", ")?;
802            }
803        }
804        let mut first = true;
805        for (name, setting) in &self.package {
806            if !first {
807                write!(f, ", ")?;
808            }
809            match setting {
810                PackageExcludeNewer::Enabled(timestamp) => {
811                    write!(f, "{name}: {}", timestamp.as_ref())?;
812                }
813                PackageExcludeNewer::Disabled => {
814                    write!(f, "{name}: disabled")?;
815                }
816            }
817            first = false;
818        }
819        Ok(())
820    }
821}
822
823#[cfg(feature = "schemars")]
824impl schemars::JsonSchema for ExcludeNewerValue {
825    fn schema_name() -> Cow<'static, str> {
826        Cow::Borrowed("ExcludeNewerValue")
827    }
828
829    fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
830        schemars::json_schema!({
831            "type": "string",
832            "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.",
833        })
834    }
835}