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}