1pub mod cache;
16
17use std::io::Read;
18use std::time::Duration;
19
20use serde::Deserialize;
21
22pub(crate) const SPARSE_INDEX_URL: &str = "https://index.crates.io/ds/p-/dsp-cli";
31
32const HTTP_TIMEOUT: Duration = Duration::from_secs(2);
36
37pub(crate) const CHECK_INTERVAL_HOURS: i64 = 24;
41
42pub(crate) const OPT_OUT_ENV: &str = "DSP_NO_UPDATE_CHECK";
46
47const MAX_BODY_BYTES: u64 = 1 << 20;
52
53#[derive(Debug, Deserialize)]
57struct IndexEntry {
58 vers: String,
59 #[serde(default)]
60 yanked: bool,
61}
62
63pub 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
82pub 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
123fn 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
133fn 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
147fn 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
157pub 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
177fn 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
191fn 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 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 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(¤t, &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(¤t, &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(¤t, &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(¤t, &latest);
387
388 assert!(!notice.to_lowercase().contains("crate"));
389 }
390
391 #[test]
392 fn garbage_latest_seen_reparses_to_none_sanitisation_guard() {
393 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 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}