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> {
485 let crate_start = body
487 .find(r#""crate""#)
488 .ok_or_else(|| Error::ParseError("'crate' field not found in response".to_string()))?;
489
490 let search_region = &body[crate_start..];
492
493 let version_key = r#""newest_version""#;
495 let key_pos = search_region.find(version_key).ok_or_else(|| {
496 Error::ParseError("'newest_version' field not found in response".to_string())
497 })?;
498
499 let after_key = &search_region[key_pos + version_key.len()..];
501
502 let colon_pos = after_key.find(':').ok_or_else(|| {
504 Error::ParseError("malformed JSON: missing colon after newest_version".to_string())
505 })?;
506
507 let after_colon = &after_key[colon_pos + 1..];
509 let after_colon_trimmed = after_colon.trim_start();
510
511 if !after_colon_trimmed.starts_with('"') {
513 return Err(Error::ParseError(
514 "malformed JSON: expected quote after newest_version colon".to_string(),
515 ));
516 }
517
518 let version_start = &after_colon_trimmed[1..];
520 let quote_end = version_start
521 .find('"')
522 .ok_or_else(|| Error::ParseError("malformed JSON: unclosed version string".to_string()))?;
523
524 Ok(version_start[..quote_end].to_string())
525}
526
527#[cfg(feature = "do-not-track")]
531pub(crate) fn do_not_track_enabled() -> bool {
532 std::env::var("DO_NOT_TRACK")
533 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
534 .unwrap_or(false)
535}
536
537fn validate_crate_name(name: &str) -> Result<(), Error> {
545 if name.is_empty() {
546 return Err(Error::InvalidCrateName(
547 "crate name cannot be empty".to_string(),
548 ));
549 }
550
551 if name.len() > 64 {
552 return Err(Error::InvalidCrateName(format!(
553 "crate name exceeds 64 characters: {}",
554 name.len()
555 )));
556 }
557
558 let first_char = name.chars().next().unwrap(); if !first_char.is_ascii_alphabetic() {
560 return Err(Error::InvalidCrateName(format!(
561 "crate name must start with a letter, found: '{first_char}'"
562 )));
563 }
564
565 for ch in name.chars() {
566 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
567 return Err(Error::InvalidCrateName(format!(
568 "invalid character in crate name: '{ch}'"
569 )));
570 }
571 }
572
573 Ok(())
574}
575
576pub(crate) fn cache_dir() -> Option<PathBuf> {
582 #[cfg(target_os = "macos")]
583 {
584 std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
585 }
586
587 #[cfg(target_os = "linux")]
588 {
589 std::env::var_os("XDG_CACHE_HOME")
590 .map(PathBuf::from)
591 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
592 }
593
594 #[cfg(target_os = "windows")]
595 {
596 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
597 }
598
599 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
600 {
601 None
602 }
603}
604
605pub fn check(
619 crate_name: impl Into<String>,
620 current_version: impl Into<String>,
621) -> Result<Option<UpdateInfo>, Error> {
622 UpdateChecker::new(crate_name, current_version).check()
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn test_update_info_display() {
631 let info = UpdateInfo {
632 current: "1.0.0".to_string(),
633 latest: "2.0.0".to_string(),
634 };
635 assert_eq!(info.current, "1.0.0");
636 assert_eq!(info.latest, "2.0.0");
637 }
638
639 #[test]
640 fn test_checker_builder() {
641 let checker = UpdateChecker::new("test-crate", "1.0.0")
642 .cache_duration(Duration::from_secs(3600))
643 .timeout(Duration::from_secs(10));
644
645 assert_eq!(checker.crate_name, "test-crate");
646 assert_eq!(checker.current_version, "1.0.0");
647 assert_eq!(checker.cache_duration, Duration::from_secs(3600));
648 assert_eq!(checker.timeout, Duration::from_secs(10));
649 assert!(checker.message_url.is_none());
650 }
651
652 #[test]
653 fn test_cache_disabled() {
654 let checker = UpdateChecker::new("test-crate", "1.0.0")
655 .cache_duration(Duration::ZERO)
656 .cache_dir(None);
657
658 assert_eq!(checker.cache_duration, Duration::ZERO);
659 assert!(checker.cache_dir.is_none());
660 }
661
662 #[test]
663 fn test_error_display() {
664 let err = Error::HttpError("connection failed".to_string());
665 assert_eq!(err.to_string(), "HTTP error: connection failed");
666
667 let err = Error::ParseError("invalid json".to_string());
668 assert_eq!(err.to_string(), "Parse error: invalid json");
669
670 let err = Error::InvalidCrateName("empty".to_string());
671 assert_eq!(err.to_string(), "Invalid crate name: empty");
672 }
673
674 #[test]
675 fn test_include_prerelease_default() {
676 let checker = UpdateChecker::new("test-crate", "1.0.0");
677 assert!(!checker.include_prerelease);
678 }
679
680 #[test]
681 fn test_include_prerelease_enabled() {
682 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
683 assert!(checker.include_prerelease);
684 }
685
686 #[test]
687 fn test_include_prerelease_disabled() {
688 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
689 assert!(!checker.include_prerelease);
690 }
691
692 const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
694 const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
695 const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
696 const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
697 const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
698 const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
699
700 #[test]
701 fn parses_real_crates_io_response() {
702 let version = extract_newest_version(REAL_RESPONSE).unwrap();
703 assert_eq!(version, "1.0.228");
704 }
705
706 #[test]
707 fn parses_compact_json() {
708 let version = extract_newest_version(COMPACT_JSON).unwrap();
709 assert_eq!(version, "2.0.0");
710 }
711
712 #[test]
713 fn parses_pretty_json() {
714 let version = extract_newest_version(PRETTY_JSON).unwrap();
715 assert_eq!(version, "3.1.4");
716 }
717
718 #[test]
719 fn parses_whitespace_around_colon() {
720 let version = extract_newest_version(SPACED_COLON).unwrap();
721 assert_eq!(version, "1.2.3");
722 }
723
724 #[test]
725 fn fails_on_missing_crate_field() {
726 let result = extract_newest_version(MISSING_CRATE);
727 assert!(result.is_err());
728 let err = result.unwrap_err().to_string();
729 assert!(
730 err.contains("crate"),
731 "Error should mention 'crate' field: {err}"
732 );
733 }
734
735 #[test]
736 fn fails_on_missing_newest_version() {
737 let result = extract_newest_version(MISSING_VERSION);
738 assert!(result.is_err());
739 let err = result.unwrap_err().to_string();
740 assert!(
741 err.contains("newest_version"),
742 "Error should mention 'newest_version' field: {err}"
743 );
744 }
745
746 #[test]
747 fn fails_on_empty_input() {
748 let result = extract_newest_version("");
749 assert!(result.is_err());
750 }
751
752 #[test]
753 fn fails_on_malformed_json() {
754 let result = extract_newest_version("not json at all");
755 assert!(result.is_err());
756 }
757
758 #[cfg(feature = "do-not-track")]
760 mod do_not_track_tests {
761 use super::*;
762
763 #[test]
764 fn do_not_track_detects_1() {
765 temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
766 assert!(do_not_track_enabled());
767 });
768 }
769
770 #[test]
771 fn do_not_track_detects_true() {
772 temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
773 assert!(do_not_track_enabled());
774 });
775 }
776
777 #[test]
778 fn do_not_track_detects_true_case_insensitive() {
779 temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
780 assert!(do_not_track_enabled());
781 });
782 }
783
784 #[test]
785 fn do_not_track_ignores_other_values() {
786 temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
787 assert!(!do_not_track_enabled());
788 });
789 temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
790 assert!(!do_not_track_enabled());
791 });
792 temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
793 assert!(!do_not_track_enabled());
794 });
795 }
796
797 #[test]
798 fn do_not_track_disabled_when_unset() {
799 temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
800 assert!(!do_not_track_enabled());
801 });
802 }
803 }
804
805 #[test]
806 fn test_message_url_default() {
807 let checker = UpdateChecker::new("test-crate", "1.0.0");
808 assert!(checker.message_url.is_none());
809 }
810
811 #[test]
812 fn test_message_url_builder() {
813 let checker = UpdateChecker::new("test-crate", "1.0.0")
814 .message_url("https://example.com/message.txt");
815 assert_eq!(
816 checker.message_url.as_deref(),
817 Some("https://example.com/message.txt")
818 );
819 }
820
821 #[test]
822 fn test_message_url_chainable() {
823 let checker = UpdateChecker::new("test-crate", "1.0.0")
824 .cache_duration(Duration::from_secs(3600))
825 .message_url("https://example.com/msg.txt")
826 .timeout(Duration::from_secs(10));
827 assert_eq!(
828 checker.message_url.as_deref(),
829 Some("https://example.com/msg.txt")
830 );
831 assert_eq!(checker.timeout, Duration::from_secs(10));
832 }
833
834 #[test]
835 fn test_compare_versions_returns_none_message() {
836 let result = compare_versions("1.0.0", "2.0.0".to_string(), false)
837 .unwrap()
838 .unwrap();
839 assert_eq!(result.current, "1.0.0");
840 assert_eq!(result.latest, "2.0.0");
841 }
842
843 #[test]
844 fn test_detailed_update_info_with_message() {
845 let info = DetailedUpdateInfo {
846 current: "1.0.0".to_string(),
847 latest: "2.0.0".to_string(),
848 message: Some("Please update!".to_string()),
849 #[cfg(feature = "response-body")]
850 response_body: None,
851 };
852 assert_eq!(info.message.as_deref(), Some("Please update!"));
853 }
854
855 #[cfg(feature = "response-body")]
856 #[test]
857 fn test_detailed_update_info_with_response_body() {
858 let info = DetailedUpdateInfo {
859 current: "1.0.0".to_string(),
860 latest: "2.0.0".to_string(),
861 message: None,
862 response_body: Some("{\"crate\":{}}".to_string()),
863 };
864 assert_eq!(info.response_body.as_deref(), Some("{\"crate\":{}}"));
865 }
866
867 #[test]
868 fn test_truncate_message_empty() {
869 assert_eq!(truncate_message(""), None);
870 }
871
872 #[test]
873 fn test_truncate_message_whitespace_only() {
874 assert_eq!(truncate_message(" \n\t "), None);
875 }
876
877 #[test]
878 fn test_truncate_message_ascii_within_limit() {
879 assert_eq!(
880 truncate_message("hello world"),
881 Some("hello world".to_string())
882 );
883 }
884
885 #[test]
886 fn test_truncate_message_trims_whitespace() {
887 assert_eq!(
888 truncate_message(" hello world \n"),
889 Some("hello world".to_string())
890 );
891 }
892
893 #[test]
894 fn test_truncate_message_exactly_at_limit() {
895 let msg = "a".repeat(4096);
896 let result = truncate_message(&msg).unwrap();
897 assert_eq!(result.len(), 4096);
898 }
899
900 #[test]
901 fn test_truncate_message_ascii_over_limit() {
902 let msg = "a".repeat(5000);
903 let result = truncate_message(&msg).unwrap();
904 assert_eq!(result.len(), 4096);
905 }
906
907 #[test]
908 fn test_truncate_message_multibyte_at_boundary() {
909 let unit = "€"; let count = 4096 / 3 + 1; let msg: String = unit.repeat(count);
913 let result = truncate_message(&msg).unwrap();
914 assert!(result.len() <= 4096);
915 assert!(result.is_char_boundary(result.len()));
917 assert_eq!(result.len(), (4096 / 3) * 3);
919 }
920}