pub mod cache;
use serde::Deserialize;
use std::io::Read;
use std::time::Duration;
pub(crate) const SPARSE_INDEX_URL: &str = "https://index.crates.io/ds/p-/dsp-cli";
const HTTP_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) const CHECK_INTERVAL_HOURS: i64 = 24;
pub(crate) const OPT_OUT_ENV: &str = "DSP_NO_UPDATE_CHECK";
const MAX_BODY_BYTES: u64 = 1 << 20;
#[derive(Debug, Deserialize)]
struct IndexEntry {
vers: String,
#[serde(default)]
yanked: bool,
}
pub fn parse_latest_stable(body: &str) -> Option<semver::Version> {
body.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<IndexEntry>(line).ok())
.filter(|entry| !entry.yanked)
.filter_map(|entry| semver::Version::parse(&entry.vers).ok())
.filter(|version| version.pre.is_empty())
.max()
}
pub fn fetch_latest(url: &str) -> Result<Option<semver::Version>, crate::diagnostic::Diagnostic> {
let client = reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.user_agent(format!(
"dsp-cli/{} (github.com/dasch-swiss/dsp-incubator)",
env!("CARGO_PKG_VERSION")
))
.build()
.map_err(|e| {
crate::diagnostic::Diagnostic::Internal(format!(
"failed to build update-check HTTP client: {e}"
))
})?;
let response = client.get(url).send().map_err(|e| {
crate::diagnostic::Diagnostic::Internal(format!("update-check request failed: {e}"))
})?;
if !response.status().is_success() {
return Ok(None);
}
let mut buf = String::new();
response
.take(MAX_BODY_BYTES)
.read_to_string(&mut buf)
.map_err(|e| {
crate::diagnostic::Diagnostic::Internal(format!(
"failed to read update-check response body: {e}"
))
})?;
Ok(parse_latest_stable(&buf))
}
fn gate_open(fmt: Option<crate::render::Format>, stderr_is_tty: bool, opted_out: bool) -> bool {
matches!(fmt, Some(crate::render::Format::Prose)) && stderr_is_tty && !opted_out
}
fn is_stale(
now: chrono::DateTime<chrono::Utc>,
last_checked: Option<chrono::DateTime<chrono::Utc>>,
interval_hours: i64,
) -> bool {
match last_checked {
None => true,
Some(last) => now - last >= chrono::Duration::hours(interval_hours),
}
}
fn compose_notice(current: &semver::Version, latest: &semver::Version) -> String {
format!(
"A newer version of dsp is available: {latest} (you have {current}).\n\
Upgrade with: cargo install dsp-cli (or, if you use cargo-update: cargo install-update -a)"
)
}
pub fn maybe_notify(fmt: Option<crate::render::Format>) {
use std::io::IsTerminal;
let stderr_is_tty = std::io::stderr().is_terminal();
let opted_out = std::env::var(OPT_OUT_ENV)
.map(|v| !v.is_empty())
.unwrap_or(false);
if !gate_open(fmt, stderr_is_tty, opted_out) {
return;
}
if let Err(e) = run_check_and_notify() {
tracing::debug!(error = %e, "update check failed; ignoring");
}
}
fn resolve_cached_latest(latest_seen: &Option<String>) -> Option<semver::Version> {
latest_seen
.as_deref()
.and_then(|s| semver::Version::parse(s).ok())
}
fn run_check_and_notify() -> Result<(), crate::diagnostic::Diagnostic> {
let current = semver::Version::parse(env!("CARGO_PKG_VERSION")).map_err(|e| {
crate::diagnostic::Diagnostic::Internal(format!("could not parse own version: {e}"))
})?;
let mut cache = cache::UpdateCheckCache::load();
let now = chrono::Utc::now();
let latest: Option<semver::Version> = if is_stale(now, cache.last_checked, CHECK_INTERVAL_HOURS)
{
cache.last_checked = Some(now);
let candidate = match fetch_latest(SPARSE_INDEX_URL) {
Ok(Some(v)) => {
cache.latest_seen = Some(v.to_string());
Some(v)
}
Ok(None) => resolve_cached_latest(&cache.latest_seen),
Err(e) => {
tracing::debug!(error = %e, "update check fetch failed; using cached version if any");
resolve_cached_latest(&cache.latest_seen)
}
};
if let Err(e) = cache.save() {
tracing::debug!(error = %e, "failed to save update check cache; ignoring");
}
candidate
} else {
resolve_cached_latest(&cache.latest_seen)
};
if let Some(latest) = latest
&& latest > current
{
eprintln!("{}", compose_notice(¤t, &latest));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_version_body_returns_highest_stable() {
let body = r#"{"name":"dsp-cli","vers":"0.1.0","yanked":false,"cksum":"abc"}
{"name":"dsp-cli","vers":"0.1.1","yanked":false,"cksum":"def"}
{"name":"dsp-cli","vers":"0.1.2","yanked":false,"cksum":"ghi"}
"#;
assert_eq!(
parse_latest_stable(body),
Some(semver::Version::parse("0.1.2").unwrap())
);
}
#[test]
fn yanked_highest_entry_is_skipped() {
let body = r#"{"name":"dsp-cli","vers":"0.1.1","yanked":false}
{"name":"dsp-cli","vers":"0.1.2","yanked":true}
"#;
assert_eq!(
parse_latest_stable(body),
Some(semver::Version::parse("0.1.1").unwrap())
);
}
#[test]
fn prerelease_higher_than_latest_stable_is_skipped() {
let body = r#"{"name":"dsp-cli","vers":"0.1.3","yanked":false}
{"name":"dsp-cli","vers":"0.2.0-rc1","yanked":false}
"#;
assert_eq!(
parse_latest_stable(body),
Some(semver::Version::parse("0.1.3").unwrap())
);
}
#[test]
fn interleaved_malformed_lines_are_ignored() {
let body = "not json\n\
{\"name\":\"dsp-cli\",\"vers\":\"0.1.0\",\"yanked\":false}\n\
\n\
{\"name\":\"dsp-cli\",\"vers\":\"0.1.1\",\"yanked\":false}\n";
assert_eq!(
parse_latest_stable(body),
Some(semver::Version::parse("0.1.1").unwrap())
);
}
#[test]
fn empty_body_returns_none() {
assert_eq!(parse_latest_stable(""), None);
}
#[test]
fn all_malformed_or_all_yanked_returns_none() {
let all_malformed = "not json\nalso not json\n";
assert_eq!(parse_latest_stable(all_malformed), None);
let all_yanked = r#"{"name":"dsp-cli","vers":"0.1.0","yanked":true}
{"name":"dsp-cli","vers":"0.1.1","yanked":true}
"#;
assert_eq!(parse_latest_stable(all_yanked), None);
}
#[test]
fn unparseable_vers_is_skipped_not_an_error() {
let body = r#"{"vers":"not-a-version","yanked":false}
{"name":"dsp-cli","vers":"0.1.0","yanked":false}
"#;
assert_eq!(
parse_latest_stable(body),
Some(semver::Version::parse("0.1.0").unwrap())
);
let only_unparseable = r#"{"vers":"not-a-version","yanked":false}"#;
assert_eq!(parse_latest_stable(only_unparseable), None);
}
#[test]
fn plain_version_without_v_prefix_parses_and_is_returned() {
let body = r#"{"name":"dsp-cli","vers":"0.1.3","yanked":false,"cksum":"abc"}"#;
assert_eq!(
parse_latest_stable(body),
Some(semver::Version::parse("0.1.3").unwrap())
);
}
#[test]
fn is_stale_when_never_checked() {
let now = chrono::Utc::now();
assert!(is_stale(now, None, CHECK_INTERVAL_HOURS));
}
#[test]
fn is_stale_when_last_checked_25_hours_ago() {
let now = chrono::Utc::now();
let last = now - chrono::Duration::hours(25);
assert!(is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
}
#[test]
fn is_fresh_when_last_checked_1_hour_ago() {
let now = chrono::Utc::now();
let last = now - chrono::Duration::hours(1);
assert!(!is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
}
#[test]
fn is_stale_at_exact_24_hour_boundary() {
let now = chrono::Utc::now();
let last = now - chrono::Duration::hours(24);
assert!(is_stale(now, Some(last), CHECK_INTERVAL_HOURS));
}
#[test]
fn gate_open_when_prose_tty_and_not_opted_out() {
assert!(gate_open(Some(crate::render::Format::Prose), true, false));
}
#[test]
fn gate_closed_when_format_is_not_prose() {
assert!(!gate_open(Some(crate::render::Format::Json), true, false));
}
#[test]
fn gate_closed_when_format_is_none() {
assert!(!gate_open(None, true, false));
}
#[test]
fn gate_closed_when_opted_out() {
assert!(!gate_open(Some(crate::render::Format::Prose), true, true));
}
#[test]
fn gate_closed_when_stderr_is_not_a_tty() {
assert!(!gate_open(Some(crate::render::Format::Prose), false, false));
}
#[test]
fn compose_notice_contains_versions_and_install_command() {
let current = semver::Version::parse("0.1.2").unwrap();
let latest = semver::Version::parse("0.1.3").unwrap();
let notice = compose_notice(¤t, &latest);
assert!(notice.contains("0.1.3"));
assert!(notice.contains("0.1.2"));
assert!(notice.contains("cargo install dsp-cli"));
}
#[test]
fn compose_notice_is_exactly_two_lines() {
let current = semver::Version::parse("0.1.2").unwrap();
let latest = semver::Version::parse("0.1.3").unwrap();
let notice = compose_notice(¤t, &latest);
assert_eq!(notice.lines().count(), 2);
}
#[test]
fn compose_notice_never_says_crate() {
let current = semver::Version::parse("0.1.2").unwrap();
let latest = semver::Version::parse("0.1.3").unwrap();
let notice = compose_notice(¤t, &latest);
assert!(!notice.to_lowercase().contains("crate"));
}
#[test]
fn garbage_latest_seen_reparses_to_none_sanitisation_guard() {
let latest_seen = Some("\u{7}not-a-version".to_string());
assert_eq!(resolve_cached_latest(&latest_seen), None);
}
#[test]
fn valid_cached_latest_seen_reparses_to_some_version() {
let latest_seen = Some("0.1.5".to_string());
assert_eq!(
resolve_cached_latest(&latest_seen),
Some(semver::Version::parse("0.1.5").unwrap())
);
}
#[test]
fn absent_cached_latest_seen_reparses_to_none() {
assert_eq!(resolve_cached_latest(&None), None);
}
}