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(format!(
100            "dsp-cli/{} (github.com/dasch-swiss/dsp-incubator)",
101            env!("CARGO_PKG_VERSION")
102        ))
103        .build()
104        .map_err(|e| {
105            crate::diagnostic::Diagnostic::Internal(format!(
106                "failed to build update-check HTTP client: {e}"
107            ))
108        })?;
109
110    let response = client.get(url).send().map_err(|e| {
111        crate::diagnostic::Diagnostic::Internal(format!("update-check request failed: {e}"))
112    })?;
113
114    if !response.status().is_success() {
115        return Ok(None);
116    }
117
118    let mut buf = String::new();
119    response
120        .take(MAX_BODY_BYTES)
121        .read_to_string(&mut buf)
122        .map_err(|e| {
123            crate::diagnostic::Diagnostic::Internal(format!(
124                "failed to read update-check response body: {e}"
125            ))
126        })?;
127
128    Ok(parse_latest_stable(&buf))
129}
130
131/// Pure gate predicate — no TTY/env access, so it is directly unit-testable.
132/// `maybe_notify` reads the real TTY/env state and passes plain `bool`s here.
133///
134/// Open iff the effective format is `Prose`, stderr is an interactive TTY,
135/// and the opt-out env var is not set (ADR-0015 "Where it runs and what
136/// gates it").
137fn gate_open(fmt: Option<crate::render::Format>, stderr_is_tty: bool, opted_out: bool) -> bool {
138    matches!(fmt, Some(crate::render::Format::Prose)) && stderr_is_tty && !opted_out
139}
140
141/// Pure staleness check: `true` iff the cache has never been checked, or the
142/// last check was at least `interval_hours` ago (boundary inclusive — exactly
143/// `interval_hours` ago counts as stale).
144fn is_stale(
145    now: chrono::DateTime<chrono::Utc>,
146    last_checked: Option<chrono::DateTime<chrono::Utc>>,
147    interval_hours: i64,
148) -> bool {
149    match last_checked {
150        None => true,
151        Some(last) => now - last >= chrono::Duration::hours(interval_hours),
152    }
153}
154
155/// Composes the two-line advisory printed to stderr. Plain language, no
156/// "crate" (ADR-0001 vocabulary — dsp-cli's user-facing surface avoids the
157/// word).
158fn compose_notice(current: &semver::Version, latest: &semver::Version) -> String {
159    format!(
160        "A newer version of dsp is available: {latest} (you have {current}).\n\
161         Upgrade with: cargo install dsp-cli   (or, if you use cargo-update: cargo install-update -a)"
162    )
163}
164
165/// The single entry point: gates, then best-effort checks + notifies.
166/// Swallows all errors; never alters the process exit code.
167///
168/// The gate (format + TTY + opt-out) is evaluated before any I/O, so the
169/// common non-interactive/agent path costs nothing (ADR-0015).
170pub fn maybe_notify(fmt: Option<crate::render::Format>) {
171    use std::io::IsTerminal;
172
173    let stderr_is_tty = std::io::stderr().is_terminal();
174    let opted_out = std::env::var(OPT_OUT_ENV)
175        .map(|v| !v.is_empty())
176        .unwrap_or(false);
177
178    if !gate_open(fmt, stderr_is_tty, opted_out) {
179        return;
180    }
181
182    if let Err(e) = run_check_and_notify() {
183        tracing::debug!(error = %e, "update check failed; ignoring");
184    }
185}
186
187/// Re-parses a cached `latest_seen` string through `semver::Version`.
188///
189/// This is the **sanitisation guard** on the cache→notice path: `latest_seen`
190/// is a plain `String` round-tripped through TOML, so a corrupt file or a
191/// future cache-format bug could hand back arbitrary text. Re-validating it
192/// as a real `Version` here means garbage/control-char content silently
193/// becomes `None` instead of ever reaching [`compose_notice`] and being
194/// printed verbatim to a TTY (security review). Used for both outcome-matrix
195/// branches in [`run_check_and_notify`] where the candidate falls back to the
196/// cache instead of a fresh fetch.
197fn resolve_cached_latest(latest_seen: &Option<String>) -> Option<semver::Version> {
198    latest_seen
199        .as_deref()
200        .and_then(|s| semver::Version::parse(s).ok())
201}
202
203/// Orchestrates one gated invocation: resolve the current version, load the
204/// cache, fetch a fresh version if the cache is stale, and print the
205/// advisory if a newer stable version is known. Never propagates a fetch or
206/// cache-save error upward — see the outcome matrix in the 031 plan's Step 5.
207fn run_check_and_notify() -> Result<(), crate::diagnostic::Diagnostic> {
208    let current = semver::Version::parse(env!("CARGO_PKG_VERSION")).map_err(|e| {
209        crate::diagnostic::Diagnostic::Internal(format!("could not parse own version: {e}"))
210    })?;
211
212    let mut cache = cache::UpdateCheckCache::load();
213    let now = chrono::Utc::now();
214
215    let latest: Option<semver::Version> = if is_stale(now, cache.last_checked, CHECK_INTERVAL_HOURS)
216    {
217        // Stamp before the fetch so the 24h backoff holds even on failure —
218        // a failed attempt still counts as an attempt (ADR-0015).
219        cache.last_checked = Some(now);
220
221        let candidate = match fetch_latest(SPARSE_INDEX_URL) {
222            Ok(Some(v)) => {
223                cache.latest_seen = Some(v.to_string());
224                Some(v)
225            }
226            Ok(None) => resolve_cached_latest(&cache.latest_seen),
227            Err(e) => {
228                // Documented non-fatal contract (ADR-0015): log at debug and
229                // fall back to the last-known cached version, if any.
230                tracing::debug!(error = %e, "update check fetch failed; using cached version if any");
231                resolve_cached_latest(&cache.latest_seen)
232            }
233        };
234
235        if let Err(e) = cache.save() {
236            tracing::debug!(error = %e, "failed to save update check cache; ignoring");
237        }
238
239        candidate
240    } else {
241        resolve_cached_latest(&cache.latest_seen)
242    };
243
244    if let Some(latest) = latest
245        && latest > current
246    {
247        eprintln!("{}", compose_notice(&current, &latest));
248    }
249
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn multi_version_body_returns_highest_stable() {
259        let body = r#"{"name":"dsp-cli","vers":"0.1.0","yanked":false,"cksum":"abc"}
260{"name":"dsp-cli","vers":"0.1.1","yanked":false,"cksum":"def"}
261{"name":"dsp-cli","vers":"0.1.2","yanked":false,"cksum":"ghi"}
262"#;
263        assert_eq!(
264            parse_latest_stable(body),
265            Some(semver::Version::parse("0.1.2").unwrap())
266        );
267    }
268
269    #[test]
270    fn yanked_highest_entry_is_skipped() {
271        let body = r#"{"name":"dsp-cli","vers":"0.1.1","yanked":false}
272{"name":"dsp-cli","vers":"0.1.2","yanked":true}
273"#;
274        assert_eq!(
275            parse_latest_stable(body),
276            Some(semver::Version::parse("0.1.1").unwrap())
277        );
278    }
279
280    #[test]
281    fn prerelease_higher_than_latest_stable_is_skipped() {
282        let body = r#"{"name":"dsp-cli","vers":"0.1.3","yanked":false}
283{"name":"dsp-cli","vers":"0.2.0-rc1","yanked":false}
284"#;
285        assert_eq!(
286            parse_latest_stable(body),
287            Some(semver::Version::parse("0.1.3").unwrap())
288        );
289    }
290
291    #[test]
292    fn interleaved_malformed_lines_are_ignored() {
293        let body = "not json\n\
294{\"name\":\"dsp-cli\",\"vers\":\"0.1.0\",\"yanked\":false}\n\
295\n\
296{\"name\":\"dsp-cli\",\"vers\":\"0.1.1\",\"yanked\":false}\n";
297        assert_eq!(
298            parse_latest_stable(body),
299            Some(semver::Version::parse("0.1.1").unwrap())
300        );
301    }
302
303    #[test]
304    fn empty_body_returns_none() {
305        assert_eq!(parse_latest_stable(""), None);
306    }
307
308    #[test]
309    fn all_malformed_or_all_yanked_returns_none() {
310        let all_malformed = "not json\nalso not json\n";
311        assert_eq!(parse_latest_stable(all_malformed), None);
312
313        let all_yanked = r#"{"name":"dsp-cli","vers":"0.1.0","yanked":true}
314{"name":"dsp-cli","vers":"0.1.1","yanked":true}
315"#;
316        assert_eq!(parse_latest_stable(all_yanked), None);
317    }
318
319    #[test]
320    fn unparseable_vers_is_skipped_not_an_error() {
321        let body = r#"{"vers":"not-a-version","yanked":false}
322{"name":"dsp-cli","vers":"0.1.0","yanked":false}
323"#;
324        assert_eq!(
325            parse_latest_stable(body),
326            Some(semver::Version::parse("0.1.0").unwrap())
327        );
328
329        let only_unparseable = r#"{"vers":"not-a-version","yanked":false}"#;
330        assert_eq!(parse_latest_stable(only_unparseable), None);
331    }
332
333    #[test]
334    fn plain_version_without_v_prefix_parses_and_is_returned() {
335        let body = r#"{"name":"dsp-cli","vers":"0.1.3","yanked":false,"cksum":"abc"}"#;
336        assert_eq!(
337            parse_latest_stable(body),
338            Some(semver::Version::parse("0.1.3").unwrap())
339        );
340    }
341
342    #[test]
343    fn is_stale_when_never_checked() {
344        let now = chrono::Utc::now();
345        assert!(is_stale(now, None, CHECK_INTERVAL_HOURS));
346    }
347
348    #[test]
349    fn is_stale_when_last_checked_25_hours_ago() {
350        let now = chrono::Utc::now();
351        let last = now - chrono::Duration::hours(25);
352        assert!(is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
353    }
354
355    #[test]
356    fn is_fresh_when_last_checked_1_hour_ago() {
357        let now = chrono::Utc::now();
358        let last = now - chrono::Duration::hours(1);
359        assert!(!is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
360    }
361
362    #[test]
363    fn is_stale_at_exact_24_hour_boundary() {
364        let now = chrono::Utc::now();
365        let last = now - chrono::Duration::hours(24);
366        assert!(is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
367    }
368
369    #[test]
370    fn gate_open_when_prose_tty_and_not_opted_out() {
371        assert!(gate_open(Some(crate::render::Format::Prose), true, false));
372    }
373
374    #[test]
375    fn gate_closed_when_format_is_not_prose() {
376        assert!(!gate_open(Some(crate::render::Format::Json), true, false));
377    }
378
379    #[test]
380    fn gate_closed_when_format_is_none() {
381        assert!(!gate_open(None, true, false));
382    }
383
384    #[test]
385    fn gate_closed_when_opted_out() {
386        assert!(!gate_open(Some(crate::render::Format::Prose), true, true));
387    }
388
389    #[test]
390    fn gate_closed_when_stderr_is_not_a_tty() {
391        assert!(!gate_open(Some(crate::render::Format::Prose), false, false));
392    }
393
394    #[test]
395    fn compose_notice_contains_versions_and_install_command() {
396        let current = semver::Version::parse("0.1.2").unwrap();
397        let latest = semver::Version::parse("0.1.3").unwrap();
398        let notice = compose_notice(&current, &latest);
399
400        assert!(notice.contains("0.1.3"));
401        assert!(notice.contains("0.1.2"));
402        assert!(notice.contains("cargo install dsp-cli"));
403    }
404
405    #[test]
406    fn compose_notice_is_exactly_two_lines() {
407        let current = semver::Version::parse("0.1.2").unwrap();
408        let latest = semver::Version::parse("0.1.3").unwrap();
409        let notice = compose_notice(&current, &latest);
410
411        assert_eq!(notice.lines().count(), 2);
412    }
413
414    #[test]
415    fn compose_notice_never_says_crate() {
416        let current = semver::Version::parse("0.1.2").unwrap();
417        let latest = semver::Version::parse("0.1.3").unwrap();
418        let notice = compose_notice(&current, &latest);
419
420        assert!(!notice.to_lowercase().contains("crate"));
421    }
422
423    #[test]
424    fn garbage_latest_seen_reparses_to_none_sanitisation_guard() {
425        // Pins the "network-supplied/cached version is never printed
426        // verbatim" invariant by calling the ACTUAL production function
427        // `run_check_and_notify` uses on both outcome-matrix branches — not a
428        // re-derivation of the same expression — so a regression in
429        // `resolve_cached_latest` (e.g. swapping in an `unwrap_or_default`)
430        // would fail this test.
431        let latest_seen = Some("\u{7}not-a-version".to_string());
432        assert_eq!(resolve_cached_latest(&latest_seen), None);
433    }
434
435    #[test]
436    fn valid_cached_latest_seen_reparses_to_some_version() {
437        // Companion happy-path case: a well-formed cached string round-trips
438        // through `resolve_cached_latest` back to the same `Version`.
439        let latest_seen = Some("0.1.5".to_string());
440        assert_eq!(
441            resolve_cached_latest(&latest_seen),
442            Some(semver::Version::parse("0.1.5").unwrap())
443        );
444    }
445
446    #[test]
447    fn absent_cached_latest_seen_reparses_to_none() {
448        assert_eq!(resolve_cached_latest(&None), None);
449    }
450}