uv_resolver/
exclude_newer.rs

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