1#[cfg(feature = "async")]
67pub mod r#async;
68
69use std::fs;
70use std::path::PathBuf;
71use std::time::{Duration, SystemTime};
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct UpdateInfo {
76 pub current: String,
78 pub latest: String,
80}
81
82#[derive(Debug)]
84pub enum Error {
85 HttpError(String),
87 ParseError(String),
89 VersionError(String),
91 CacheError(String),
93 InvalidCrateName(String),
95}
96
97impl std::fmt::Display for Error {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 match self {
100 Self::HttpError(msg) => write!(f, "HTTP error: {msg}"),
101 Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
102 Self::VersionError(msg) => write!(f, "Version error: {msg}"),
103 Self::CacheError(msg) => write!(f, "Cache error: {msg}"),
104 Self::InvalidCrateName(msg) => write!(f, "Invalid crate name: {msg}"),
105 }
106 }
107}
108
109impl std::error::Error for Error {}
110
111#[derive(Debug, Clone)]
126pub struct UpdateChecker {
127 crate_name: String,
128 current_version: String,
129 cache_duration: Duration,
130 timeout: Duration,
131 cache_dir: Option<PathBuf>,
132 include_prerelease: bool,
133}
134
135impl UpdateChecker {
136 #[must_use]
143 pub fn new(crate_name: impl Into<String>, current_version: impl Into<String>) -> Self {
144 Self {
145 crate_name: crate_name.into(),
146 current_version: current_version.into(),
147 cache_duration: Duration::from_secs(24 * 60 * 60), timeout: Duration::from_secs(5),
149 cache_dir: dirs::cache_dir(),
150 include_prerelease: false,
151 }
152 }
153
154 #[must_use]
158 pub const fn cache_duration(mut self, duration: Duration) -> Self {
159 self.cache_duration = duration;
160 self
161 }
162
163 #[must_use]
165 pub const fn timeout(mut self, timeout: Duration) -> Self {
166 self.timeout = timeout;
167 self
168 }
169
170 #[must_use]
174 pub fn cache_dir(mut self, dir: Option<PathBuf>) -> Self {
175 self.cache_dir = dir;
176 self
177 }
178
179 #[must_use]
185 pub const fn include_prerelease(mut self, include: bool) -> Self {
186 self.include_prerelease = include;
187 self
188 }
189
190 pub fn check(&self) -> Result<Option<UpdateInfo>, Error> {
202 #[cfg(feature = "do-not-track")]
203 if do_not_track_enabled() {
204 return Ok(None);
205 }
206
207 validate_crate_name(&self.crate_name)?;
208 let latest = self.get_latest_version()?;
209
210 let current = semver::Version::parse(&self.current_version)
211 .map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
212 let latest_ver = semver::Version::parse(&latest)
213 .map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
214
215 if !self.include_prerelease && !latest_ver.pre.is_empty() {
217 return Ok(None);
218 }
219
220 if latest_ver > current {
221 Ok(Some(UpdateInfo {
222 current: self.current_version.clone(),
223 latest,
224 }))
225 } else {
226 Ok(None)
227 }
228 }
229
230 fn get_latest_version(&self) -> Result<String, Error> {
232 let cache_path = self.cache_path();
233
234 if self.cache_duration > Duration::ZERO {
236 if let Some(ref path) = cache_path {
237 if let Some(cached) = self.read_cache(path) {
238 return Ok(cached);
239 }
240 }
241 }
242
243 let latest = self.fetch_latest_version()?;
245
246 if let Some(ref path) = cache_path {
248 let _ = fs::write(path, &latest);
249 }
250
251 Ok(latest)
252 }
253
254 fn cache_path(&self) -> Option<PathBuf> {
256 self.cache_dir
257 .as_ref()
258 .map(|d| d.join(format!("{}-update-check", self.crate_name)))
259 }
260
261 fn read_cache(&self, path: &std::path::Path) -> Option<String> {
263 let metadata = fs::metadata(path).ok()?;
264 let modified = metadata.modified().ok()?;
265 let age = SystemTime::now().duration_since(modified).ok()?;
266
267 if age < self.cache_duration {
268 fs::read_to_string(path).ok().map(|s| s.trim().to_string())
269 } else {
270 None
271 }
272 }
273
274 fn fetch_latest_version(&self) -> Result<String, Error> {
276 let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
277
278 let agent: ureq::Agent = ureq::Agent::config_builder()
279 .timeout_global(Some(self.timeout))
280 .user_agent(concat!(
281 env!("CARGO_PKG_NAME"),
282 "/",
283 env!("CARGO_PKG_VERSION")
284 ))
285 .tls_config(build_tls_config())
286 .build()
287 .into();
288
289 let body = agent
290 .get(&url)
291 .call()
292 .map_err(|e| Error::HttpError(e.to_string()))?
293 .into_body()
294 .read_to_string()
295 .map_err(|e| Error::HttpError(e.to_string()))?;
296
297 extract_newest_version(&body)
298 }
299}
300
301pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
306 let crate_start = body
308 .find(r#""crate""#)
309 .ok_or_else(|| Error::ParseError("'crate' field not found in response".to_string()))?;
310
311 let search_region = &body[crate_start..];
313
314 let version_key = r#""newest_version""#;
316 let key_pos = search_region.find(version_key).ok_or_else(|| {
317 Error::ParseError("'newest_version' field not found in response".to_string())
318 })?;
319
320 let after_key = &search_region[key_pos + version_key.len()..];
322
323 let colon_pos = after_key.find(':').ok_or_else(|| {
325 Error::ParseError("malformed JSON: missing colon after newest_version".to_string())
326 })?;
327
328 let after_colon = &after_key[colon_pos + 1..];
330 let after_colon_trimmed = after_colon.trim_start();
331
332 if !after_colon_trimmed.starts_with('"') {
334 return Err(Error::ParseError(
335 "malformed JSON: expected quote after newest_version colon".to_string(),
336 ));
337 }
338
339 let version_start = &after_colon_trimmed[1..];
341 let quote_end = version_start
342 .find('"')
343 .ok_or_else(|| Error::ParseError("malformed JSON: unclosed version string".to_string()))?;
344
345 Ok(version_start[..quote_end].to_string())
346}
347
348#[cfg(feature = "do-not-track")]
352pub(crate) fn do_not_track_enabled() -> bool {
353 std::env::var("DO_NOT_TRACK")
354 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
355 .unwrap_or(false)
356}
357
358fn validate_crate_name(name: &str) -> Result<(), Error> {
366 if name.is_empty() {
367 return Err(Error::InvalidCrateName(
368 "crate name cannot be empty".to_string(),
369 ));
370 }
371
372 if name.len() > 64 {
373 return Err(Error::InvalidCrateName(format!(
374 "crate name exceeds 64 characters: {}",
375 name.len()
376 )));
377 }
378
379 let first_char = name.chars().next().unwrap(); if !first_char.is_ascii_alphabetic() {
381 return Err(Error::InvalidCrateName(format!(
382 "crate name must start with a letter, found: '{first_char}'"
383 )));
384 }
385
386 for ch in name.chars() {
387 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
388 return Err(Error::InvalidCrateName(format!(
389 "invalid character in crate name: '{ch}'"
390 )));
391 }
392 }
393
394 Ok(())
395}
396
397fn build_tls_config() -> ureq::tls::TlsConfig {
399 #[cfg(not(any(feature = "native-tls", feature = "rustls")))]
400 compile_error!("Either 'native-tls' or 'rustls' feature must be enabled");
401
402 #[cfg(feature = "native-tls")]
403 let provider = ureq::tls::TlsProvider::NativeTls;
404
405 #[cfg(all(feature = "rustls", not(feature = "native-tls")))]
406 let provider = ureq::tls::TlsProvider::Rustls;
407
408 ureq::tls::TlsConfig::builder().provider(provider).build()
409}
410
411pub fn check(
425 crate_name: impl Into<String>,
426 current_version: impl Into<String>,
427) -> Result<Option<UpdateInfo>, Error> {
428 UpdateChecker::new(crate_name, current_version).check()
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn test_update_info_display() {
437 let info = UpdateInfo {
438 current: "1.0.0".to_string(),
439 latest: "2.0.0".to_string(),
440 };
441 assert_eq!(info.current, "1.0.0");
442 assert_eq!(info.latest, "2.0.0");
443 }
444
445 #[test]
446 fn test_checker_builder() {
447 let checker = UpdateChecker::new("test-crate", "1.0.0")
448 .cache_duration(Duration::from_secs(3600))
449 .timeout(Duration::from_secs(10));
450
451 assert_eq!(checker.crate_name, "test-crate");
452 assert_eq!(checker.current_version, "1.0.0");
453 assert_eq!(checker.cache_duration, Duration::from_secs(3600));
454 assert_eq!(checker.timeout, Duration::from_secs(10));
455 }
456
457 #[test]
458 fn test_cache_disabled() {
459 let checker = UpdateChecker::new("test-crate", "1.0.0")
460 .cache_duration(Duration::ZERO)
461 .cache_dir(None);
462
463 assert_eq!(checker.cache_duration, Duration::ZERO);
464 assert!(checker.cache_dir.is_none());
465 }
466
467 #[test]
468 fn test_error_display() {
469 let err = Error::HttpError("connection failed".to_string());
470 assert_eq!(err.to_string(), "HTTP error: connection failed");
471
472 let err = Error::ParseError("invalid json".to_string());
473 assert_eq!(err.to_string(), "Parse error: invalid json");
474
475 let err = Error::InvalidCrateName("empty".to_string());
476 assert_eq!(err.to_string(), "Invalid crate name: empty");
477 }
478
479 #[test]
480 fn test_include_prerelease_default() {
481 let checker = UpdateChecker::new("test-crate", "1.0.0");
482 assert!(!checker.include_prerelease);
483 }
484
485 #[test]
486 fn test_include_prerelease_enabled() {
487 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
488 assert!(checker.include_prerelease);
489 }
490
491 #[test]
492 fn test_include_prerelease_disabled() {
493 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
494 assert!(!checker.include_prerelease);
495 }
496
497 const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
499 const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
500 const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
501 const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
502 const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
503 const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
504
505 #[test]
506 fn parses_real_crates_io_response() {
507 let version = extract_newest_version(REAL_RESPONSE).unwrap();
508 assert_eq!(version, "1.0.228");
509 }
510
511 #[test]
512 fn parses_compact_json() {
513 let version = extract_newest_version(COMPACT_JSON).unwrap();
514 assert_eq!(version, "2.0.0");
515 }
516
517 #[test]
518 fn parses_pretty_json() {
519 let version = extract_newest_version(PRETTY_JSON).unwrap();
520 assert_eq!(version, "3.1.4");
521 }
522
523 #[test]
524 fn parses_whitespace_around_colon() {
525 let version = extract_newest_version(SPACED_COLON).unwrap();
526 assert_eq!(version, "1.2.3");
527 }
528
529 #[test]
530 fn fails_on_missing_crate_field() {
531 let result = extract_newest_version(MISSING_CRATE);
532 assert!(result.is_err());
533 let err = result.unwrap_err().to_string();
534 assert!(
535 err.contains("crate"),
536 "Error should mention 'crate' field: {err}"
537 );
538 }
539
540 #[test]
541 fn fails_on_missing_newest_version() {
542 let result = extract_newest_version(MISSING_VERSION);
543 assert!(result.is_err());
544 let err = result.unwrap_err().to_string();
545 assert!(
546 err.contains("newest_version"),
547 "Error should mention 'newest_version' field: {err}"
548 );
549 }
550
551 #[test]
552 fn fails_on_empty_input() {
553 let result = extract_newest_version("");
554 assert!(result.is_err());
555 }
556
557 #[test]
558 fn fails_on_malformed_json() {
559 let result = extract_newest_version("not json at all");
560 assert!(result.is_err());
561 }
562
563 #[cfg(feature = "do-not-track")]
565 mod do_not_track_tests {
566 use super::*;
567
568 #[test]
569 fn do_not_track_detects_1() {
570 temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
571 assert!(do_not_track_enabled());
572 });
573 }
574
575 #[test]
576 fn do_not_track_detects_true() {
577 temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
578 assert!(do_not_track_enabled());
579 });
580 }
581
582 #[test]
583 fn do_not_track_detects_true_case_insensitive() {
584 temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
585 assert!(do_not_track_enabled());
586 });
587 }
588
589 #[test]
590 fn do_not_track_ignores_other_values() {
591 temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
592 assert!(!do_not_track_enabled());
593 });
594 temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
595 assert!(!do_not_track_enabled());
596 });
597 temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
598 assert!(!do_not_track_enabled());
599 });
600 }
601
602 #[test]
603 fn do_not_track_disabled_when_unset() {
604 temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
605 assert!(!do_not_track_enabled());
606 });
607 }
608 }
609}