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}