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 fetch_latest_version(&self) -> Result<(String, Option<String>), Error> {
406 let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
407
408 let response = minreq::get(&url)
409 .with_timeout(self.timeout.as_secs())
410 .with_header("User-Agent", USER_AGENT)
411 .send()
412 .map_err(|e| Error::HttpError(e.to_string()))?;
413
414 let body = response
415 .as_str()
416 .map_err(|e| Error::HttpError(e.to_string()))?;
417
418 let version = extract_newest_version(body)?;
419
420 #[cfg(feature = "response-body")]
421 return Ok((version, Some(body.to_string())));
422
423 #[cfg(not(feature = "response-body"))]
424 Ok((version, None))
425 }
426
427 fn fetch_message(&self, url: &str) -> Option<String> {
431 let response = minreq::get(url)
432 .with_timeout(self.timeout.as_secs())
433 .with_header("User-Agent", USER_AGENT)
434 .send()
435 .ok()?;
436
437 let body = response.as_str().ok()?;
438 truncate_message(body)
439 }
440}
441
442pub(crate) fn compare_versions(
444 current_version: &str,
445 latest: String,
446 include_prerelease: bool,
447) -> Result<Option<UpdateInfo>, Error> {
448 let current = semver::Version::parse(current_version)
449 .map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
450 let latest_ver = semver::Version::parse(&latest)
451 .map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
452
453 if !include_prerelease && !latest_ver.pre.is_empty() {
454 return Ok(None);
455 }
456
457 if latest_ver > current {
458 Ok(Some(UpdateInfo {
459 current: current_version.to_string(),
460 latest,
461 }))
462 } else {
463 Ok(None)
464 }
465}
466
467pub(crate) fn read_cache(path: &std::path::Path, cache_duration: Duration) -> Option<String> {
469 let metadata = fs::metadata(path).ok()?;
470 let modified = metadata.modified().ok()?;
471 let age = SystemTime::now().duration_since(modified).ok()?;
472
473 if age < cache_duration {
474 fs::read_to_string(path).ok().map(|s| s.trim().to_string())
475 } else {
476 None
477 }
478}
479
480pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
484 let json: serde_json::Value =
485 serde_json::from_str(body).map_err(|e| Error::ParseError(e.to_string()))?;
486
487 json["crate"]["newest_version"]
488 .as_str()
489 .map(String::from)
490 .ok_or_else(|| {
491 if json.get("crate").is_none() {
492 Error::ParseError("'crate' field not found in response".to_string())
493 } else {
494 Error::ParseError("'newest_version' field not found in response".to_string())
495 }
496 })
497}
498
499#[cfg(feature = "do-not-track")]
503pub(crate) fn do_not_track_enabled() -> bool {
504 std::env::var("DO_NOT_TRACK")
505 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
506 .unwrap_or(false)
507}
508
509fn validate_crate_name(name: &str) -> Result<(), Error> {
517 if name.is_empty() {
518 return Err(Error::InvalidCrateName(
519 "crate name cannot be empty".to_string(),
520 ));
521 }
522
523 if name.len() > 64 {
524 return Err(Error::InvalidCrateName(format!(
525 "crate name exceeds 64 characters: {}",
526 name.len()
527 )));
528 }
529
530 let first_char = name.chars().next().unwrap(); if !first_char.is_ascii_alphabetic() {
532 return Err(Error::InvalidCrateName(format!(
533 "crate name must start with a letter, found: '{first_char}'"
534 )));
535 }
536
537 for ch in name.chars() {
538 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
539 return Err(Error::InvalidCrateName(format!(
540 "invalid character in crate name: '{ch}'"
541 )));
542 }
543 }
544
545 Ok(())
546}
547
548pub(crate) fn cache_dir() -> Option<PathBuf> {
554 #[cfg(target_os = "macos")]
555 {
556 std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
557 }
558
559 #[cfg(target_os = "linux")]
560 {
561 std::env::var_os("XDG_CACHE_HOME")
562 .map(PathBuf::from)
563 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
564 }
565
566 #[cfg(target_os = "windows")]
567 {
568 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
569 }
570
571 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
572 {
573 None
574 }
575}
576
577pub fn check(
591 crate_name: impl Into<String>,
592 current_version: impl Into<String>,
593) -> Result<Option<UpdateInfo>, Error> {
594 UpdateChecker::new(crate_name, current_version).check()
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn test_update_info_display() {
603 let info = UpdateInfo {
604 current: "1.0.0".to_string(),
605 latest: "2.0.0".to_string(),
606 };
607 assert_eq!(info.current, "1.0.0");
608 assert_eq!(info.latest, "2.0.0");
609 }
610
611 #[test]
612 fn test_checker_builder() {
613 let checker = UpdateChecker::new("test-crate", "1.0.0")
614 .cache_duration(Duration::from_secs(3600))
615 .timeout(Duration::from_secs(10));
616
617 assert_eq!(checker.crate_name, "test-crate");
618 assert_eq!(checker.current_version, "1.0.0");
619 assert_eq!(checker.cache_duration, Duration::from_secs(3600));
620 assert_eq!(checker.timeout, Duration::from_secs(10));
621 assert!(checker.message_url.is_none());
622 }
623
624 #[test]
625 fn test_cache_disabled() {
626 let checker = UpdateChecker::new("test-crate", "1.0.0")
627 .cache_duration(Duration::ZERO)
628 .cache_dir(None);
629
630 assert_eq!(checker.cache_duration, Duration::ZERO);
631 assert!(checker.cache_dir.is_none());
632 }
633
634 #[test]
635 fn test_error_display() {
636 let err = Error::HttpError("connection failed".to_string());
637 assert_eq!(err.to_string(), "HTTP error: connection failed");
638
639 let err = Error::ParseError("invalid json".to_string());
640 assert_eq!(err.to_string(), "Parse error: invalid json");
641
642 let err = Error::InvalidCrateName("empty".to_string());
643 assert_eq!(err.to_string(), "Invalid crate name: empty");
644 }
645
646 #[test]
647 fn test_include_prerelease_default() {
648 let checker = UpdateChecker::new("test-crate", "1.0.0");
649 assert!(!checker.include_prerelease);
650 }
651
652 #[test]
653 fn test_include_prerelease_enabled() {
654 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
655 assert!(checker.include_prerelease);
656 }
657
658 #[test]
659 fn test_include_prerelease_disabled() {
660 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
661 assert!(!checker.include_prerelease);
662 }
663
664 const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
666 const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
667 const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
668 const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
669 const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
670 const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
671 const ESCAPED_CHARS: &str = include_str!("../tests/fixtures/escaped_chars.json");
672 const NESTED_VERSION: &str = include_str!("../tests/fixtures/nested_version.json");
673 const NULL_VERSION: &str = include_str!("../tests/fixtures/null_version.json");
674
675 #[test]
676 fn parses_real_crates_io_response() {
677 let version = extract_newest_version(REAL_RESPONSE).unwrap();
678 assert_eq!(version, "1.0.228");
679 }
680
681 #[test]
682 fn parses_compact_json() {
683 let version = extract_newest_version(COMPACT_JSON).unwrap();
684 assert_eq!(version, "2.0.0");
685 }
686
687 #[test]
688 fn parses_pretty_json() {
689 let version = extract_newest_version(PRETTY_JSON).unwrap();
690 assert_eq!(version, "3.1.4");
691 }
692
693 #[test]
694 fn parses_whitespace_around_colon() {
695 let version = extract_newest_version(SPACED_COLON).unwrap();
696 assert_eq!(version, "1.2.3");
697 }
698
699 #[test]
700 fn fails_on_missing_crate_field() {
701 let result = extract_newest_version(MISSING_CRATE);
702 assert!(result.is_err());
703 let err = result.unwrap_err().to_string();
704 assert!(
705 err.contains("crate"),
706 "Error should mention 'crate' field: {err}"
707 );
708 }
709
710 #[test]
711 fn fails_on_missing_newest_version() {
712 let result = extract_newest_version(MISSING_VERSION);
713 assert!(result.is_err());
714 let err = result.unwrap_err().to_string();
715 assert!(
716 err.contains("newest_version"),
717 "Error should mention 'newest_version' field: {err}"
718 );
719 }
720
721 #[test]
722 fn fails_on_empty_input() {
723 let result = extract_newest_version("");
724 assert!(result.is_err());
725 }
726
727 #[test]
728 fn fails_on_malformed_json() {
729 let result = extract_newest_version("not json at all");
730 assert!(result.is_err());
731 }
732
733 #[test]
734 fn parses_json_with_escaped_characters() {
735 let version = extract_newest_version(ESCAPED_CHARS).unwrap();
736 assert_eq!(version, "4.0.0");
737 }
738
739 #[test]
740 fn parses_version_from_crate_object_not_versions_array() {
741 let version = extract_newest_version(NESTED_VERSION).unwrap();
744 assert_eq!(version, "5.0.0");
745 }
746
747 #[test]
748 fn fails_on_null_version() {
749 let result = extract_newest_version(NULL_VERSION);
750 assert!(result.is_err());
751 }
752
753 #[cfg(feature = "do-not-track")]
755 mod do_not_track_tests {
756 use super::*;
757
758 #[test]
759 fn do_not_track_detects_1() {
760 temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
761 assert!(do_not_track_enabled());
762 });
763 }
764
765 #[test]
766 fn do_not_track_detects_true() {
767 temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
768 assert!(do_not_track_enabled());
769 });
770 }
771
772 #[test]
773 fn do_not_track_detects_true_case_insensitive() {
774 temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
775 assert!(do_not_track_enabled());
776 });
777 }
778
779 #[test]
780 fn do_not_track_ignores_other_values() {
781 temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
782 assert!(!do_not_track_enabled());
783 });
784 temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
785 assert!(!do_not_track_enabled());
786 });
787 temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
788 assert!(!do_not_track_enabled());
789 });
790 }
791
792 #[test]
793 fn do_not_track_disabled_when_unset() {
794 temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
795 assert!(!do_not_track_enabled());
796 });
797 }
798 }
799
800 #[test]
801 fn test_message_url_default() {
802 let checker = UpdateChecker::new("test-crate", "1.0.0");
803 assert!(checker.message_url.is_none());
804 }
805
806 #[test]
807 fn test_message_url_builder() {
808 let checker = UpdateChecker::new("test-crate", "1.0.0")
809 .message_url("https://example.com/message.txt");
810 assert_eq!(
811 checker.message_url.as_deref(),
812 Some("https://example.com/message.txt")
813 );
814 }
815
816 #[test]
817 fn test_message_url_chainable() {
818 let checker = UpdateChecker::new("test-crate", "1.0.0")
819 .cache_duration(Duration::from_secs(3600))
820 .message_url("https://example.com/msg.txt")
821 .timeout(Duration::from_secs(10));
822 assert_eq!(
823 checker.message_url.as_deref(),
824 Some("https://example.com/msg.txt")
825 );
826 assert_eq!(checker.timeout, Duration::from_secs(10));
827 }
828
829 #[test]
830 fn test_compare_versions_returns_none_message() {
831 let result = compare_versions("1.0.0", "2.0.0".to_string(), false)
832 .unwrap()
833 .unwrap();
834 assert_eq!(result.current, "1.0.0");
835 assert_eq!(result.latest, "2.0.0");
836 }
837
838 #[test]
839 fn test_detailed_update_info_with_message() {
840 let info = DetailedUpdateInfo {
841 current: "1.0.0".to_string(),
842 latest: "2.0.0".to_string(),
843 message: Some("Please update!".to_string()),
844 #[cfg(feature = "response-body")]
845 response_body: None,
846 };
847 assert_eq!(info.message.as_deref(), Some("Please update!"));
848 }
849
850 #[cfg(feature = "response-body")]
851 #[test]
852 fn test_detailed_update_info_with_response_body() {
853 let info = DetailedUpdateInfo {
854 current: "1.0.0".to_string(),
855 latest: "2.0.0".to_string(),
856 message: None,
857 response_body: Some("{\"crate\":{}}".to_string()),
858 };
859 assert_eq!(info.response_body.as_deref(), Some("{\"crate\":{}}"));
860 }
861
862 #[test]
863 fn test_truncate_message_empty() {
864 assert_eq!(truncate_message(""), None);
865 }
866
867 #[test]
868 fn test_truncate_message_whitespace_only() {
869 assert_eq!(truncate_message(" \n\t "), None);
870 }
871
872 #[test]
873 fn test_truncate_message_ascii_within_limit() {
874 assert_eq!(
875 truncate_message("hello world"),
876 Some("hello world".to_string())
877 );
878 }
879
880 #[test]
881 fn test_truncate_message_trims_whitespace() {
882 assert_eq!(
883 truncate_message(" hello world \n"),
884 Some("hello world".to_string())
885 );
886 }
887
888 #[test]
889 fn test_truncate_message_exactly_at_limit() {
890 let msg = "a".repeat(4096);
891 let result = truncate_message(&msg).unwrap();
892 assert_eq!(result.len(), 4096);
893 }
894
895 #[test]
896 fn test_truncate_message_ascii_over_limit() {
897 let msg = "a".repeat(5000);
898 let result = truncate_message(&msg).unwrap();
899 assert_eq!(result.len(), 4096);
900 }
901
902 #[test]
903 fn test_truncate_message_multibyte_at_boundary() {
904 let unit = "€"; let count = 4096 / 3 + 1; let msg: String = unit.repeat(count);
908 let result = truncate_message(&msg).unwrap();
909 assert!(result.len() <= 4096);
910 assert!(result.is_char_boundary(result.len()));
912 assert_eq!(result.len(), (4096 / 3) * 3);
914 }
915}