#[cfg(feature = "async")]
pub mod r#async;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
const MAX_MESSAGE_SIZE: usize = 4096;
pub(crate) fn truncate_message(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.len() > MAX_MESSAGE_SIZE {
let mut end = MAX_MESSAGE_SIZE;
while !trimmed.is_char_boundary(end) {
end -= 1;
}
Some(trimmed[..end].to_string())
} else {
Some(trimmed.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateInfo {
pub current: String,
pub latest: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct DetailedUpdateInfo {
pub current: String,
pub latest: String,
pub message: Option<String>,
#[cfg(feature = "response-body")]
pub response_body: Option<String>,
}
impl From<UpdateInfo> for DetailedUpdateInfo {
fn from(info: UpdateInfo) -> Self {
Self {
current: info.current,
latest: info.latest,
message: None,
#[cfg(feature = "response-body")]
response_body: None,
}
}
}
impl From<DetailedUpdateInfo> for UpdateInfo {
fn from(info: DetailedUpdateInfo) -> Self {
Self {
current: info.current,
latest: info.latest,
}
}
}
#[derive(Debug)]
pub enum Error {
HttpError(String),
ParseError(String),
VersionError(String),
CacheError(String),
InvalidCrateName(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::HttpError(msg) => write!(f, "HTTP error: {msg}"),
Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
Self::VersionError(msg) => write!(f, "Version error: {msg}"),
Self::CacheError(msg) => write!(f, "Cache error: {msg}"),
Self::InvalidCrateName(msg) => write!(f, "Invalid crate name: {msg}"),
}
}
}
impl std::error::Error for Error {}
#[derive(Debug, Clone)]
pub struct UpdateChecker {
crate_name: String,
current_version: String,
cache_duration: Duration,
timeout: Duration,
cache_dir: Option<PathBuf>,
include_prerelease: bool,
message_url: Option<String>,
}
impl UpdateChecker {
#[must_use]
pub fn new(crate_name: impl Into<String>, current_version: impl Into<String>) -> Self {
Self {
crate_name: crate_name.into(),
current_version: current_version.into(),
cache_duration: Duration::from_secs(24 * 60 * 60), timeout: Duration::from_secs(5),
cache_dir: cache_dir(),
include_prerelease: false,
message_url: None,
}
}
#[must_use]
pub const fn cache_duration(mut self, duration: Duration) -> Self {
self.cache_duration = duration;
self
}
#[must_use]
pub const fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn cache_dir(mut self, dir: Option<PathBuf>) -> Self {
self.cache_dir = dir;
self
}
#[must_use]
pub const fn include_prerelease(mut self, include: bool) -> Self {
self.include_prerelease = include;
self
}
#[must_use]
pub fn message_url(mut self, url: impl Into<String>) -> Self {
self.message_url = Some(url.into());
self
}
pub fn check(&self) -> Result<Option<UpdateInfo>, Error> {
#[cfg(feature = "do-not-track")]
if do_not_track_enabled() {
return Ok(None);
}
validate_crate_name(&self.crate_name)?;
let (latest, _) = self.get_latest_version()?;
compare_versions(&self.current_version, latest, self.include_prerelease)
}
pub fn check_detailed(&self) -> Result<Option<DetailedUpdateInfo>, Error> {
#[cfg(feature = "do-not-track")]
if do_not_track_enabled() {
return Ok(None);
}
validate_crate_name(&self.crate_name)?;
#[cfg(feature = "response-body")]
let (latest, response_body) = self.get_latest_version()?;
#[cfg(not(feature = "response-body"))]
let (latest, _) = self.get_latest_version()?;
let update = compare_versions(&self.current_version, latest, self.include_prerelease)?;
Ok(update.map(|info| {
let mut detailed = DetailedUpdateInfo::from(info);
if let Some(ref url) = self.message_url {
detailed.message = self.fetch_message(url);
}
#[cfg(feature = "response-body")]
{
detailed.response_body = response_body;
}
detailed
}))
}
fn get_latest_version(&self) -> Result<(String, Option<String>), Error> {
let path = self
.cache_dir
.as_ref()
.map(|d| d.join(format!("{}-update-check", self.crate_name)));
if self.cache_duration > Duration::ZERO {
if let Some(ref path) = path {
if let Some(cached) = read_cache(path, self.cache_duration) {
return Ok((cached, None));
}
}
}
let (latest, response_body) = self.fetch_latest_version()?;
if let Some(ref path) = path {
let _ = fs::write(path, &latest);
}
Ok((latest, response_body))
}
fn fetch_latest_version(&self) -> Result<(String, Option<String>), Error> {
let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
let response = minreq::get(&url)
.with_timeout(self.timeout.as_secs())
.with_header("User-Agent", USER_AGENT)
.send()
.map_err(|e| Error::HttpError(e.to_string()))?;
let body = response
.as_str()
.map_err(|e| Error::HttpError(e.to_string()))?;
let version = extract_newest_version(body)?;
#[cfg(feature = "response-body")]
return Ok((version, Some(body.to_string())));
#[cfg(not(feature = "response-body"))]
Ok((version, None))
}
fn fetch_message(&self, url: &str) -> Option<String> {
let response = minreq::get(url)
.with_timeout(self.timeout.as_secs())
.with_header("User-Agent", USER_AGENT)
.send()
.ok()?;
let body = response.as_str().ok()?;
truncate_message(body)
}
}
pub(crate) fn compare_versions(
current_version: &str,
latest: String,
include_prerelease: bool,
) -> Result<Option<UpdateInfo>, Error> {
let current = semver::Version::parse(current_version)
.map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
let latest_ver = semver::Version::parse(&latest)
.map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
if !include_prerelease && !latest_ver.pre.is_empty() {
return Ok(None);
}
if latest_ver > current {
Ok(Some(UpdateInfo {
current: current_version.to_string(),
latest,
}))
} else {
Ok(None)
}
}
pub(crate) fn read_cache(path: &std::path::Path, cache_duration: Duration) -> Option<String> {
let metadata = fs::metadata(path).ok()?;
let modified = metadata.modified().ok()?;
let age = SystemTime::now().duration_since(modified).ok()?;
if age < cache_duration {
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
} else {
None
}
}
pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
let json: serde_json::Value =
serde_json::from_str(body).map_err(|e| Error::ParseError(e.to_string()))?;
json["crate"]["newest_version"]
.as_str()
.map(String::from)
.ok_or_else(|| {
if json.get("crate").is_none() {
Error::ParseError("'crate' field not found in response".to_string())
} else {
Error::ParseError("'newest_version' field not found in response".to_string())
}
})
}
#[cfg(feature = "do-not-track")]
pub(crate) fn do_not_track_enabled() -> bool {
std::env::var("DO_NOT_TRACK")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
fn validate_crate_name(name: &str) -> Result<(), Error> {
if name.is_empty() {
return Err(Error::InvalidCrateName(
"crate name cannot be empty".to_string(),
));
}
if name.len() > 64 {
return Err(Error::InvalidCrateName(format!(
"crate name exceeds 64 characters: {}",
name.len()
)));
}
let first_char = name.chars().next().unwrap(); if !first_char.is_ascii_alphabetic() {
return Err(Error::InvalidCrateName(format!(
"crate name must start with a letter, found: '{first_char}'"
)));
}
for ch in name.chars() {
if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
return Err(Error::InvalidCrateName(format!(
"invalid character in crate name: '{ch}'"
)));
}
}
Ok(())
}
pub(crate) fn cache_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
}
#[cfg(target_os = "linux")]
{
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
}
#[cfg(target_os = "windows")]
{
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
None
}
}
pub fn check(
crate_name: impl Into<String>,
current_version: impl Into<String>,
) -> Result<Option<UpdateInfo>, Error> {
UpdateChecker::new(crate_name, current_version).check()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_info_display() {
let info = UpdateInfo {
current: "1.0.0".to_string(),
latest: "2.0.0".to_string(),
};
assert_eq!(info.current, "1.0.0");
assert_eq!(info.latest, "2.0.0");
}
#[test]
fn test_checker_builder() {
let checker = UpdateChecker::new("test-crate", "1.0.0")
.cache_duration(Duration::from_secs(3600))
.timeout(Duration::from_secs(10));
assert_eq!(checker.crate_name, "test-crate");
assert_eq!(checker.current_version, "1.0.0");
assert_eq!(checker.cache_duration, Duration::from_secs(3600));
assert_eq!(checker.timeout, Duration::from_secs(10));
assert!(checker.message_url.is_none());
}
#[test]
fn test_cache_disabled() {
let checker = UpdateChecker::new("test-crate", "1.0.0")
.cache_duration(Duration::ZERO)
.cache_dir(None);
assert_eq!(checker.cache_duration, Duration::ZERO);
assert!(checker.cache_dir.is_none());
}
#[test]
fn test_error_display() {
let err = Error::HttpError("connection failed".to_string());
assert_eq!(err.to_string(), "HTTP error: connection failed");
let err = Error::ParseError("invalid json".to_string());
assert_eq!(err.to_string(), "Parse error: invalid json");
let err = Error::InvalidCrateName("empty".to_string());
assert_eq!(err.to_string(), "Invalid crate name: empty");
}
#[test]
fn test_include_prerelease_default() {
let checker = UpdateChecker::new("test-crate", "1.0.0");
assert!(!checker.include_prerelease);
}
#[test]
fn test_include_prerelease_enabled() {
let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
assert!(checker.include_prerelease);
}
#[test]
fn test_include_prerelease_disabled() {
let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
assert!(!checker.include_prerelease);
}
const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
const ESCAPED_CHARS: &str = include_str!("../tests/fixtures/escaped_chars.json");
const NESTED_VERSION: &str = include_str!("../tests/fixtures/nested_version.json");
const NULL_VERSION: &str = include_str!("../tests/fixtures/null_version.json");
#[test]
fn parses_real_crates_io_response() {
let version = extract_newest_version(REAL_RESPONSE).unwrap();
assert_eq!(version, "1.0.228");
}
#[test]
fn parses_compact_json() {
let version = extract_newest_version(COMPACT_JSON).unwrap();
assert_eq!(version, "2.0.0");
}
#[test]
fn parses_pretty_json() {
let version = extract_newest_version(PRETTY_JSON).unwrap();
assert_eq!(version, "3.1.4");
}
#[test]
fn parses_whitespace_around_colon() {
let version = extract_newest_version(SPACED_COLON).unwrap();
assert_eq!(version, "1.2.3");
}
#[test]
fn fails_on_missing_crate_field() {
let result = extract_newest_version(MISSING_CRATE);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("crate"),
"Error should mention 'crate' field: {err}"
);
}
#[test]
fn fails_on_missing_newest_version() {
let result = extract_newest_version(MISSING_VERSION);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("newest_version"),
"Error should mention 'newest_version' field: {err}"
);
}
#[test]
fn fails_on_empty_input() {
let result = extract_newest_version("");
assert!(result.is_err());
}
#[test]
fn fails_on_malformed_json() {
let result = extract_newest_version("not json at all");
assert!(result.is_err());
}
#[test]
fn parses_json_with_escaped_characters() {
let version = extract_newest_version(ESCAPED_CHARS).unwrap();
assert_eq!(version, "4.0.0");
}
#[test]
fn parses_version_from_crate_object_not_versions_array() {
let version = extract_newest_version(NESTED_VERSION).unwrap();
assert_eq!(version, "5.0.0");
}
#[test]
fn fails_on_null_version() {
let result = extract_newest_version(NULL_VERSION);
assert!(result.is_err());
}
#[cfg(feature = "do-not-track")]
mod do_not_track_tests {
use super::*;
#[test]
fn do_not_track_detects_1() {
temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
assert!(do_not_track_enabled());
});
}
#[test]
fn do_not_track_detects_true() {
temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
assert!(do_not_track_enabled());
});
}
#[test]
fn do_not_track_detects_true_case_insensitive() {
temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
assert!(do_not_track_enabled());
});
}
#[test]
fn do_not_track_ignores_other_values() {
temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
assert!(!do_not_track_enabled());
});
temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
assert!(!do_not_track_enabled());
});
temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
assert!(!do_not_track_enabled());
});
}
#[test]
fn do_not_track_disabled_when_unset() {
temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
assert!(!do_not_track_enabled());
});
}
}
#[test]
fn test_message_url_default() {
let checker = UpdateChecker::new("test-crate", "1.0.0");
assert!(checker.message_url.is_none());
}
#[test]
fn test_message_url_builder() {
let checker = UpdateChecker::new("test-crate", "1.0.0")
.message_url("https://example.com/message.txt");
assert_eq!(
checker.message_url.as_deref(),
Some("https://example.com/message.txt")
);
}
#[test]
fn test_message_url_chainable() {
let checker = UpdateChecker::new("test-crate", "1.0.0")
.cache_duration(Duration::from_secs(3600))
.message_url("https://example.com/msg.txt")
.timeout(Duration::from_secs(10));
assert_eq!(
checker.message_url.as_deref(),
Some("https://example.com/msg.txt")
);
assert_eq!(checker.timeout, Duration::from_secs(10));
}
#[test]
fn test_compare_versions_returns_none_message() {
let result = compare_versions("1.0.0", "2.0.0".to_string(), false)
.unwrap()
.unwrap();
assert_eq!(result.current, "1.0.0");
assert_eq!(result.latest, "2.0.0");
}
#[test]
fn test_detailed_update_info_with_message() {
let info = DetailedUpdateInfo {
current: "1.0.0".to_string(),
latest: "2.0.0".to_string(),
message: Some("Please update!".to_string()),
#[cfg(feature = "response-body")]
response_body: None,
};
assert_eq!(info.message.as_deref(), Some("Please update!"));
}
#[cfg(feature = "response-body")]
#[test]
fn test_detailed_update_info_with_response_body() {
let info = DetailedUpdateInfo {
current: "1.0.0".to_string(),
latest: "2.0.0".to_string(),
message: None,
response_body: Some("{\"crate\":{}}".to_string()),
};
assert_eq!(info.response_body.as_deref(), Some("{\"crate\":{}}"));
}
#[test]
fn test_truncate_message_empty() {
assert_eq!(truncate_message(""), None);
}
#[test]
fn test_truncate_message_whitespace_only() {
assert_eq!(truncate_message(" \n\t "), None);
}
#[test]
fn test_truncate_message_ascii_within_limit() {
assert_eq!(
truncate_message("hello world"),
Some("hello world".to_string())
);
}
#[test]
fn test_truncate_message_trims_whitespace() {
assert_eq!(
truncate_message(" hello world \n"),
Some("hello world".to_string())
);
}
#[test]
fn test_truncate_message_exactly_at_limit() {
let msg = "a".repeat(4096);
let result = truncate_message(&msg).unwrap();
assert_eq!(result.len(), 4096);
}
#[test]
fn test_truncate_message_ascii_over_limit() {
let msg = "a".repeat(5000);
let result = truncate_message(&msg).unwrap();
assert_eq!(result.len(), 4096);
}
#[test]
fn test_truncate_message_multibyte_at_boundary() {
let unit = "€"; let count = 4096 / 3 + 1; let msg: String = unit.repeat(count);
let result = truncate_message(&msg).unwrap();
assert!(result.len() <= 4096);
assert!(result.is_char_boundary(result.len()));
assert_eq!(result.len(), (4096 / 3) * 3);
}
}