Skip to main content

dsp_cli/update/
mod.rs

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