Skip to main content

dsp_cli/update/
mod.rs

1//! Interactive update check (advise-only). See dsp-cli/ADR-0015
2//! (`docs/adr/0015-update-check-and-self-update.md`) and design plan
3//! 031-update-check in the dsp-incubator archive.
4//!
5//! On **prose-format + interactive-TTY** runs only (unless opted out via
6//! `DSP_NO_UPDATE_CHECK`), `dsp` checks the crates.io sparse index for a
7//! newer published version and, if one exists, prints a two-line advisory to
8//! stderr recommending `cargo install dsp-cli`. No binary self-replace, no
9//! shell-out to `cargo`, no change to stdout, the JSON envelope, or the exit
10//! code — every failure path is non-fatal and swallowed.
11//!
12//! [`maybe_notify`] is the single public entry point; `main.rs` calls it
13//! after handling the command result.
14
15pub mod cache;
16
17use std::io::Read;
18use std::time::Duration;
19
20use serde::Deserialize;
21
22/// The crates.io sparse-index path for `dsp-cli`. Fixed by the crate name; if
23/// the name ever changes, this path changes with it (see the 031 plan's
24/// "Sparse-index path stability" risk note).
25///
26/// `fetch_latest` takes its URL as an explicit parameter (rather than reading
27/// this const internally) so tests can point it at a mock server;
28/// `run_check_and_notify` is the real caller that passes this const in
29/// production.
30pub(crate) const SPARSE_INDEX_URL: &str = "https://index.crates.io/ds/p-/dsp-cli";
31
32/// Timeout for the update-check HTTP request. Kept short (~2s) since this
33/// fetch runs synchronously on every gated interactive run and must never
34/// noticeably delay the CLI.
35const HTTP_TIMEOUT: Duration = Duration::from_secs(2);
36
37/// At most one network fetch per this many hours; within the window the
38/// reminder still shows every interactive run, from the cached
39/// `latest_seen` (see dsp-cli/ADR-0015 "Transport, frequency, politeness").
40pub(crate) const CHECK_INTERVAL_HOURS: i64 = 24;
41
42/// Standalone opt-out env var (set to any non-empty value to disable the
43/// check entirely). Read directly via `std::env`, not through
44/// `Config::resolve` (server-only) — see dsp-cli/ADR-0015 "Opt-out".
45pub(crate) const OPT_OUT_ENV: &str = "DSP_NO_UPDATE_CHECK";
46
47/// Size cap on the response body read, for parity with `AuthCache`'s
48/// anti-slurp guard. A crates.io sparse-index entry is a few KiB in practice;
49/// this cap only protects against a misbehaving/malicious endpoint forcing
50/// unbounded allocation.
51const MAX_BODY_BYTES: u64 = 1 << 20;
52
53/// One line of the crates.io sparse index (newline-delimited JSON, one
54/// object per published version). Unknown fields (`cksum`, `deps`,
55/// `features`, …) are silently ignored — we only need `vers` and `yanked`.
56#[derive(Debug, Deserialize)]
57struct IndexEntry {
58    vers: String,
59    #[serde(default)]
60    yanked: bool,
61}
62
63/// Parses a crates.io sparse-index response body and returns the highest
64/// published **stable** (non-prerelease, non-yanked) version, if any.
65///
66/// The index's `vers` field has no `v` prefix (e.g. `"0.1.3"`, not
67/// `"v0.1.3"`); we do not strip one — see the test below.
68///
69/// Malformed lines, entries with an unparseable `vers`, yanked entries, and
70/// prerelease versions are all silently skipped rather than treated as
71/// errors — an empty/garbage body simply yields `None`.
72pub fn parse_latest_stable(body: &str) -> Option<semver::Version> {
73    body.lines()
74        .filter(|line| !line.trim().is_empty())
75        .filter_map(|line| serde_json::from_str::<IndexEntry>(line).ok())
76        .filter(|entry| !entry.yanked)
77        .filter_map(|entry| semver::Version::parse(&entry.vers).ok())
78        .filter(|version| version.pre.is_empty())
79        .max()
80}
81
82/// Fetches `url` (a crates.io sparse-index endpoint) and returns the highest
83/// published stable version found in the response body, if any.
84///
85/// **Errors here are never user-visible; the caller logs at debug and drops
86/// them.** Every failure path (client construction, the request itself, or
87/// reading the body) maps to `Diagnostic::Internal` — never any other
88/// `Diagnostic` variant — so a future refactor doesn't leak an `Internal`
89/// diagnostic onto the `main.rs` `Error: {diag}` path. A non-2xx response
90/// (404 for an unpublished crate name, 5xx for a server error) is not
91/// treated as an error at all: it simply means "no info available", so it
92/// returns `Ok(None)`.
93///
94/// `url` is an explicit parameter (rather than derived from `SPARSE_INDEX_URL`
95/// internally) so tests can point this at a mock server.
96pub fn fetch_latest(url: &str) -> Result<Option<semver::Version>, crate::diagnostic::Diagnostic> {
97    let client = reqwest::blocking::Client::builder()
98        .timeout(HTTP_TIMEOUT)
99        .redirect(reqwest::redirect::Policy::none())
100        .user_agent(crate::util::USER_AGENT)
101        .build()
102        .map_err(|e| {
103            crate::diagnostic::Diagnostic::Internal(format!("failed to build update-check HTTP client: {e}"))
104        })?;
105
106    let response = client
107        .get(url)
108        .send()
109        .map_err(|e| crate::diagnostic::Diagnostic::Internal(format!("update-check request failed: {e}")))?;
110
111    if !response.status().is_success() {
112        return Ok(None);
113    }
114
115    let mut buf = String::new();
116    response.take(MAX_BODY_BYTES).read_to_string(&mut buf).map_err(|e| {
117        crate::diagnostic::Diagnostic::Internal(format!("failed to read update-check response body: {e}"))
118    })?;
119
120    Ok(parse_latest_stable(&buf))
121}
122
123/// Pure gate predicate — no TTY/env access, so it is directly unit-testable.
124/// `maybe_notify` reads the real TTY/env state and passes plain `bool`s here.
125///
126/// Open iff the effective format is `Prose`, stderr is an interactive TTY,
127/// and the opt-out env var is not set (dsp-cli/ADR-0015 "Where it runs and what
128/// gates it").
129fn gate_open(fmt: Option<crate::render::Format>, stderr_is_tty: bool, opted_out: bool) -> bool {
130    matches!(fmt, Some(crate::render::Format::Prose)) && stderr_is_tty && !opted_out
131}
132
133/// Pure staleness check: `true` iff the cache has never been checked, or the
134/// last check was at least `interval_hours` ago (boundary inclusive — exactly
135/// `interval_hours` ago counts as stale).
136fn is_stale(
137    now: chrono::DateTime<chrono::Utc>,
138    last_checked: Option<chrono::DateTime<chrono::Utc>>,
139    interval_hours: i64,
140) -> bool {
141    match last_checked {
142        None => true,
143        Some(last) => now - last >= chrono::Duration::hours(interval_hours),
144    }
145}
146
147/// Composes the two-line advisory printed to stderr. Plain language, no
148/// "crate" (dsp-cli/ADR-0001 vocabulary — dsp-cli's user-facing surface avoids the
149/// word).
150fn compose_notice(current: &semver::Version, latest: &semver::Version) -> String {
151    format!(
152        "A newer version of dsp is available: {latest} (you have {current}).\n\
153         Upgrade with: cargo install dsp-cli   (or, if you use cargo-update: cargo install-update -a)"
154    )
155}
156
157/// The single entry point: gates, then best-effort checks + notifies.
158/// Swallows all errors; never alters the process exit code.
159///
160/// The gate (format + TTY + opt-out) is evaluated before any I/O, so the
161/// common non-interactive/agent path costs nothing (dsp-cli/ADR-0015).
162pub fn maybe_notify(fmt: Option<crate::render::Format>) {
163    use std::io::IsTerminal;
164
165    let stderr_is_tty = std::io::stderr().is_terminal();
166    let opted_out = std::env::var(OPT_OUT_ENV).map(|v| !v.is_empty()).unwrap_or(false);
167
168    if !gate_open(fmt, stderr_is_tty, opted_out) {
169        return;
170    }
171
172    if let Err(e) = run_check_and_notify() {
173        tracing::debug!(error = %e, "update check failed; ignoring");
174    }
175}
176
177/// Re-parses a cached `latest_seen` string through `semver::Version`.
178///
179/// This is the **sanitisation guard** on the cache→notice path: `latest_seen`
180/// is a plain `String` round-tripped through TOML, so a corrupt file or a
181/// future cache-format bug could hand back arbitrary text. Re-validating it
182/// as a real `Version` here means garbage/control-char content silently
183/// becomes `None` instead of ever reaching [`compose_notice`] and being
184/// printed verbatim to a TTY (security review). Used for both outcome-matrix
185/// branches in [`run_check_and_notify`] where the candidate falls back to the
186/// cache instead of a fresh fetch.
187fn resolve_cached_latest(latest_seen: &Option<String>) -> Option<semver::Version> {
188    latest_seen.as_deref().and_then(|s| semver::Version::parse(s).ok())
189}
190
191/// Orchestrates one gated invocation: resolve the current version, load the
192/// cache, fetch a fresh version if the cache is stale, and print the
193/// advisory if a newer stable version is known. Never propagates a fetch or
194/// cache-save error upward — see the outcome matrix in the 031 plan's Step 5.
195fn run_check_and_notify() -> Result<(), crate::diagnostic::Diagnostic> {
196    let current = semver::Version::parse(env!("CARGO_PKG_VERSION"))
197        .map_err(|e| crate::diagnostic::Diagnostic::Internal(format!("could not parse own version: {e}")))?;
198
199    let mut cache = cache::UpdateCheckCache::load();
200    let now = chrono::Utc::now();
201
202    let latest: Option<semver::Version> = if is_stale(now, cache.last_checked, CHECK_INTERVAL_HOURS) {
203        // Stamp before the fetch so the 24h backoff holds even on failure —
204        // a failed attempt still counts as an attempt (dsp-cli/ADR-0015).
205        cache.last_checked = Some(now);
206
207        let candidate = match fetch_latest(SPARSE_INDEX_URL) {
208            Ok(Some(v)) => {
209                cache.latest_seen = Some(v.to_string());
210                Some(v)
211            }
212            Ok(None) => resolve_cached_latest(&cache.latest_seen),
213            Err(e) => {
214                // Documented non-fatal contract (dsp-cli/ADR-0015): log at debug and
215                // fall back to the last-known cached version, if any.
216                tracing::debug!(error = %e, "update check fetch failed; using cached version if any");
217                resolve_cached_latest(&cache.latest_seen)
218            }
219        };
220
221        if let Err(e) = cache.save() {
222            tracing::debug!(error = %e, "failed to save update check cache; ignoring");
223        }
224
225        candidate
226    } else {
227        resolve_cached_latest(&cache.latest_seen)
228    };
229
230    if let Some(latest) = latest
231        && latest > current
232    {
233        eprintln!("{}", compose_notice(&current, &latest));
234    }
235
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn multi_version_body_returns_highest_stable() {
245        let body = r#"{"name":"dsp-cli","vers":"0.1.0","yanked":false,"cksum":"abc"}
246{"name":"dsp-cli","vers":"0.1.1","yanked":false,"cksum":"def"}
247{"name":"dsp-cli","vers":"0.1.2","yanked":false,"cksum":"ghi"}
248"#;
249        assert_eq!(parse_latest_stable(body), Some(semver::Version::parse("0.1.2").unwrap()));
250    }
251
252    #[test]
253    fn yanked_highest_entry_is_skipped() {
254        let body = r#"{"name":"dsp-cli","vers":"0.1.1","yanked":false}
255{"name":"dsp-cli","vers":"0.1.2","yanked":true}
256"#;
257        assert_eq!(parse_latest_stable(body), Some(semver::Version::parse("0.1.1").unwrap()));
258    }
259
260    #[test]
261    fn prerelease_higher_than_latest_stable_is_skipped() {
262        let body = r#"{"name":"dsp-cli","vers":"0.1.3","yanked":false}
263{"name":"dsp-cli","vers":"0.2.0-rc1","yanked":false}
264"#;
265        assert_eq!(parse_latest_stable(body), Some(semver::Version::parse("0.1.3").unwrap()));
266    }
267
268    #[test]
269    fn interleaved_malformed_lines_are_ignored() {
270        let body = "not json\n\
271{\"name\":\"dsp-cli\",\"vers\":\"0.1.0\",\"yanked\":false}\n\
272\n\
273{\"name\":\"dsp-cli\",\"vers\":\"0.1.1\",\"yanked\":false}\n";
274        assert_eq!(parse_latest_stable(body), Some(semver::Version::parse("0.1.1").unwrap()));
275    }
276
277    #[test]
278    fn empty_body_returns_none() {
279        assert_eq!(parse_latest_stable(""), None);
280    }
281
282    #[test]
283    fn all_malformed_or_all_yanked_returns_none() {
284        let all_malformed = "not json\nalso not json\n";
285        assert_eq!(parse_latest_stable(all_malformed), None);
286
287        let all_yanked = r#"{"name":"dsp-cli","vers":"0.1.0","yanked":true}
288{"name":"dsp-cli","vers":"0.1.1","yanked":true}
289"#;
290        assert_eq!(parse_latest_stable(all_yanked), None);
291    }
292
293    #[test]
294    fn unparseable_vers_is_skipped_not_an_error() {
295        let body = r#"{"vers":"not-a-version","yanked":false}
296{"name":"dsp-cli","vers":"0.1.0","yanked":false}
297"#;
298        assert_eq!(parse_latest_stable(body), Some(semver::Version::parse("0.1.0").unwrap()));
299
300        let only_unparseable = r#"{"vers":"not-a-version","yanked":false}"#;
301        assert_eq!(parse_latest_stable(only_unparseable), None);
302    }
303
304    #[test]
305    fn plain_version_without_v_prefix_parses_and_is_returned() {
306        let body = r#"{"name":"dsp-cli","vers":"0.1.3","yanked":false,"cksum":"abc"}"#;
307        assert_eq!(parse_latest_stable(body), Some(semver::Version::parse("0.1.3").unwrap()));
308    }
309
310    #[test]
311    fn is_stale_when_never_checked() {
312        let now = chrono::Utc::now();
313        assert!(is_stale(now, None, CHECK_INTERVAL_HOURS));
314    }
315
316    #[test]
317    fn is_stale_when_last_checked_25_hours_ago() {
318        let now = chrono::Utc::now();
319        let last = now - chrono::Duration::hours(25);
320        assert!(is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
321    }
322
323    #[test]
324    fn is_fresh_when_last_checked_1_hour_ago() {
325        let now = chrono::Utc::now();
326        let last = now - chrono::Duration::hours(1);
327        assert!(!is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
328    }
329
330    #[test]
331    fn is_stale_at_exact_24_hour_boundary() {
332        let now = chrono::Utc::now();
333        let last = now - chrono::Duration::hours(24);
334        assert!(is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
335    }
336
337    #[test]
338    fn gate_open_when_prose_tty_and_not_opted_out() {
339        assert!(gate_open(Some(crate::render::Format::Prose), true, false));
340    }
341
342    #[test]
343    fn gate_closed_when_format_is_not_prose() {
344        assert!(!gate_open(Some(crate::render::Format::Json), true, false));
345    }
346
347    #[test]
348    fn gate_closed_when_format_is_none() {
349        assert!(!gate_open(None, true, false));
350    }
351
352    #[test]
353    fn gate_closed_when_opted_out() {
354        assert!(!gate_open(Some(crate::render::Format::Prose), true, true));
355    }
356
357    #[test]
358    fn gate_closed_when_stderr_is_not_a_tty() {
359        assert!(!gate_open(Some(crate::render::Format::Prose), false, false));
360    }
361
362    #[test]
363    fn compose_notice_contains_versions_and_install_command() {
364        let current = semver::Version::parse("0.1.2").unwrap();
365        let latest = semver::Version::parse("0.1.3").unwrap();
366        let notice = compose_notice(&current, &latest);
367
368        assert!(notice.contains("0.1.3"));
369        assert!(notice.contains("0.1.2"));
370        assert!(notice.contains("cargo install dsp-cli"));
371    }
372
373    #[test]
374    fn compose_notice_is_exactly_two_lines() {
375        let current = semver::Version::parse("0.1.2").unwrap();
376        let latest = semver::Version::parse("0.1.3").unwrap();
377        let notice = compose_notice(&current, &latest);
378
379        assert_eq!(notice.lines().count(), 2);
380    }
381
382    #[test]
383    fn compose_notice_never_says_crate() {
384        let current = semver::Version::parse("0.1.2").unwrap();
385        let latest = semver::Version::parse("0.1.3").unwrap();
386        let notice = compose_notice(&current, &latest);
387
388        assert!(!notice.to_lowercase().contains("crate"));
389    }
390
391    #[test]
392    fn garbage_latest_seen_reparses_to_none_sanitisation_guard() {
393        // Pins the "network-supplied/cached version is never printed
394        // verbatim" invariant by calling the ACTUAL production function
395        // `run_check_and_notify` uses on both outcome-matrix branches — not a
396        // re-derivation of the same expression — so a regression in
397        // `resolve_cached_latest` (e.g. swapping in an `unwrap_or_default`)
398        // would fail this test.
399        let latest_seen = Some("\u{7}not-a-version".to_string());
400        assert_eq!(resolve_cached_latest(&latest_seen), None);
401    }
402
403    #[test]
404    fn valid_cached_latest_seen_reparses_to_some_version() {
405        // Companion happy-path case: a well-formed cached string round-trips
406        // through `resolve_cached_latest` back to the same `Version`.
407        let latest_seen = Some("0.1.5".to_string());
408        assert_eq!(
409            resolve_cached_latest(&latest_seen),
410            Some(semver::Version::parse("0.1.5").unwrap())
411        );
412    }
413
414    #[test]
415    fn absent_cached_latest_seen_reparses_to_none() {
416        assert_eq!(resolve_cached_latest(&None), None);
417    }
418}