Skip to main content

deps_core/
freshness.rs

1//! Release-freshness signal: publish-time tracking and cooldown-window checks.
2//!
3//! Mirrors GitHub Dependabot's default 3-day package cooldown: a version
4//! published very recently is a distinct signal from one that has been live
5//! for a while, independent of whether it is otherwise "the latest". This
6//! module is deliberately minimal (a Unix-seconds newtype, a cooldown-window
7//! newtype, and the [`FreshnessSettings`] enum built from them) and stays
8//! confined to `deps-core` — ecosystem crates only ever produce a
9//! [`PublishTime`], never touch the `time` crate directly.
10
11use time::OffsetDateTime;
12use time::format_description::well_known::Rfc3339;
13
14/// Dependabot's default cooldown window (3 days), in seconds.
15pub const DEFAULT_COOLDOWN_SECS: u64 = 3 * 24 * 60 * 60;
16
17/// A release publish instant, normalized to Unix epoch seconds (UTC).
18///
19/// `Copy` and cheap to pass around, so it fits into `Box<dyn Version>`
20/// trait objects without lifetime or allocation concerns.
21///
22/// # Examples
23///
24/// ```
25/// use deps_core::PublishTime;
26///
27/// let published = PublishTime::parse_rfc3339("2026-07-18T23:05:13Z").unwrap();
28/// let now = PublishTime::from_unix_secs(published.as_unix_secs() + 3600);
29///
30/// assert_eq!(published.age_secs_from(now), 3600);
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub struct PublishTime(i64);
34
35impl PublishTime {
36    /// Returns the current time as a [`PublishTime`].
37    ///
38    /// Uses [`std::time::SystemTime`] rather than the `time` crate's own
39    /// "now" APIs, so this module never needs the `local-offset`/
40    /// `wasm-bindgen` features. A clock before the Unix epoch saturates to 0.
41    ///
42    /// # Examples
43    ///
44    /// ```
45    /// use deps_core::PublishTime;
46    ///
47    /// let now = PublishTime::now();
48    /// assert!(now.as_unix_secs() > 0);
49    /// ```
50    #[must_use]
51    pub fn now() -> Self {
52        use std::time::{SystemTime, UNIX_EPOCH};
53
54        let secs = SystemTime::now()
55            .duration_since(UNIX_EPOCH)
56            .map_or(0, |d| d.as_secs().cast_signed());
57        Self(secs)
58    }
59
60    /// Builds a [`PublishTime`] directly from Unix epoch seconds.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use deps_core::PublishTime;
66    ///
67    /// let t = PublishTime::from_unix_secs(1_753_052_713);
68    /// assert_eq!(t.as_unix_secs(), 1_753_052_713);
69    /// ```
70    #[must_use]
71    pub const fn from_unix_secs(secs: i64) -> Self {
72        Self(secs)
73    }
74
75    /// Parses an RFC 3339 timestamp string into a [`PublishTime`].
76    ///
77    /// Accepts every shape seen across v1 registries: a bare `Z` suffix,
78    /// arbitrary fractional-second digits, and a numeric UTC offset
79    /// (`+00:00`). Returns `None` on any parse failure — per [US-003], a
80    /// missing or malformed timestamp degrades to pre-feature behavior
81    /// rather than surfacing an error.
82    ///
83    /// [US-003]: https://github.com/bug-ops/deps-lsp/issues/145
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use deps_core::PublishTime;
89    ///
90    /// assert!(PublishTime::parse_rfc3339("2026-07-18T23:05:13Z").is_some());
91    /// assert!(PublishTime::parse_rfc3339("2026-05-14T19:25:27.735762Z").is_some());
92    /// assert!(PublishTime::parse_rfc3339("2026-01-02T08:56:05+00:00").is_some());
93    /// assert!(PublishTime::parse_rfc3339("not a timestamp").is_none());
94    /// assert!(PublishTime::parse_rfc3339("").is_none());
95    /// ```
96    #[must_use]
97    pub fn parse_rfc3339(s: &str) -> Option<Self> {
98        OffsetDateTime::parse(s, &Rfc3339)
99            .ok()
100            .map(|dt| Self(dt.unix_timestamp()))
101    }
102
103    /// Returns this instant as Unix epoch seconds.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use deps_core::PublishTime;
109    ///
110    /// let t = PublishTime::from_unix_secs(42);
111    /// assert_eq!(t.as_unix_secs(), 42);
112    /// ```
113    #[must_use]
114    pub const fn as_unix_secs(self) -> i64 {
115        self.0
116    }
117
118    /// Age of this publish instant relative to `now`, in seconds.
119    ///
120    /// A `self` in the future relative to `now` (clock skew, or a registry
121    /// timestamp bug) saturates to age `0` rather than underflowing or
122    /// returning a negative duration — chosen because a slightly-ahead
123    /// registry clock is exactly the "just published" case the freshness
124    /// signal exists to surface, so clamping preserves the signal instead of
125    /// silently losing it.
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use deps_core::PublishTime;
131    ///
132    /// let published = PublishTime::from_unix_secs(1_000);
133    /// let now = PublishTime::from_unix_secs(1_100);
134    /// assert_eq!(published.age_secs_from(now), 100);
135    ///
136    /// // Clock skew: published "after" now clamps to 0, not a negative age.
137    /// let future = PublishTime::from_unix_secs(2_000);
138    /// assert_eq!(future.age_secs_from(now), 0);
139    /// ```
140    #[must_use]
141    pub const fn age_secs_from(self, now: Self) -> u64 {
142        // `saturating_sub` only guards i64 overflow; the diff can still be negative
143        // (a future `self`), so clamp to 0 before the `as u64` cast.
144        let diff = now.0.saturating_sub(self.0);
145        #[expect(
146            clippy::cast_sign_loss,
147            reason = "diff >= 0 guaranteed by the else branch check on the same line"
148        )]
149        if diff < 0 { 0 } else { diff as u64 }
150    }
151}
152
153/// A cooldown window, in seconds, below which a publish age counts as "recent".
154///
155/// A newtype rather than a bare `u64` so a cooldown-window value and a plain
156/// age-in-seconds value can never be transposed at a call site without a
157/// compile error — the bug class a raw `is_within_cooldown(age, cooldown)`
158/// free function invited (swapped argument order compiled silently).
159///
160/// # Examples
161///
162/// ```
163/// use deps_core::CooldownWindow;
164///
165/// let window = CooldownWindow::from_secs(200);
166/// assert!(window.contains(100));
167/// assert!(!window.contains(200));
168/// assert!(window.contains(199));
169/// assert_eq!(window.as_secs(), 200);
170/// ```
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
172pub struct CooldownWindow(u64);
173
174impl CooldownWindow {
175    /// Dependabot's default cooldown window (3 days).
176    pub const DEFAULT: Self = Self(DEFAULT_COOLDOWN_SECS);
177
178    /// Builds a cooldown window from a duration in seconds.
179    ///
180    /// `0` is a valid window: every publish age is then `>= 0`, so
181    /// [`Self::contains`] never fires and the freshness callout is simply
182    /// never shown, rather than being an error case.
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use deps_core::CooldownWindow;
188    ///
189    /// let window = CooldownWindow::from_secs(3600);
190    /// assert_eq!(window.as_secs(), 3600);
191    /// ```
192    #[must_use]
193    pub const fn from_secs(secs: u64) -> Self {
194        Self(secs)
195    }
196
197    /// Returns this window's length in seconds.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use deps_core::CooldownWindow;
203    ///
204    /// assert_eq!(CooldownWindow::from_secs(42).as_secs(), 42);
205    /// ```
206    #[must_use]
207    pub const fn as_secs(self) -> u64 {
208        self.0
209    }
210
211    /// Whether a publish age (in seconds) falls within this cooldown window.
212    ///
213    /// The bound is exclusive: `age_secs < self.as_secs()`. A version
214    /// published exactly this window's length ago is **not** within
215    /// cooldown; one published a second earlier is. This is the single rule
216    /// applied uniformly across hover, diagnostics, and completion — no
217    /// ecosystem overrides it.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use deps_core::CooldownWindow;
223    ///
224    /// let window = CooldownWindow::from_secs(200);
225    /// assert!(window.contains(100));
226    /// assert!(!window.contains(200));
227    /// assert!(window.contains(199));
228    /// ```
229    #[must_use]
230    pub const fn contains(self, age_secs: u64) -> bool {
231        age_secs < self.0
232    }
233}
234
235const MINUTE: u64 = 60;
236const HOUR: u64 = 60 * MINUTE;
237const DAY: u64 = 24 * HOUR;
238const WEEK: u64 = 7 * DAY;
239const MONTH: u64 = 30 * DAY;
240const YEAR: u64 = 365 * DAY;
241
242/// Pluralizes a unit label: `"1 minute ago"` vs `"5 minutes ago"`.
243fn format_unit_ago(count: u64, unit: &str) -> String {
244    if count == 1 {
245        format!("1 {unit} ago")
246    } else {
247        format!("{count} {unit}s ago")
248    }
249}
250
251/// Formats an age in seconds as a coarse, human-readable relative duration.
252///
253/// Buckets by the largest whole unit that fits: minutes, hours, days, weeks,
254/// months, years — pure duration bucketing on a `u64`, not calendar-aware
255/// date arithmetic (no month/year length variation), so it needs no
256/// timezone or leap-year handling.
257///
258/// # Examples
259///
260/// ```
261/// use deps_core::format_relative_age;
262///
263/// assert_eq!(format_relative_age(0), "just now");
264/// assert_eq!(format_relative_age(59), "just now");
265/// assert_eq!(format_relative_age(60), "1 minute ago");
266/// assert_eq!(format_relative_age(300), "5 minutes ago");
267/// assert_eq!(format_relative_age(3600), "1 hour ago");
268/// assert_eq!(format_relative_age(86_400), "1 day ago");
269/// assert_eq!(format_relative_age(604_800), "1 week ago");
270/// assert_eq!(format_relative_age(2_592_000), "1 month ago");
271/// assert_eq!(format_relative_age(31_536_000), "1 year ago");
272/// ```
273#[must_use]
274pub fn format_relative_age(age_secs: u64) -> String {
275    if age_secs < MINUTE {
276        "just now".to_string()
277    } else if age_secs < HOUR {
278        format_unit_ago(age_secs / MINUTE, "minute")
279    } else if age_secs < DAY {
280        format_unit_ago(age_secs / HOUR, "hour")
281    } else if age_secs < WEEK {
282        format_unit_ago(age_secs / DAY, "day")
283    } else if age_secs < MONTH {
284        format_unit_ago(age_secs / WEEK, "week")
285    } else if age_secs < YEAR {
286        format_unit_ago(age_secs / MONTH, "month")
287    } else {
288        format_unit_ago(age_secs / YEAR, "year")
289    }
290}
291
292/// LSP-facing freshness settings, threaded into hover/diagnostics/completion.
293///
294/// A `Copy` DTO so it can be snapshotted from `deps-lsp`'s
295/// `Arc<RwLock<DepsConfig>>` before an `.await` point (matching the existing
296/// `CacheConfig` snapshot-before-await pattern) and passed by value into
297/// `Ecosystem::generate_hover`/`generate_diagnostics`, without holding the
298/// config lock across the call.
299///
300/// # Examples
301///
302/// ```
303/// use deps_core::{CooldownWindow, FreshnessSettings};
304///
305/// let settings = FreshnessSettings::default();
306/// assert!(settings.is_enabled());
307/// assert_eq!(settings.cooldown(), Some(CooldownWindow::DEFAULT));
308/// ```
309// Exhaustive: built by literal construction in several ecosystem crates and
310// `deps-lsp`'s `const fn to_freshness` — `#[non_exhaustive]` would break that for no
311// benefit, and would let a new variant slip past an exhaustive `match` at compile
312// time, which is exactly the guarantee this enum exists to give (issue #755).
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum FreshnessSettings {
315    /// The freshness signal is not rendered at all.
316    Disabled,
317    /// The freshness signal is rendered, gated by `cooldown`.
318    Enabled {
319        /// Cooldown window below which a publish age is "recent".
320        cooldown: CooldownWindow,
321    },
322}
323
324impl Default for FreshnessSettings {
325    fn default() -> Self {
326        Self::Enabled {
327            cooldown: CooldownWindow::DEFAULT,
328        }
329    }
330}
331
332impl FreshnessSettings {
333    /// Returns the cooldown window, if the freshness signal is enabled.
334    ///
335    /// # Examples
336    ///
337    /// ```
338    /// use deps_core::{CooldownWindow, FreshnessSettings};
339    ///
340    /// assert_eq!(FreshnessSettings::Disabled.cooldown(), None);
341    /// assert_eq!(
342    ///     FreshnessSettings::Enabled { cooldown: CooldownWindow::from_secs(60) }.cooldown(),
343    ///     Some(CooldownWindow::from_secs(60))
344    /// );
345    /// ```
346    #[must_use]
347    pub const fn cooldown(self) -> Option<CooldownWindow> {
348        match self {
349            Self::Enabled { cooldown } => Some(cooldown),
350            Self::Disabled => None,
351        }
352    }
353
354    /// Whether the freshness signal is rendered at all.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use deps_core::FreshnessSettings;
360    ///
361    /// assert!(!FreshnessSettings::Disabled.is_enabled());
362    /// assert!(FreshnessSettings::default().is_enabled());
363    /// ```
364    #[must_use]
365    pub const fn is_enabled(self) -> bool {
366        matches!(self, Self::Enabled { .. })
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    // --- PublishTime::parse_rfc3339: one fixture per v1 ecosystem's wire format ---
375
376    #[test]
377    fn test_parse_rfc3339_cargo_bare_z() {
378        let t = PublishTime::parse_rfc3339("2026-07-18T23:05:13Z").unwrap();
379        assert_eq!(t.as_unix_secs(), 1_784_415_913);
380    }
381
382    #[test]
383    fn test_parse_rfc3339_pypi_six_digit_fraction() {
384        assert!(PublishTime::parse_rfc3339("2026-05-14T19:25:27.735762Z").is_some());
385    }
386
387    #[test]
388    fn test_parse_rfc3339_composer_numeric_offset() {
389        let a = PublishTime::parse_rfc3339("2026-01-02T08:56:05+00:00").unwrap();
390        let b = PublishTime::parse_rfc3339("2026-01-02T08:56:05Z").unwrap();
391        assert_eq!(a, b);
392    }
393
394    #[test]
395    fn test_parse_rfc3339_bundler_millisecond_fraction() {
396        assert!(PublishTime::parse_rfc3339("2024-01-15T10:30:00.000Z").is_some());
397    }
398
399    #[test]
400    fn test_parse_rfc3339_dart_fractional_form() {
401        assert!(PublishTime::parse_rfc3339("2025-03-10T14:22:05.123Z").is_some());
402    }
403
404    #[test]
405    fn test_parse_rfc3339_go_bare_z() {
406        assert!(PublishTime::parse_rfc3339("2026-02-01T00:00:00Z").is_some());
407    }
408
409    #[test]
410    fn test_parse_rfc3339_garbage_is_none() {
411        assert!(PublishTime::parse_rfc3339("not-a-timestamp").is_none());
412    }
413
414    #[test]
415    fn test_parse_rfc3339_empty_is_none() {
416        assert!(PublishTime::parse_rfc3339("").is_none());
417    }
418
419    // --- age_secs_from / clock skew ---
420
421    #[test]
422    fn test_age_secs_from_future_timestamp_clamps_to_zero() {
423        let now = PublishTime::from_unix_secs(1_000);
424        let published_in_future = PublishTime::from_unix_secs(1_500);
425        assert_eq!(published_in_future.age_secs_from(now), 0);
426    }
427
428    #[test]
429    fn test_age_secs_from_normal_case() {
430        let published = PublishTime::from_unix_secs(1_000);
431        let now = PublishTime::from_unix_secs(1_360);
432        assert_eq!(published.age_secs_from(now), 360);
433    }
434
435    #[test]
436    fn test_age_secs_from_extreme_values_do_not_panic() {
437        let published = PublishTime::from_unix_secs(i64::MIN);
438        let now = PublishTime::from_unix_secs(i64::MAX);
439        // Just must not overflow-panic; the exact saturated value is not load-bearing.
440        let _ = published.age_secs_from(now);
441
442        let published = PublishTime::from_unix_secs(i64::MAX);
443        let now = PublishTime::from_unix_secs(i64::MIN);
444        assert_eq!(published.age_secs_from(now), 0);
445    }
446
447    // --- CooldownWindow::contains boundary ---
448
449    #[test]
450    fn test_cooldown_window_contains_future_timestamp_counts_as_within() {
451        let now = PublishTime::from_unix_secs(1_000);
452        let published_in_future = PublishTime::from_unix_secs(1_500);
453        let age = published_in_future.age_secs_from(now);
454        assert!(CooldownWindow::DEFAULT.contains(age));
455    }
456
457    #[test]
458    fn test_cooldown_window_contains_at_boundary_is_false() {
459        assert!(!CooldownWindow::from_secs(200).contains(200));
460    }
461
462    #[test]
463    fn test_cooldown_window_contains_one_below_boundary_is_true() {
464        assert!(CooldownWindow::from_secs(200).contains(199));
465    }
466
467    // --- format_relative_age bucket boundaries ---
468
469    #[test]
470    fn test_format_relative_age_just_now() {
471        assert_eq!(format_relative_age(0), "just now");
472        assert_eq!(format_relative_age(59), "just now");
473    }
474
475    #[test]
476    fn test_format_relative_age_minutes() {
477        assert_eq!(format_relative_age(60), "1 minute ago");
478        assert_eq!(format_relative_age(119), "1 minute ago");
479        assert_eq!(format_relative_age(300), "5 minutes ago");
480        assert_eq!(format_relative_age(HOUR - 1), "59 minutes ago");
481    }
482
483    #[test]
484    fn test_format_relative_age_hours() {
485        assert_eq!(format_relative_age(HOUR), "1 hour ago");
486        assert_eq!(format_relative_age(2 * HOUR), "2 hours ago");
487        assert_eq!(format_relative_age(DAY - 1), "23 hours ago");
488    }
489
490    #[test]
491    fn test_format_relative_age_days() {
492        assert_eq!(format_relative_age(DAY), "1 day ago");
493        assert_eq!(format_relative_age(3 * DAY), "3 days ago");
494        assert_eq!(format_relative_age(WEEK - 1), "6 days ago");
495    }
496
497    #[test]
498    fn test_format_relative_age_weeks() {
499        assert_eq!(format_relative_age(WEEK), "1 week ago");
500        assert_eq!(format_relative_age(2 * WEEK), "2 weeks ago");
501        assert_eq!(format_relative_age(MONTH - 1), "4 weeks ago");
502    }
503
504    #[test]
505    fn test_format_relative_age_months() {
506        assert_eq!(format_relative_age(MONTH), "1 month ago");
507        assert_eq!(format_relative_age(5 * MONTH), "5 months ago");
508        assert_eq!(format_relative_age(YEAR - 1), "12 months ago");
509    }
510
511    #[test]
512    fn test_format_relative_age_years() {
513        assert_eq!(format_relative_age(YEAR), "1 year ago");
514        assert_eq!(format_relative_age(2 * YEAR), "2 years ago");
515    }
516
517    // --- FreshnessSettings ---
518
519    #[test]
520    fn test_freshness_settings_default() {
521        let settings = FreshnessSettings::default();
522        assert!(settings.is_enabled());
523        assert_eq!(settings.cooldown(), Some(CooldownWindow::DEFAULT));
524    }
525
526    #[test]
527    fn test_freshness_settings_disabled_has_no_cooldown() {
528        assert_eq!(FreshnessSettings::Disabled.cooldown(), None);
529        assert!(!FreshnessSettings::Disabled.is_enabled());
530    }
531}