1pub mod cache;
16
17use serde::Deserialize;
18use std::io::Read;
19use std::time::Duration;
20
21pub(crate) const SPARSE_INDEX_URL: &str = "https://index.crates.io/ds/p-/dsp-cli";
30
31const HTTP_TIMEOUT: Duration = Duration::from_secs(2);
35
36pub(crate) const CHECK_INTERVAL_HOURS: i64 = 24;
40
41pub(crate) const OPT_OUT_ENV: &str = "DSP_NO_UPDATE_CHECK";
45
46const MAX_BODY_BYTES: u64 = 1 << 20;
51
52#[derive(Debug, Deserialize)]
56struct IndexEntry {
57 vers: String,
58 #[serde(default)]
59 yanked: bool,
60}
61
62pub 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
81pub 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
131fn 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
141fn 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
155fn 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
165pub 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
187fn 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
203fn 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 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 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(¤t, &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(¤t, &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(¤t, &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(¤t, &latest);
419
420 assert!(!notice.to_lowercase().contains("crate"));
421 }
422
423 #[test]
424 fn garbage_latest_seen_reparses_to_none_sanitisation_guard() {
425 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 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}