1#[cfg(feature = "async")]
87pub mod r#async;
88
89use std::fs;
90use std::path::PathBuf;
91use std::time::{Duration, SystemTime};
92
93pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
94
95const MAX_MESSAGE_SIZE: usize = 4096;
96
97pub(crate) fn truncate_message(text: &str) -> Option<String> {
102 let trimmed = text.trim();
103 if trimmed.is_empty() {
104 return None;
105 }
106 if trimmed.len() > MAX_MESSAGE_SIZE {
107 let mut end = MAX_MESSAGE_SIZE;
108 while !trimmed.is_char_boundary(end) {
109 end -= 1;
110 }
111 Some(trimmed[..end].to_string())
112 } else {
113 Some(trimmed.to_string())
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct UpdateInfo {
125 pub current: String,
127 pub latest: String,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
136#[non_exhaustive]
137pub struct DetailedUpdateInfo {
138 pub current: String,
140 pub latest: String,
142 pub message: Option<String>,
148 #[cfg(feature = "response-body")]
156 pub response_body: Option<String>,
157}
158
159impl From<UpdateInfo> for DetailedUpdateInfo {
160 fn from(info: UpdateInfo) -> Self {
161 Self {
162 current: info.current,
163 latest: info.latest,
164 message: None,
165 #[cfg(feature = "response-body")]
166 response_body: None,
167 }
168 }
169}
170
171impl From<DetailedUpdateInfo> for UpdateInfo {
172 fn from(info: DetailedUpdateInfo) -> Self {
173 Self {
174 current: info.current,
175 latest: info.latest,
176 }
177 }
178}
179
180#[derive(Debug)]
182pub enum Error {
183 HttpError(String),
185 ParseError(String),
187 VersionError(String),
189 CacheError(String),
191 InvalidCrateName(String),
193}
194
195impl std::fmt::Display for Error {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 Self::HttpError(msg) => write!(f, "HTTP error: {msg}"),
199 Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
200 Self::VersionError(msg) => write!(f, "Version error: {msg}"),
201 Self::CacheError(msg) => write!(f, "Cache error: {msg}"),
202 Self::InvalidCrateName(msg) => write!(f, "Invalid crate name: {msg}"),
203 }
204 }
205}
206
207impl std::error::Error for Error {}
208
209#[derive(Debug, Clone)]
224pub struct UpdateChecker {
225 crate_name: String,
226 current_version: String,
227 cache_duration: Duration,
228 timeout: Duration,
229 cache_dir: Option<PathBuf>,
230 include_prerelease: bool,
231 message_url: Option<String>,
232}
233
234impl UpdateChecker {
235 #[must_use]
242 pub fn new(crate_name: impl Into<String>, current_version: impl Into<String>) -> Self {
243 Self {
244 crate_name: crate_name.into(),
245 current_version: current_version.into(),
246 cache_duration: Duration::from_secs(24 * 60 * 60), timeout: Duration::from_secs(5),
248 cache_dir: cache_dir(),
249 include_prerelease: false,
250 message_url: None,
251 }
252 }
253
254 #[must_use]
258 pub const fn cache_duration(mut self, duration: Duration) -> Self {
259 self.cache_duration = duration;
260 self
261 }
262
263 #[must_use]
265 pub const fn timeout(mut self, timeout: Duration) -> Self {
266 self.timeout = timeout;
267 self
268 }
269
270 #[must_use]
274 pub fn cache_dir(mut self, dir: Option<PathBuf>) -> Self {
275 self.cache_dir = dir;
276 self
277 }
278
279 #[must_use]
285 pub const fn include_prerelease(mut self, include: bool) -> Self {
286 self.include_prerelease = include;
287 self
288 }
289
290 #[must_use]
299 pub fn message_url(mut self, url: impl Into<String>) -> Self {
300 self.message_url = Some(url.into());
301 self
302 }
303
304 pub fn check(&self) -> Result<Option<UpdateInfo>, Error> {
324 #[cfg(feature = "do-not-track")]
325 if do_not_track_enabled() {
326 return Ok(None);
327 }
328
329 validate_crate_name(&self.crate_name)?;
330 let (latest, _) = self.get_latest_version()?;
331
332 compare_versions(&self.current_version, latest, self.include_prerelease)
333 }
334
335 pub fn check_detailed(&self) -> Result<Option<DetailedUpdateInfo>, Error> {
351 #[cfg(feature = "do-not-track")]
352 if do_not_track_enabled() {
353 return Ok(None);
354 }
355
356 validate_crate_name(&self.crate_name)?;
357 #[cfg(feature = "response-body")]
358 let (latest, response_body) = self.get_latest_version()?;
359 #[cfg(not(feature = "response-body"))]
360 let (latest, _) = self.get_latest_version()?;
361
362 let update = compare_versions(&self.current_version, latest, self.include_prerelease)?;
363
364 Ok(update.map(|info| {
365 let mut detailed = DetailedUpdateInfo::from(info);
366 if let Some(ref url) = self.message_url {
367 detailed.message = self.fetch_message(url);
368 }
369 #[cfg(feature = "response-body")]
370 {
371 detailed.response_body = response_body;
372 }
373 detailed
374 }))
375 }
376
377 fn get_latest_version(&self) -> Result<(String, Option<String>), Error> {
379 let path = self
380 .cache_dir
381 .as_ref()
382 .map(|d| d.join(format!("{}-update-check", self.crate_name)));
383
384 if self.cache_duration > Duration::ZERO {
386 if let Some(ref path) = path {
387 if let Some(cached) = read_cache(path, self.cache_duration) {
388 return Ok((cached, None));
389 }
390 }
391 }
392
393 let (latest, response_body) = self.fetch_latest_version()?;
395
396 if let Some(ref path) = path {
398 let _ = fs::write(path, &latest);
399 }
400
401 Ok((latest, response_body))
402 }
403
404 fn build_agent(&self) -> ureq::Agent {
405 #[cfg(feature = "native-tls")]
406 let tls = ureq::tls::TlsConfig::builder()
407 .provider(ureq::tls::TlsProvider::NativeTls)
408 .build();
409 #[cfg(all(not(feature = "native-tls"), feature = "rustls"))]
410 let tls = ureq::tls::TlsConfig::builder()
411 .provider(ureq::tls::TlsProvider::Rustls)
412 .build();
413 #[cfg(not(any(feature = "native-tls", feature = "rustls")))]
414 let tls = ureq::tls::TlsConfig::default();
415
416 ureq::Agent::config_builder()
417 .timeout_global(Some(self.timeout))
418 .tls_config(tls)
419 .build()
420 .into()
421 }
422
423 fn fetch_latest_version(&self) -> Result<(String, Option<String>), Error> {
425 let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
426
427 let body = self
428 .build_agent()
429 .get(&url)
430 .header("User-Agent", USER_AGENT)
431 .call()
432 .map_err(|e| Error::HttpError(e.to_string()))?
433 .body_mut()
434 .read_to_string()
435 .map_err(|e| Error::HttpError(e.to_string()))?;
436
437 let version = extract_newest_version(&body)?;
438
439 #[cfg(feature = "response-body")]
440 return Ok((version, Some(body)));
441
442 #[cfg(not(feature = "response-body"))]
443 Ok((version, None))
444 }
445
446 fn fetch_message(&self, url: &str) -> Option<String> {
450 let body = self
451 .build_agent()
452 .get(url)
453 .header("User-Agent", USER_AGENT)
454 .call()
455 .ok()?
456 .body_mut()
457 .read_to_string()
458 .ok()?;
459 truncate_message(&body)
460 }
461}
462
463pub(crate) fn compare_versions(
465 current_version: &str,
466 latest: String,
467 include_prerelease: bool,
468) -> Result<Option<UpdateInfo>, Error> {
469 let current = semver::Version::parse(current_version)
470 .map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
471 let latest_ver = semver::Version::parse(&latest)
472 .map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
473
474 if !include_prerelease && !latest_ver.pre.is_empty() {
475 return Ok(None);
476 }
477
478 if latest_ver > current {
479 Ok(Some(UpdateInfo {
480 current: current_version.to_string(),
481 latest,
482 }))
483 } else {
484 Ok(None)
485 }
486}
487
488pub(crate) fn read_cache(path: &std::path::Path, cache_duration: Duration) -> Option<String> {
490 let metadata = fs::metadata(path).ok()?;
491 let modified = metadata.modified().ok()?;
492 let age = SystemTime::now().duration_since(modified).ok()?;
493
494 if age < cache_duration {
495 fs::read_to_string(path).ok().map(|s| s.trim().to_string())
496 } else {
497 None
498 }
499}
500
501pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
505 let json: serde_json::Value =
506 serde_json::from_str(body).map_err(|e| Error::ParseError(e.to_string()))?;
507
508 json["crate"]["newest_version"]
509 .as_str()
510 .map(String::from)
511 .ok_or_else(|| {
512 if json.get("crate").is_none() {
513 Error::ParseError("'crate' field not found in response".to_string())
514 } else {
515 Error::ParseError("'newest_version' field not found in response".to_string())
516 }
517 })
518}
519
520#[cfg(feature = "do-not-track")]
524pub(crate) fn do_not_track_enabled() -> bool {
525 std::env::var("DO_NOT_TRACK")
526 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
527 .unwrap_or(false)
528}
529
530fn validate_crate_name(name: &str) -> Result<(), Error> {
538 if name.is_empty() {
539 return Err(Error::InvalidCrateName(
540 "crate name cannot be empty".to_string(),
541 ));
542 }
543
544 if name.len() > 64 {
545 return Err(Error::InvalidCrateName(format!(
546 "crate name exceeds 64 characters: {}",
547 name.len()
548 )));
549 }
550
551 let first_char = name.chars().next().unwrap(); if !first_char.is_ascii_alphabetic() {
553 return Err(Error::InvalidCrateName(format!(
554 "crate name must start with a letter, found: '{first_char}'"
555 )));
556 }
557
558 for ch in name.chars() {
559 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
560 return Err(Error::InvalidCrateName(format!(
561 "invalid character in crate name: '{ch}'"
562 )));
563 }
564 }
565
566 Ok(())
567}
568
569pub(crate) fn cache_dir() -> Option<PathBuf> {
575 #[cfg(target_os = "macos")]
576 {
577 std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
578 }
579
580 #[cfg(target_os = "linux")]
581 {
582 std::env::var_os("XDG_CACHE_HOME")
583 .map(PathBuf::from)
584 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
585 }
586
587 #[cfg(target_os = "windows")]
588 {
589 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
590 }
591
592 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
593 {
594 None
595 }
596}
597
598pub fn check(
612 crate_name: impl Into<String>,
613 current_version: impl Into<String>,
614) -> Result<Option<UpdateInfo>, Error> {
615 UpdateChecker::new(crate_name, current_version).check()
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn test_update_info_display() {
624 let info = UpdateInfo {
625 current: "1.0.0".to_string(),
626 latest: "2.0.0".to_string(),
627 };
628 assert_eq!(info.current, "1.0.0");
629 assert_eq!(info.latest, "2.0.0");
630 }
631
632 #[test]
633 fn test_checker_builder() {
634 let checker = UpdateChecker::new("test-crate", "1.0.0")
635 .cache_duration(Duration::from_secs(3600))
636 .timeout(Duration::from_secs(10));
637
638 assert_eq!(checker.crate_name, "test-crate");
639 assert_eq!(checker.current_version, "1.0.0");
640 assert_eq!(checker.cache_duration, Duration::from_secs(3600));
641 assert_eq!(checker.timeout, Duration::from_secs(10));
642 assert!(checker.message_url.is_none());
643 }
644
645 #[test]
646 fn test_cache_disabled() {
647 let checker = UpdateChecker::new("test-crate", "1.0.0")
648 .cache_duration(Duration::ZERO)
649 .cache_dir(None);
650
651 assert_eq!(checker.cache_duration, Duration::ZERO);
652 assert!(checker.cache_dir.is_none());
653 }
654
655 #[test]
656 fn test_error_display() {
657 let err = Error::HttpError("connection failed".to_string());
658 assert_eq!(err.to_string(), "HTTP error: connection failed");
659
660 let err = Error::ParseError("invalid json".to_string());
661 assert_eq!(err.to_string(), "Parse error: invalid json");
662
663 let err = Error::InvalidCrateName("empty".to_string());
664 assert_eq!(err.to_string(), "Invalid crate name: empty");
665 }
666
667 #[test]
668 fn test_include_prerelease_default() {
669 let checker = UpdateChecker::new("test-crate", "1.0.0");
670 assert!(!checker.include_prerelease);
671 }
672
673 #[test]
674 fn test_include_prerelease_enabled() {
675 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
676 assert!(checker.include_prerelease);
677 }
678
679 #[test]
680 fn test_include_prerelease_disabled() {
681 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
682 assert!(!checker.include_prerelease);
683 }
684
685 const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
687 const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
688 const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
689 const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
690 const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
691 const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
692 const ESCAPED_CHARS: &str = include_str!("../tests/fixtures/escaped_chars.json");
693 const NESTED_VERSION: &str = include_str!("../tests/fixtures/nested_version.json");
694 const NULL_VERSION: &str = include_str!("../tests/fixtures/null_version.json");
695
696 #[test]
697 fn parses_real_crates_io_response() {
698 let version = extract_newest_version(REAL_RESPONSE).unwrap();
699 assert_eq!(version, "1.0.228");
700 }
701
702 #[test]
703 fn parses_compact_json() {
704 let version = extract_newest_version(COMPACT_JSON).unwrap();
705 assert_eq!(version, "2.0.0");
706 }
707
708 #[test]
709 fn parses_pretty_json() {
710 let version = extract_newest_version(PRETTY_JSON).unwrap();
711 assert_eq!(version, "3.1.4");
712 }
713
714 #[test]
715 fn parses_whitespace_around_colon() {
716 let version = extract_newest_version(SPACED_COLON).unwrap();
717 assert_eq!(version, "1.2.3");
718 }
719
720 #[test]
721 fn fails_on_missing_crate_field() {
722 let result = extract_newest_version(MISSING_CRATE);
723 assert!(result.is_err());
724 let err = result.unwrap_err().to_string();
725 assert!(
726 err.contains("crate"),
727 "Error should mention 'crate' field: {err}"
728 );
729 }
730
731 #[test]
732 fn fails_on_missing_newest_version() {
733 let result = extract_newest_version(MISSING_VERSION);
734 assert!(result.is_err());
735 let err = result.unwrap_err().to_string();
736 assert!(
737 err.contains("newest_version"),
738 "Error should mention 'newest_version' field: {err}"
739 );
740 }
741
742 #[test]
743 fn fails_on_empty_input() {
744 let result = extract_newest_version("");
745 assert!(result.is_err());
746 }
747
748 #[test]
749 fn fails_on_malformed_json() {
750 let result = extract_newest_version("not json at all");
751 assert!(result.is_err());
752 }
753
754 #[test]
755 fn parses_json_with_escaped_characters() {
756 let version = extract_newest_version(ESCAPED_CHARS).unwrap();
757 assert_eq!(version, "4.0.0");
758 }
759
760 #[test]
761 fn parses_version_from_crate_object_not_versions_array() {
762 let version = extract_newest_version(NESTED_VERSION).unwrap();
765 assert_eq!(version, "5.0.0");
766 }
767
768 #[test]
769 fn fails_on_null_version() {
770 let result = extract_newest_version(NULL_VERSION);
771 assert!(result.is_err());
772 }
773
774 #[cfg(feature = "do-not-track")]
776 mod do_not_track_tests {
777 use super::*;
778
779 #[test]
780 fn do_not_track_detects_1() {
781 temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
782 assert!(do_not_track_enabled());
783 });
784 }
785
786 #[test]
787 fn do_not_track_detects_true() {
788 temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
789 assert!(do_not_track_enabled());
790 });
791 }
792
793 #[test]
794 fn do_not_track_detects_true_case_insensitive() {
795 temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
796 assert!(do_not_track_enabled());
797 });
798 }
799
800 #[test]
801 fn do_not_track_ignores_other_values() {
802 temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
803 assert!(!do_not_track_enabled());
804 });
805 temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
806 assert!(!do_not_track_enabled());
807 });
808 temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
809 assert!(!do_not_track_enabled());
810 });
811 }
812
813 #[test]
814 fn do_not_track_disabled_when_unset() {
815 temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
816 assert!(!do_not_track_enabled());
817 });
818 }
819 }
820
821 #[test]
822 fn test_message_url_default() {
823 let checker = UpdateChecker::new("test-crate", "1.0.0");
824 assert!(checker.message_url.is_none());
825 }
826
827 #[test]
828 fn test_message_url_builder() {
829 let checker = UpdateChecker::new("test-crate", "1.0.0")
830 .message_url("https://example.com/message.txt");
831 assert_eq!(
832 checker.message_url.as_deref(),
833 Some("https://example.com/message.txt")
834 );
835 }
836
837 #[test]
838 fn test_message_url_chainable() {
839 let checker = UpdateChecker::new("test-crate", "1.0.0")
840 .cache_duration(Duration::from_secs(3600))
841 .message_url("https://example.com/msg.txt")
842 .timeout(Duration::from_secs(10));
843 assert_eq!(
844 checker.message_url.as_deref(),
845 Some("https://example.com/msg.txt")
846 );
847 assert_eq!(checker.timeout, Duration::from_secs(10));
848 }
849
850 #[test]
851 fn test_compare_versions_returns_none_message() {
852 let result = compare_versions("1.0.0", "2.0.0".to_string(), false)
853 .unwrap()
854 .unwrap();
855 assert_eq!(result.current, "1.0.0");
856 assert_eq!(result.latest, "2.0.0");
857 }
858
859 #[test]
860 fn test_detailed_update_info_with_message() {
861 let info = DetailedUpdateInfo {
862 current: "1.0.0".to_string(),
863 latest: "2.0.0".to_string(),
864 message: Some("Please update!".to_string()),
865 #[cfg(feature = "response-body")]
866 response_body: None,
867 };
868 assert_eq!(info.message.as_deref(), Some("Please update!"));
869 }
870
871 #[cfg(feature = "response-body")]
872 #[test]
873 fn test_detailed_update_info_with_response_body() {
874 let info = DetailedUpdateInfo {
875 current: "1.0.0".to_string(),
876 latest: "2.0.0".to_string(),
877 message: None,
878 response_body: Some("{\"crate\":{}}".to_string()),
879 };
880 assert_eq!(info.response_body.as_deref(), Some("{\"crate\":{}}"));
881 }
882
883 #[test]
884 fn test_truncate_message_empty() {
885 assert_eq!(truncate_message(""), None);
886 }
887
888 #[test]
889 fn test_truncate_message_whitespace_only() {
890 assert_eq!(truncate_message(" \n\t "), None);
891 }
892
893 #[test]
894 fn test_truncate_message_ascii_within_limit() {
895 assert_eq!(
896 truncate_message("hello world"),
897 Some("hello world".to_string())
898 );
899 }
900
901 #[test]
902 fn test_truncate_message_trims_whitespace() {
903 assert_eq!(
904 truncate_message(" hello world \n"),
905 Some("hello world".to_string())
906 );
907 }
908
909 #[test]
910 fn test_truncate_message_exactly_at_limit() {
911 let msg = "a".repeat(4096);
912 let result = truncate_message(&msg).unwrap();
913 assert_eq!(result.len(), 4096);
914 }
915
916 #[test]
917 fn test_truncate_message_ascii_over_limit() {
918 let msg = "a".repeat(5000);
919 let result = truncate_message(&msg).unwrap();
920 assert_eq!(result.len(), 4096);
921 }
922
923 #[test]
924 fn test_truncate_message_multibyte_at_boundary() {
925 let unit = "€"; let count = 4096 / 3 + 1; let msg: String = unit.repeat(count);
929 let result = truncate_message(&msg).unwrap();
930 assert!(result.len() <= 4096);
931 assert!(result.is_char_boundary(result.len()));
933 assert_eq!(result.len(), (4096 / 3) * 3);
935 }
936}