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