Skip to main content

tiny_update_check/
lib.rs

1//! # tiny-update-check
2//!
3//! A minimal, lightweight crate update checker for Rust CLI applications.
4//!
5//! This crate provides a simple way to check if a newer version of your crate
6//! is available on crates.io, with built-in caching to avoid excessive API requests.
7//!
8//! ## Features
9//!
10//! - **Minimal dependencies**: Only `minreq`, `semver`, and `serde_json` (sync mode)
11//! - **Small binary impact**: ~0.5MB with `native-tls` (vs ~1.4MB for alternatives)
12//! - **Simple file-based caching**: Configurable cache duration (default: 24 hours)
13//! - **TLS flexibility**: Choose `native-tls` (default) or `rustls`
14//!
15//! ## Quick Start
16//!
17//! ```no_run
18//! use tiny_update_check::UpdateChecker;
19//!
20//! let checker = UpdateChecker::new("my-crate", "1.0.0");
21//! if let Ok(Some(update)) = checker.check() {
22//!     eprintln!("Update available: {} -> {}", update.current, update.latest);
23//! }
24//! ```
25//!
26//! ## With Custom Configuration
27//!
28//! ```no_run
29//! use tiny_update_check::UpdateChecker;
30//! use std::time::Duration;
31//!
32//! let checker = UpdateChecker::new("my-crate", "1.0.0")
33//!     .cache_duration(Duration::from_secs(60 * 60)) // 1 hour
34//!     .timeout(Duration::from_secs(10));
35//!
36//! if let Ok(Some(update)) = checker.check() {
37//!     eprintln!("New version {} released!", update.latest);
38//! }
39//! ```
40//!
41//! ## Feature Flags
42//!
43//! - `native-tls` (default): Uses system TLS, smaller binary size
44//! - `rustls`: Pure Rust TLS, better for cross-compilation
45//! - `async`: Enables async support using `reqwest`
46//! - `do-not-track` (default): Respects [`DO_NOT_TRACK`] environment variable
47//! - `response-body`: Includes the raw crates.io response body in [`DetailedUpdateInfo`]
48//!
49//! ## Update Messages
50//!
51//! You can attach a message to update notifications by hosting a plain text file
52//! at a URL and configuring the checker with [`UpdateChecker::message_url`]:
53//!
54//! ```no_run
55//! use tiny_update_check::UpdateChecker;
56//!
57//! let checker = UpdateChecker::new("my-crate", "1.0.0")
58//!     .message_url("https://example.com/my-crate-update-message.txt");
59//!
60//! if let Ok(Some(update)) = checker.check_detailed() {
61//!     eprintln!("Update available: {} -> {}", update.current, update.latest);
62//!     if let Some(msg) = &update.message {
63//!         eprintln!("{msg}");
64//!     }
65//! }
66//! ```
67//!
68//! ## `DO_NOT_TRACK` Support
69//!
70//! When the `do-not-track` feature is enabled (default), the checker respects
71//! the [`DO_NOT_TRACK`] environment variable standard. If `DO_NOT_TRACK=1` is set,
72//! update checks will return `Ok(None)` without making network requests.
73//!
74//! To disable `DO_NOT_TRACK` support, disable the feature at compile time:
75//!
76//! ```toml
77//! [dependencies]
78//! tiny-update-check = { version = "1", default-features = false, features = ["native-tls"] }
79//! ```
80//!
81//! [`DO_NOT_TRACK`]: https://consoledonottrack.com/
82
83/// Async update checking module (requires `async` feature).
84///
85/// This module provides async versions of the update checker using `reqwest`.
86#[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
97/// Trim and truncate a message body to at most [`MAX_MESSAGE_SIZE`] bytes,
98/// splitting on a valid UTF-8 char boundary.
99///
100/// Returns `None` if the input is empty or whitespace-only.
101pub(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/// Information about an available update.
118///
119/// # Stability
120///
121/// In 2.0, this struct should be marked `#[non_exhaustive]` to allow adding
122/// fields without breaking changes.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct UpdateInfo {
125    /// The currently running version.
126    pub current: String,
127    /// The latest available version on crates.io.
128    pub latest: String,
129}
130
131/// Extended update information with optional message and response data.
132///
133/// Returned by [`UpdateChecker::check_detailed`]. Contains the same version
134/// information as [`UpdateInfo`] plus additional metadata.
135#[derive(Debug, Clone, PartialEq, Eq)]
136#[non_exhaustive]
137pub struct DetailedUpdateInfo {
138    /// The currently running version.
139    pub current: String,
140    /// The latest available version on crates.io.
141    pub latest: String,
142    /// An optional message from the crate author.
143    ///
144    /// Populated when [`UpdateChecker::message_url`] is configured and the
145    /// message was successfully fetched. The message is plain text, trimmed,
146    /// and truncated to 4KB.
147    pub message: Option<String>,
148    /// The raw response body from crates.io.
149    ///
150    /// Only available when the `response-body` feature is enabled. This lets
151    /// you extract any field from the crates.io API response using your own
152    /// parsing logic.
153    ///
154    /// This is `None` when the version was served from cache.
155    #[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/// Errors that can occur during update checking.
181#[derive(Debug)]
182pub enum Error {
183    /// Failed to make HTTP request to crates.io.
184    HttpError(String),
185    /// Failed to parse response from crates.io.
186    ParseError(String),
187    /// Failed to parse version string.
188    VersionError(String),
189    /// Cache I/O error.
190    CacheError(String),
191    /// Invalid crate name provided.
192    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/// A lightweight update checker for crates.io.
210///
211/// # Example
212///
213/// ```no_run
214/// use tiny_update_check::UpdateChecker;
215///
216/// let checker = UpdateChecker::new("my-crate", "1.0.0");
217/// match checker.check() {
218///     Ok(Some(update)) => println!("Update available: {}", update.latest),
219///     Ok(None) => println!("Already on latest version"),
220///     Err(e) => eprintln!("Failed to check for updates: {}", e),
221/// }
222/// ```
223#[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    /// Create a new update checker for the given crate.
236    ///
237    /// # Arguments
238    ///
239    /// * `crate_name` - The name of your crate on crates.io
240    /// * `current_version` - The currently running version (typically from `env!("CARGO_PKG_VERSION")`)
241    #[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), // 24 hours
247            timeout: Duration::from_secs(5),
248            cache_dir: cache_dir(),
249            include_prerelease: false,
250            message_url: None,
251        }
252    }
253
254    /// Set the cache duration. Defaults to 24 hours.
255    ///
256    /// Set to `Duration::ZERO` to disable caching.
257    #[must_use]
258    pub const fn cache_duration(mut self, duration: Duration) -> Self {
259        self.cache_duration = duration;
260        self
261    }
262
263    /// Set the HTTP request timeout. Defaults to 5 seconds.
264    #[must_use]
265    pub const fn timeout(mut self, timeout: Duration) -> Self {
266        self.timeout = timeout;
267        self
268    }
269
270    /// Set a custom cache directory. Defaults to system cache directory.
271    ///
272    /// Set to `None` to disable caching.
273    #[must_use]
274    pub fn cache_dir(mut self, dir: Option<PathBuf>) -> Self {
275        self.cache_dir = dir;
276        self
277    }
278
279    /// Include pre-release versions in update checks. Defaults to `false`.
280    ///
281    /// When `false` (the default), versions like `2.0.0-alpha.1` or `2.0.0-beta`
282    /// will not be reported as available updates. Set to `true` to receive
283    /// notifications about pre-release versions.
284    #[must_use]
285    pub const fn include_prerelease(mut self, include: bool) -> Self {
286        self.include_prerelease = include;
287        self
288    }
289
290    /// Set a URL to fetch an update message from.
291    ///
292    /// When an update is available, the checker will make a separate HTTP request
293    /// to this URL and include the response as [`DetailedUpdateInfo::message`]. The URL
294    /// should serve plain text.
295    ///
296    /// The fetch is best-effort: if it fails, the update check still succeeds
297    /// with `message` set to `None`. The message is trimmed and truncated to 4KB.
298    #[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    /// Check for updates.
305    ///
306    /// Returns `Ok(Some(UpdateInfo))` if a newer version is available,
307    /// `Ok(None)` if already on the latest version (or if `DO_NOT_TRACK=1` is set
308    /// and the `do-not-track` feature is enabled),
309    /// or `Err` if the check failed.
310    ///
311    /// For additional metadata (update messages, response body), use
312    /// [`check_detailed`](Self::check_detailed) instead.
313    ///
314    /// # Stability
315    ///
316    /// In 2.0, `check` and `check_detailed` will likely be combined into a
317    /// single method returning `DetailedUpdateInfo` (with `UpdateInfo` removed).
318    ///
319    /// # Errors
320    ///
321    /// Returns an error if the crate name is invalid, the HTTP request fails,
322    /// the response cannot be parsed, or version comparison fails.
323    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    /// Check for updates with extended metadata.
336    ///
337    /// Like [`check`](Self::check), but returns [`DetailedUpdateInfo`] which
338    /// includes an optional author message and (with the `response-body`
339    /// feature) the raw crates.io response.
340    ///
341    /// # Stability
342    ///
343    /// In 2.0, `check` and `check_detailed` will likely be combined into a
344    /// single method returning `DetailedUpdateInfo` (with `UpdateInfo` removed).
345    ///
346    /// # Errors
347    ///
348    /// Returns an error if the crate name is invalid, the HTTP request fails,
349    /// the response cannot be parsed, or version comparison fails.
350    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    /// Get the latest version, using cache if available and fresh.
378    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        // Check cache first
385        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        // Fetch from crates.io
394        let (latest, response_body) = self.fetch_latest_version()?;
395
396        // Update cache
397        if let Some(ref path) = path {
398            let _ = fs::write(path, &latest);
399        }
400
401        Ok((latest, response_body))
402    }
403
404    /// Fetch the latest version from crates.io.
405    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    /// Fetch a plain text message from the configured URL.
428    ///
429    /// Best-effort: returns `None` on any failure.
430    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
442/// Compare current and latest versions, returning `UpdateInfo` if an update is available.
443pub(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
467/// Read from cache if it exists and is fresh.
468pub(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
480/// Extract the `newest_version` field from a crates.io API response.
481///
482/// Parses the JSON response and extracts `crate.newest_version`.
483pub(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/// Check if the `DO_NOT_TRACK` environment variable is set to a truthy value.
500///
501/// Returns `true` if `DO_NOT_TRACK` is set to `1` or `true` (case-insensitive).
502#[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
509/// Validate a crate name according to Cargo's rules.
510///
511/// Valid crate names must:
512/// - Be non-empty
513/// - Start with an ASCII alphabetic character
514/// - Contain only ASCII alphanumeric characters, `-`, or `_`
515/// - Be at most 64 characters long
516fn 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(); // safe: checked non-empty
531    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
548/// Returns the platform-specific user cache directory.
549///
550/// - **Linux**: `$XDG_CACHE_HOME` or `$HOME/.cache`
551/// - **macOS**: `$HOME/Library/Caches`
552/// - **Windows**: `%LOCALAPPDATA%`
553pub(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
577/// Convenience function to check for updates with default settings.
578///
579/// # Example
580///
581/// ```no_run
582/// if let Ok(Some(update)) = tiny_update_check::check("my-crate", "1.0.0") {
583///     eprintln!("Update available: {} -> {}", update.current, update.latest);
584/// }
585/// ```
586///
587/// # Errors
588///
589/// Returns an error if the update check fails.
590pub 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    // Parsing tests (moved from tests/parsing.rs)
665    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        // The "newest_version" in the versions array should be ignored;
742        // only the one inside the top-level "crate" object matters.
743        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    // DO_NOT_TRACK tests
754    #[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        // '€' is 3 bytes in UTF-8. Fill so the 4096 boundary falls mid-character.
905        let unit = "€"; // 3 bytes
906        let count = 4096 / 3 + 1; // enough to exceed 4096 bytes
907        let msg: String = unit.repeat(count);
908        let result = truncate_message(&msg).unwrap();
909        assert!(result.len() <= 4096);
910        // Must end on a valid char boundary (no panic on further use)
911        assert!(result.is_char_boundary(result.len()));
912        // Should be the largest multiple of 3 that fits
913        assert_eq!(result.len(), (4096 / 3) * 3);
914    }
915}