1#[cfg(feature = "async")]
89pub mod r#async;
90
91use std::fs;
92use std::path::PathBuf;
93use std::time::{Duration, SystemTime};
94
95#[cfg(not(any(feature = "native-tls", feature = "rustls")))]
96compile_error!("At least one TLS feature must be enabled: `native-tls` or `rustls`");
97
98pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
99
100const MAX_MESSAGE_SIZE: usize = 4096;
101
102pub(crate) fn truncate_message(text: &str) -> Option<String> {
107 let trimmed = text.trim();
108 if trimmed.is_empty() {
109 return None;
110 }
111 if trimmed.len() > MAX_MESSAGE_SIZE {
112 let mut end = MAX_MESSAGE_SIZE;
113 while !trimmed.is_char_boundary(end) {
114 end -= 1;
115 }
116 Some(trimmed[..end].to_string())
117 } else {
118 Some(trimmed.to_string())
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct UpdateInfo {
130 pub current: String,
132 pub latest: String,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub struct DetailedUpdateInfo {
143 pub current: String,
145 pub latest: String,
147 pub message: Option<String>,
153 #[cfg(feature = "response-body")]
161 pub response_body: Option<String>,
162}
163
164impl From<UpdateInfo> for DetailedUpdateInfo {
165 fn from(info: UpdateInfo) -> Self {
166 Self {
167 current: info.current,
168 latest: info.latest,
169 message: None,
170 #[cfg(feature = "response-body")]
171 response_body: None,
172 }
173 }
174}
175
176impl From<DetailedUpdateInfo> for UpdateInfo {
177 fn from(info: DetailedUpdateInfo) -> Self {
178 Self {
179 current: info.current,
180 latest: info.latest,
181 }
182 }
183}
184
185#[derive(Debug)]
187pub enum Error {
188 HttpError(String),
190 ParseError(String),
192 VersionError(String),
194 CacheError(String),
196 InvalidCrateName(String),
198}
199
200impl std::fmt::Display for Error {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 match self {
203 Self::HttpError(msg) => write!(f, "HTTP error: {msg}"),
204 Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
205 Self::VersionError(msg) => write!(f, "Version error: {msg}"),
206 Self::CacheError(msg) => write!(f, "Cache error: {msg}"),
207 Self::InvalidCrateName(msg) => write!(f, "Invalid crate name: {msg}"),
208 }
209 }
210}
211
212impl std::error::Error for Error {}
213
214#[derive(Debug, Clone)]
229pub struct UpdateChecker {
230 crate_name: String,
231 current_version: String,
232 cache_duration: Duration,
233 timeout: Duration,
234 cache_dir: Option<PathBuf>,
235 include_prerelease: bool,
236 message_url: Option<String>,
237}
238
239impl UpdateChecker {
240 #[must_use]
247 pub fn new(crate_name: impl Into<String>, current_version: impl Into<String>) -> Self {
248 Self {
249 crate_name: crate_name.into(),
250 current_version: current_version.into(),
251 cache_duration: Duration::from_secs(24 * 60 * 60), timeout: Duration::from_secs(5),
253 cache_dir: cache_dir(),
254 include_prerelease: false,
255 message_url: None,
256 }
257 }
258
259 #[must_use]
263 pub const fn cache_duration(mut self, duration: Duration) -> Self {
264 self.cache_duration = duration;
265 self
266 }
267
268 #[must_use]
270 pub const fn timeout(mut self, timeout: Duration) -> Self {
271 self.timeout = timeout;
272 self
273 }
274
275 #[must_use]
279 pub fn cache_dir(mut self, dir: Option<PathBuf>) -> Self {
280 self.cache_dir = dir;
281 self
282 }
283
284 #[must_use]
290 pub const fn include_prerelease(mut self, include: bool) -> Self {
291 self.include_prerelease = include;
292 self
293 }
294
295 #[must_use]
304 pub fn message_url(mut self, url: impl Into<String>) -> Self {
305 self.message_url = Some(url.into());
306 self
307 }
308
309 pub fn check(&self) -> Result<Option<UpdateInfo>, Error> {
329 #[cfg(feature = "do-not-track")]
330 if do_not_track_enabled() {
331 return Ok(None);
332 }
333
334 validate_crate_name(&self.crate_name)?;
335 let (latest, _) = self.get_latest_version()?;
336
337 compare_versions(&self.current_version, latest, self.include_prerelease)
338 }
339
340 pub fn check_detailed(&self) -> Result<Option<DetailedUpdateInfo>, Error> {
356 #[cfg(feature = "do-not-track")]
357 if do_not_track_enabled() {
358 return Ok(None);
359 }
360
361 validate_crate_name(&self.crate_name)?;
362 #[cfg(feature = "response-body")]
363 let (latest, response_body) = self.get_latest_version()?;
364 #[cfg(not(feature = "response-body"))]
365 let (latest, _) = self.get_latest_version()?;
366
367 let update = compare_versions(&self.current_version, latest, self.include_prerelease)?;
368
369 Ok(update.map(|info| {
370 let mut detailed = DetailedUpdateInfo::from(info);
371 if let Some(ref url) = self.message_url {
372 detailed.message = self.fetch_message(url);
373 }
374 #[cfg(feature = "response-body")]
375 {
376 detailed.response_body = response_body;
377 }
378 detailed
379 }))
380 }
381
382 fn get_latest_version(&self) -> Result<(String, Option<String>), Error> {
384 let path = self
385 .cache_dir
386 .as_ref()
387 .map(|d| d.join(format!("{}-update-check", self.crate_name)));
388
389 if self.cache_duration > Duration::ZERO {
391 if let Some(ref path) = path {
392 if let Some(cached) = read_cache(path, self.cache_duration) {
393 return Ok((cached, None));
394 }
395 }
396 }
397
398 let (latest, response_body) = self.fetch_latest_version()?;
400
401 if let Some(ref path) = path {
403 let _ = fs::write(path, &latest);
404 }
405
406 Ok((latest, response_body))
407 }
408
409 #[cfg(feature = "rustls")]
415 fn build_ureq_agent(&self) -> ureq::Agent {
416 ureq::Agent::config_builder()
417 .timeout_global(Some(self.timeout))
418 .build()
419 .into()
420 }
421
422 fn fetch_latest_version(&self) -> Result<(String, Option<String>), Error> {
424 let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
425
426 #[cfg(feature = "rustls")]
429 let body = self
430 .build_ureq_agent()
431 .get(&url)
432 .header("User-Agent", USER_AGENT)
433 .call()
434 .map_err(|e| Error::HttpError(e.to_string()))?
435 .body_mut()
436 .read_to_string()
437 .map_err(|e| Error::HttpError(e.to_string()))?;
438
439 #[cfg(not(feature = "rustls"))]
440 let body = {
441 let response = minreq::get(&url)
442 .with_timeout(self.timeout.as_secs())
443 .with_header("User-Agent", USER_AGENT)
444 .send()
445 .map_err(|e| Error::HttpError(e.to_string()))?;
446 response
447 .as_str()
448 .map_err(|e| Error::HttpError(e.to_string()))?
449 .to_string()
450 };
451
452 let version = extract_newest_version(&body)?;
453
454 #[cfg(feature = "response-body")]
455 return Ok((version, Some(body)));
456
457 #[cfg(not(feature = "response-body"))]
458 Ok((version, None))
459 }
460
461 fn fetch_message(&self, url: &str) -> Option<String> {
465 #[cfg(feature = "rustls")]
467 let body = self
468 .build_ureq_agent()
469 .get(url)
470 .header("User-Agent", USER_AGENT)
471 .call()
472 .ok()?
473 .body_mut()
474 .read_to_string()
475 .ok()?;
476
477 #[cfg(not(feature = "rustls"))]
478 let body = {
479 let response = minreq::get(url)
480 .with_timeout(self.timeout.as_secs())
481 .with_header("User-Agent", USER_AGENT)
482 .send()
483 .ok()?;
484 response.as_str().ok()?.to_string()
485 };
486
487 truncate_message(&body)
488 }
489}
490
491pub(crate) fn compare_versions(
493 current_version: &str,
494 latest: String,
495 include_prerelease: bool,
496) -> Result<Option<UpdateInfo>, Error> {
497 let current = semver::Version::parse(current_version)
498 .map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
499 let latest_ver = semver::Version::parse(&latest)
500 .map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
501
502 if !include_prerelease && !latest_ver.pre.is_empty() {
503 return Ok(None);
504 }
505
506 if latest_ver > current {
507 Ok(Some(UpdateInfo {
508 current: current_version.to_string(),
509 latest,
510 }))
511 } else {
512 Ok(None)
513 }
514}
515
516pub(crate) fn read_cache(path: &std::path::Path, cache_duration: Duration) -> Option<String> {
518 let metadata = fs::metadata(path).ok()?;
519 let modified = metadata.modified().ok()?;
520 let age = SystemTime::now().duration_since(modified).ok()?;
521
522 if age < cache_duration {
523 fs::read_to_string(path).ok().map(|s| s.trim().to_string())
524 } else {
525 None
526 }
527}
528
529pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
533 let json: serde_json::Value =
534 serde_json::from_str(body).map_err(|e| Error::ParseError(e.to_string()))?;
535
536 json["crate"]["newest_version"]
537 .as_str()
538 .map(String::from)
539 .ok_or_else(|| {
540 if json.get("crate").is_none() {
541 Error::ParseError("'crate' field not found in response".to_string())
542 } else {
543 Error::ParseError("'newest_version' field not found in response".to_string())
544 }
545 })
546}
547
548#[cfg(feature = "do-not-track")]
552pub(crate) fn do_not_track_enabled() -> bool {
553 std::env::var("DO_NOT_TRACK")
554 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
555 .unwrap_or(false)
556}
557
558fn validate_crate_name(name: &str) -> Result<(), Error> {
566 if name.is_empty() {
567 return Err(Error::InvalidCrateName(
568 "crate name cannot be empty".to_string(),
569 ));
570 }
571
572 if name.len() > 64 {
573 return Err(Error::InvalidCrateName(format!(
574 "crate name exceeds 64 characters: {}",
575 name.len()
576 )));
577 }
578
579 let first_char = name.chars().next().unwrap(); if !first_char.is_ascii_alphabetic() {
581 return Err(Error::InvalidCrateName(format!(
582 "crate name must start with a letter, found: '{first_char}'"
583 )));
584 }
585
586 for ch in name.chars() {
587 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
588 return Err(Error::InvalidCrateName(format!(
589 "invalid character in crate name: '{ch}'"
590 )));
591 }
592 }
593
594 Ok(())
595}
596
597pub(crate) fn cache_dir() -> Option<PathBuf> {
603 #[cfg(target_os = "macos")]
604 {
605 std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
606 }
607
608 #[cfg(target_os = "linux")]
609 {
610 std::env::var_os("XDG_CACHE_HOME")
611 .map(PathBuf::from)
612 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
613 }
614
615 #[cfg(target_os = "windows")]
616 {
617 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
618 }
619
620 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
621 {
622 None
623 }
624}
625
626pub fn check(
640 crate_name: impl Into<String>,
641 current_version: impl Into<String>,
642) -> Result<Option<UpdateInfo>, Error> {
643 UpdateChecker::new(crate_name, current_version).check()
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use std::fs;
650
651 #[test]
652 fn test_update_info_display() {
653 let info = UpdateInfo {
654 current: "1.0.0".to_string(),
655 latest: "2.0.0".to_string(),
656 };
657 assert_eq!(info.current, "1.0.0");
658 assert_eq!(info.latest, "2.0.0");
659 }
660
661 #[test]
662 fn test_checker_builder() {
663 let checker = UpdateChecker::new("test-crate", "1.0.0")
664 .cache_duration(Duration::from_secs(3600))
665 .timeout(Duration::from_secs(10));
666
667 assert_eq!(checker.crate_name, "test-crate");
668 assert_eq!(checker.current_version, "1.0.0");
669 assert_eq!(checker.cache_duration, Duration::from_secs(3600));
670 assert_eq!(checker.timeout, Duration::from_secs(10));
671 assert!(checker.message_url.is_none());
672 }
673
674 #[test]
675 fn test_cache_disabled() {
676 let checker = UpdateChecker::new("test-crate", "1.0.0")
677 .cache_duration(Duration::ZERO)
678 .cache_dir(None);
679
680 assert_eq!(checker.cache_duration, Duration::ZERO);
681 assert!(checker.cache_dir.is_none());
682 }
683
684 #[test]
685 fn test_error_display() {
686 let err = Error::HttpError("connection failed".to_string());
687 assert_eq!(err.to_string(), "HTTP error: connection failed");
688
689 let err = Error::ParseError("invalid json".to_string());
690 assert_eq!(err.to_string(), "Parse error: invalid json");
691
692 let err = Error::InvalidCrateName("empty".to_string());
693 assert_eq!(err.to_string(), "Invalid crate name: empty");
694
695 let err = Error::VersionError("bad semver".to_string());
696 assert_eq!(err.to_string(), "Version error: bad semver");
697
698 let err = Error::CacheError("permission denied".to_string());
699 assert_eq!(err.to_string(), "Cache error: permission denied");
700 }
701
702 #[test]
703 fn test_from_update_info_to_detailed() {
704 let info = UpdateInfo {
705 current: "1.0.0".to_string(),
706 latest: "2.0.0".to_string(),
707 };
708 let detailed = DetailedUpdateInfo::from(info);
709 assert_eq!(detailed.current, "1.0.0");
710 assert_eq!(detailed.latest, "2.0.0");
711 assert!(detailed.message.is_none());
712 }
713
714 #[test]
715 fn test_from_detailed_to_update_info() {
716 let info = UpdateInfo {
717 current: "1.0.0".to_string(),
718 latest: "2.0.0".to_string(),
719 };
720 let mut detailed = DetailedUpdateInfo::from(info);
721 detailed.message = Some("please upgrade".to_string());
722 let info = UpdateInfo::from(detailed);
723 assert_eq!(info.current, "1.0.0");
724 assert_eq!(info.latest, "2.0.0");
725 }
726
727 #[test]
728 fn compare_versions_rejects_invalid_current() {
729 let err = compare_versions("not-semver", "1.0.0".to_string(), false).unwrap_err();
730 assert!(matches!(err, Error::VersionError(_)));
731 }
732
733 #[test]
734 fn compare_versions_rejects_invalid_latest() {
735 let err = compare_versions("1.0.0", "not-semver".to_string(), false).unwrap_err();
736 assert!(matches!(err, Error::VersionError(_)));
737 }
738
739 #[test]
740 fn read_cache_returns_none_for_expired_entry() {
741 let dir = tempfile::tempdir().unwrap();
742 let path = dir.path().join("test-cache");
743 fs::write(&path, "1.2.3").unwrap();
744
745 let result = read_cache(&path, Duration::ZERO);
747 assert!(result.is_none());
748 }
749
750 #[test]
751 fn read_cache_returns_value_when_fresh() {
752 let dir = tempfile::tempdir().unwrap();
753 let path = dir.path().join("test-cache");
754 fs::write(&path, " 1.2.3 ").unwrap();
755
756 let result = read_cache(&path, Duration::from_secs(3600));
757 assert_eq!(result.unwrap(), "1.2.3");
758 }
759
760 #[test]
761 fn test_include_prerelease_default() {
762 let checker = UpdateChecker::new("test-crate", "1.0.0");
763 assert!(!checker.include_prerelease);
764 }
765
766 #[test]
767 fn test_include_prerelease_enabled() {
768 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
769 assert!(checker.include_prerelease);
770 }
771
772 #[test]
773 fn test_include_prerelease_disabled() {
774 let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
775 assert!(!checker.include_prerelease);
776 }
777
778 const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
780 const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
781 const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
782 const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
783 const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
784 const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
785 const ESCAPED_CHARS: &str = include_str!("../tests/fixtures/escaped_chars.json");
786 const NESTED_VERSION: &str = include_str!("../tests/fixtures/nested_version.json");
787 const NULL_VERSION: &str = include_str!("../tests/fixtures/null_version.json");
788
789 #[test]
790 fn parses_real_crates_io_response() {
791 let version = extract_newest_version(REAL_RESPONSE).unwrap();
792 assert_eq!(version, "1.0.228");
793 }
794
795 #[test]
796 fn parses_compact_json() {
797 let version = extract_newest_version(COMPACT_JSON).unwrap();
798 assert_eq!(version, "2.0.0");
799 }
800
801 #[test]
802 fn parses_pretty_json() {
803 let version = extract_newest_version(PRETTY_JSON).unwrap();
804 assert_eq!(version, "3.1.4");
805 }
806
807 #[test]
808 fn parses_whitespace_around_colon() {
809 let version = extract_newest_version(SPACED_COLON).unwrap();
810 assert_eq!(version, "1.2.3");
811 }
812
813 #[test]
814 fn fails_on_missing_crate_field() {
815 let result = extract_newest_version(MISSING_CRATE);
816 assert!(result.is_err());
817 let err = result.unwrap_err().to_string();
818 assert!(
819 err.contains("crate"),
820 "Error should mention 'crate' field: {err}"
821 );
822 }
823
824 #[test]
825 fn fails_on_missing_newest_version() {
826 let result = extract_newest_version(MISSING_VERSION);
827 assert!(result.is_err());
828 let err = result.unwrap_err().to_string();
829 assert!(
830 err.contains("newest_version"),
831 "Error should mention 'newest_version' field: {err}"
832 );
833 }
834
835 #[test]
836 fn fails_on_empty_input() {
837 let result = extract_newest_version("");
838 assert!(result.is_err());
839 }
840
841 #[test]
842 fn fails_on_malformed_json() {
843 let result = extract_newest_version("not json at all");
844 assert!(result.is_err());
845 }
846
847 #[test]
848 fn parses_json_with_escaped_characters() {
849 let version = extract_newest_version(ESCAPED_CHARS).unwrap();
850 assert_eq!(version, "4.0.0");
851 }
852
853 #[test]
854 fn parses_version_from_crate_object_not_versions_array() {
855 let version = extract_newest_version(NESTED_VERSION).unwrap();
858 assert_eq!(version, "5.0.0");
859 }
860
861 #[test]
862 fn fails_on_null_version() {
863 let result = extract_newest_version(NULL_VERSION);
864 assert!(result.is_err());
865 }
866
867 #[cfg(feature = "do-not-track")]
869 mod do_not_track_tests {
870 use super::*;
871
872 #[test]
873 fn do_not_track_detects_1() {
874 temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
875 assert!(do_not_track_enabled());
876 });
877 }
878
879 #[test]
880 fn do_not_track_detects_true() {
881 temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
882 assert!(do_not_track_enabled());
883 });
884 }
885
886 #[test]
887 fn do_not_track_detects_true_case_insensitive() {
888 temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
889 assert!(do_not_track_enabled());
890 });
891 }
892
893 #[test]
894 fn do_not_track_ignores_other_values() {
895 temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
896 assert!(!do_not_track_enabled());
897 });
898 temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
899 assert!(!do_not_track_enabled());
900 });
901 temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
902 assert!(!do_not_track_enabled());
903 });
904 }
905
906 #[test]
907 fn do_not_track_disabled_when_unset() {
908 temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
909 assert!(!do_not_track_enabled());
910 });
911 }
912 }
913
914 #[cfg(feature = "rustls")]
917 mod rustls_tests {
918 use super::*;
919 use std::fs;
920
921 #[test]
922 fn builder_works_with_rustls_feature() {
923 let checker = UpdateChecker::new("test-crate", "1.0.0")
924 .cache_duration(Duration::from_secs(3600))
925 .timeout(Duration::from_secs(10));
926 assert_eq!(checker.crate_name, "test-crate");
927 assert_eq!(checker.timeout, Duration::from_secs(10));
928 }
929
930 #[test]
931 fn cache_hit_does_not_invoke_http() {
932 let dir = tempfile::tempdir().unwrap();
935 let cache_file = dir.path().join("test-crate-update-check");
936 fs::write(&cache_file, "99.0.0").unwrap();
937
938 let checker = UpdateChecker::new("test-crate", "1.0.0")
939 .cache_dir(Some(dir.path().to_path_buf()))
940 .cache_duration(Duration::from_secs(3600));
941
942 let result = checker.check().unwrap();
943 assert!(result.is_some());
944 assert_eq!(result.unwrap().latest, "99.0.0");
945 }
946
947 #[cfg(feature = "do-not-track")]
948 #[test]
949 fn do_not_track_returns_none_with_rustls() {
950 temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
951 let checker = UpdateChecker::new("test-crate", "1.0.0").cache_dir(None);
952 assert!(checker.check().unwrap().is_none());
953 assert!(checker.check_detailed().unwrap().is_none());
954 });
955 }
956
957 #[test]
958 fn invalid_crate_name_rejected_before_http() {
959 let checker = UpdateChecker::new("", "1.0.0").cache_dir(None);
961 assert!(matches!(checker.check(), Err(Error::InvalidCrateName(_))));
962 }
963 }
964
965 #[test]
966 fn test_message_url_default() {
967 let checker = UpdateChecker::new("test-crate", "1.0.0");
968 assert!(checker.message_url.is_none());
969 }
970
971 #[test]
972 fn test_message_url_builder() {
973 let checker = UpdateChecker::new("test-crate", "1.0.0")
974 .message_url("https://example.com/message.txt");
975 assert_eq!(
976 checker.message_url.as_deref(),
977 Some("https://example.com/message.txt")
978 );
979 }
980
981 #[test]
982 fn test_message_url_chainable() {
983 let checker = UpdateChecker::new("test-crate", "1.0.0")
984 .cache_duration(Duration::from_secs(3600))
985 .message_url("https://example.com/msg.txt")
986 .timeout(Duration::from_secs(10));
987 assert_eq!(
988 checker.message_url.as_deref(),
989 Some("https://example.com/msg.txt")
990 );
991 assert_eq!(checker.timeout, Duration::from_secs(10));
992 }
993
994 #[test]
995 fn test_compare_versions_returns_none_message() {
996 let result = compare_versions("1.0.0", "2.0.0".to_string(), false)
997 .unwrap()
998 .unwrap();
999 assert_eq!(result.current, "1.0.0");
1000 assert_eq!(result.latest, "2.0.0");
1001 }
1002
1003 #[test]
1004 fn test_detailed_update_info_with_message() {
1005 let info = DetailedUpdateInfo {
1006 current: "1.0.0".to_string(),
1007 latest: "2.0.0".to_string(),
1008 message: Some("Please update!".to_string()),
1009 #[cfg(feature = "response-body")]
1010 response_body: None,
1011 };
1012 assert_eq!(info.message.as_deref(), Some("Please update!"));
1013 }
1014
1015 #[cfg(feature = "response-body")]
1016 #[test]
1017 fn test_detailed_update_info_with_response_body() {
1018 let info = DetailedUpdateInfo {
1019 current: "1.0.0".to_string(),
1020 latest: "2.0.0".to_string(),
1021 message: None,
1022 response_body: Some("{\"crate\":{}}".to_string()),
1023 };
1024 assert_eq!(info.response_body.as_deref(), Some("{\"crate\":{}}"));
1025 }
1026
1027 #[test]
1028 fn test_truncate_message_empty() {
1029 assert_eq!(truncate_message(""), None);
1030 }
1031
1032 #[test]
1033 fn test_truncate_message_whitespace_only() {
1034 assert_eq!(truncate_message(" \n\t "), None);
1035 }
1036
1037 #[test]
1038 fn test_truncate_message_ascii_within_limit() {
1039 assert_eq!(
1040 truncate_message("hello world"),
1041 Some("hello world".to_string())
1042 );
1043 }
1044
1045 #[test]
1046 fn test_truncate_message_trims_whitespace() {
1047 assert_eq!(
1048 truncate_message(" hello world \n"),
1049 Some("hello world".to_string())
1050 );
1051 }
1052
1053 #[test]
1054 fn test_truncate_message_exactly_at_limit() {
1055 let msg = "a".repeat(4096);
1056 let result = truncate_message(&msg).unwrap();
1057 assert_eq!(result.len(), 4096);
1058 }
1059
1060 #[test]
1061 fn test_truncate_message_ascii_over_limit() {
1062 let msg = "a".repeat(5000);
1063 let result = truncate_message(&msg).unwrap();
1064 assert_eq!(result.len(), 4096);
1065 }
1066
1067 #[test]
1068 fn test_truncate_message_multibyte_at_boundary() {
1069 let unit = "€"; let count = 4096 / 3 + 1; let msg: String = unit.repeat(count);
1073 let result = truncate_message(&msg).unwrap();
1074 assert!(result.len() <= 4096);
1075 assert!(result.is_char_boundary(result.len()));
1077 assert_eq!(result.len(), (4096 / 3) * 3);
1079 }
1080}