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(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
128fn 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
138fn 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
152fn 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
162pub 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
184fn 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
200fn 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 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 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(¤t, &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(¤t, &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(¤t, &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(¤t, &latest);
416
417 assert!(!notice.to_lowercase().contains("crate"));
418 }
419
420 #[test]
421 fn garbage_latest_seen_reparses_to_none_sanitisation_guard() {
422 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 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}