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 `ureq`, `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    fn build_agent(&self) -> ureq::Agent {
405        #[cfg(feature = "native-tls")]
406        let tls = ureq::tls::TlsConfig::builder()
407            .provider(ureq::tls::TlsProvider::NativeTls)
408            .build();
409        #[cfg(all(not(feature = "native-tls"), feature = "rustls"))]
410        let tls = ureq::tls::TlsConfig::builder()
411            .provider(ureq::tls::TlsProvider::Rustls)
412            .build();
413        #[cfg(not(any(feature = "native-tls", feature = "rustls")))]
414        let tls = ureq::tls::TlsConfig::default();
415
416        ureq::Agent::config_builder()
417            .timeout_global(Some(self.timeout))
418            .tls_config(tls)
419            .build()
420            .into()
421    }
422
423    /// Fetch the latest version from crates.io.
424    fn fetch_latest_version(&self) -> Result<(String, Option<String>), Error> {
425        let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
426
427        let body = self
428            .build_agent()
429            .get(&url)
430            .header("User-Agent", USER_AGENT)
431            .call()
432            .map_err(|e| Error::HttpError(e.to_string()))?
433            .body_mut()
434            .read_to_string()
435            .map_err(|e| Error::HttpError(e.to_string()))?;
436
437        let version = extract_newest_version(&body)?;
438
439        #[cfg(feature = "response-body")]
440        return Ok((version, Some(body)));
441
442        #[cfg(not(feature = "response-body"))]
443        Ok((version, None))
444    }
445
446    /// Fetch a plain text message from the configured URL.
447    ///
448    /// Best-effort: returns `None` on any failure.
449    fn fetch_message(&self, url: &str) -> Option<String> {
450        let body = self
451            .build_agent()
452            .get(url)
453            .header("User-Agent", USER_AGENT)
454            .call()
455            .ok()?
456            .body_mut()
457            .read_to_string()
458            .ok()?;
459        truncate_message(&body)
460    }
461}
462
463/// Compare current and latest versions, returning `UpdateInfo` if an update is available.
464pub(crate) fn compare_versions(
465    current_version: &str,
466    latest: String,
467    include_prerelease: bool,
468) -> Result<Option<UpdateInfo>, Error> {
469    let current = semver::Version::parse(current_version)
470        .map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
471    let latest_ver = semver::Version::parse(&latest)
472        .map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
473
474    if !include_prerelease && !latest_ver.pre.is_empty() {
475        return Ok(None);
476    }
477
478    if latest_ver > current {
479        Ok(Some(UpdateInfo {
480            current: current_version.to_string(),
481            latest,
482        }))
483    } else {
484        Ok(None)
485    }
486}
487
488/// Read from cache if it exists and is fresh.
489pub(crate) fn read_cache(path: &std::path::Path, cache_duration: Duration) -> Option<String> {
490    let metadata = fs::metadata(path).ok()?;
491    let modified = metadata.modified().ok()?;
492    let age = SystemTime::now().duration_since(modified).ok()?;
493
494    if age < cache_duration {
495        fs::read_to_string(path).ok().map(|s| s.trim().to_string())
496    } else {
497        None
498    }
499}
500
501/// Extract the `newest_version` field from a crates.io API response.
502///
503/// Parses the JSON response and extracts `crate.newest_version`.
504pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
505    let json: serde_json::Value =
506        serde_json::from_str(body).map_err(|e| Error::ParseError(e.to_string()))?;
507
508    json["crate"]["newest_version"]
509        .as_str()
510        .map(String::from)
511        .ok_or_else(|| {
512            if json.get("crate").is_none() {
513                Error::ParseError("'crate' field not found in response".to_string())
514            } else {
515                Error::ParseError("'newest_version' field not found in response".to_string())
516            }
517        })
518}
519
520/// Check if the `DO_NOT_TRACK` environment variable is set to a truthy value.
521///
522/// Returns `true` if `DO_NOT_TRACK` is set to `1` or `true` (case-insensitive).
523#[cfg(feature = "do-not-track")]
524pub(crate) fn do_not_track_enabled() -> bool {
525    std::env::var("DO_NOT_TRACK")
526        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
527        .unwrap_or(false)
528}
529
530/// Validate a crate name according to Cargo's rules.
531///
532/// Valid crate names must:
533/// - Be non-empty
534/// - Start with an ASCII alphabetic character
535/// - Contain only ASCII alphanumeric characters, `-`, or `_`
536/// - Be at most 64 characters long
537fn validate_crate_name(name: &str) -> Result<(), Error> {
538    if name.is_empty() {
539        return Err(Error::InvalidCrateName(
540            "crate name cannot be empty".to_string(),
541        ));
542    }
543
544    if name.len() > 64 {
545        return Err(Error::InvalidCrateName(format!(
546            "crate name exceeds 64 characters: {}",
547            name.len()
548        )));
549    }
550
551    let first_char = name.chars().next().unwrap(); // safe: checked non-empty
552    if !first_char.is_ascii_alphabetic() {
553        return Err(Error::InvalidCrateName(format!(
554            "crate name must start with a letter, found: '{first_char}'"
555        )));
556    }
557
558    for ch in name.chars() {
559        if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
560            return Err(Error::InvalidCrateName(format!(
561                "invalid character in crate name: '{ch}'"
562            )));
563        }
564    }
565
566    Ok(())
567}
568
569/// Returns the platform-specific user cache directory.
570///
571/// - **Linux**: `$XDG_CACHE_HOME` or `$HOME/.cache`
572/// - **macOS**: `$HOME/Library/Caches`
573/// - **Windows**: `%LOCALAPPDATA%`
574pub(crate) fn cache_dir() -> Option<PathBuf> {
575    #[cfg(target_os = "macos")]
576    {
577        std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
578    }
579
580    #[cfg(target_os = "linux")]
581    {
582        std::env::var_os("XDG_CACHE_HOME")
583            .map(PathBuf::from)
584            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
585    }
586
587    #[cfg(target_os = "windows")]
588    {
589        std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
590    }
591
592    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
593    {
594        None
595    }
596}
597
598/// Convenience function to check for updates with default settings.
599///
600/// # Example
601///
602/// ```no_run
603/// if let Ok(Some(update)) = tiny_update_check::check("my-crate", "1.0.0") {
604///     eprintln!("Update available: {} -> {}", update.current, update.latest);
605/// }
606/// ```
607///
608/// # Errors
609///
610/// Returns an error if the update check fails.
611pub fn check(
612    crate_name: impl Into<String>,
613    current_version: impl Into<String>,
614) -> Result<Option<UpdateInfo>, Error> {
615    UpdateChecker::new(crate_name, current_version).check()
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_update_info_display() {
624        let info = UpdateInfo {
625            current: "1.0.0".to_string(),
626            latest: "2.0.0".to_string(),
627        };
628        assert_eq!(info.current, "1.0.0");
629        assert_eq!(info.latest, "2.0.0");
630    }
631
632    #[test]
633    fn test_checker_builder() {
634        let checker = UpdateChecker::new("test-crate", "1.0.0")
635            .cache_duration(Duration::from_secs(3600))
636            .timeout(Duration::from_secs(10));
637
638        assert_eq!(checker.crate_name, "test-crate");
639        assert_eq!(checker.current_version, "1.0.0");
640        assert_eq!(checker.cache_duration, Duration::from_secs(3600));
641        assert_eq!(checker.timeout, Duration::from_secs(10));
642        assert!(checker.message_url.is_none());
643    }
644
645    #[test]
646    fn test_cache_disabled() {
647        let checker = UpdateChecker::new("test-crate", "1.0.0")
648            .cache_duration(Duration::ZERO)
649            .cache_dir(None);
650
651        assert_eq!(checker.cache_duration, Duration::ZERO);
652        assert!(checker.cache_dir.is_none());
653    }
654
655    #[test]
656    fn test_error_display() {
657        let err = Error::HttpError("connection failed".to_string());
658        assert_eq!(err.to_string(), "HTTP error: connection failed");
659
660        let err = Error::ParseError("invalid json".to_string());
661        assert_eq!(err.to_string(), "Parse error: invalid json");
662
663        let err = Error::InvalidCrateName("empty".to_string());
664        assert_eq!(err.to_string(), "Invalid crate name: empty");
665    }
666
667    #[test]
668    fn test_include_prerelease_default() {
669        let checker = UpdateChecker::new("test-crate", "1.0.0");
670        assert!(!checker.include_prerelease);
671    }
672
673    #[test]
674    fn test_include_prerelease_enabled() {
675        let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
676        assert!(checker.include_prerelease);
677    }
678
679    #[test]
680    fn test_include_prerelease_disabled() {
681        let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
682        assert!(!checker.include_prerelease);
683    }
684
685    // Parsing tests (moved from tests/parsing.rs)
686    const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
687    const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
688    const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
689    const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
690    const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
691    const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
692    const ESCAPED_CHARS: &str = include_str!("../tests/fixtures/escaped_chars.json");
693    const NESTED_VERSION: &str = include_str!("../tests/fixtures/nested_version.json");
694    const NULL_VERSION: &str = include_str!("../tests/fixtures/null_version.json");
695
696    #[test]
697    fn parses_real_crates_io_response() {
698        let version = extract_newest_version(REAL_RESPONSE).unwrap();
699        assert_eq!(version, "1.0.228");
700    }
701
702    #[test]
703    fn parses_compact_json() {
704        let version = extract_newest_version(COMPACT_JSON).unwrap();
705        assert_eq!(version, "2.0.0");
706    }
707
708    #[test]
709    fn parses_pretty_json() {
710        let version = extract_newest_version(PRETTY_JSON).unwrap();
711        assert_eq!(version, "3.1.4");
712    }
713
714    #[test]
715    fn parses_whitespace_around_colon() {
716        let version = extract_newest_version(SPACED_COLON).unwrap();
717        assert_eq!(version, "1.2.3");
718    }
719
720    #[test]
721    fn fails_on_missing_crate_field() {
722        let result = extract_newest_version(MISSING_CRATE);
723        assert!(result.is_err());
724        let err = result.unwrap_err().to_string();
725        assert!(
726            err.contains("crate"),
727            "Error should mention 'crate' field: {err}"
728        );
729    }
730
731    #[test]
732    fn fails_on_missing_newest_version() {
733        let result = extract_newest_version(MISSING_VERSION);
734        assert!(result.is_err());
735        let err = result.unwrap_err().to_string();
736        assert!(
737            err.contains("newest_version"),
738            "Error should mention 'newest_version' field: {err}"
739        );
740    }
741
742    #[test]
743    fn fails_on_empty_input() {
744        let result = extract_newest_version("");
745        assert!(result.is_err());
746    }
747
748    #[test]
749    fn fails_on_malformed_json() {
750        let result = extract_newest_version("not json at all");
751        assert!(result.is_err());
752    }
753
754    #[test]
755    fn parses_json_with_escaped_characters() {
756        let version = extract_newest_version(ESCAPED_CHARS).unwrap();
757        assert_eq!(version, "4.0.0");
758    }
759
760    #[test]
761    fn parses_version_from_crate_object_not_versions_array() {
762        // The "newest_version" in the versions array should be ignored;
763        // only the one inside the top-level "crate" object matters.
764        let version = extract_newest_version(NESTED_VERSION).unwrap();
765        assert_eq!(version, "5.0.0");
766    }
767
768    #[test]
769    fn fails_on_null_version() {
770        let result = extract_newest_version(NULL_VERSION);
771        assert!(result.is_err());
772    }
773
774    // DO_NOT_TRACK tests
775    #[cfg(feature = "do-not-track")]
776    mod do_not_track_tests {
777        use super::*;
778
779        #[test]
780        fn do_not_track_detects_1() {
781            temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
782                assert!(do_not_track_enabled());
783            });
784        }
785
786        #[test]
787        fn do_not_track_detects_true() {
788            temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
789                assert!(do_not_track_enabled());
790            });
791        }
792
793        #[test]
794        fn do_not_track_detects_true_case_insensitive() {
795            temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
796                assert!(do_not_track_enabled());
797            });
798        }
799
800        #[test]
801        fn do_not_track_ignores_other_values() {
802            temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
803                assert!(!do_not_track_enabled());
804            });
805            temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
806                assert!(!do_not_track_enabled());
807            });
808            temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
809                assert!(!do_not_track_enabled());
810            });
811        }
812
813        #[test]
814        fn do_not_track_disabled_when_unset() {
815            temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
816                assert!(!do_not_track_enabled());
817            });
818        }
819    }
820
821    #[test]
822    fn test_message_url_default() {
823        let checker = UpdateChecker::new("test-crate", "1.0.0");
824        assert!(checker.message_url.is_none());
825    }
826
827    #[test]
828    fn test_message_url_builder() {
829        let checker = UpdateChecker::new("test-crate", "1.0.0")
830            .message_url("https://example.com/message.txt");
831        assert_eq!(
832            checker.message_url.as_deref(),
833            Some("https://example.com/message.txt")
834        );
835    }
836
837    #[test]
838    fn test_message_url_chainable() {
839        let checker = UpdateChecker::new("test-crate", "1.0.0")
840            .cache_duration(Duration::from_secs(3600))
841            .message_url("https://example.com/msg.txt")
842            .timeout(Duration::from_secs(10));
843        assert_eq!(
844            checker.message_url.as_deref(),
845            Some("https://example.com/msg.txt")
846        );
847        assert_eq!(checker.timeout, Duration::from_secs(10));
848    }
849
850    #[test]
851    fn test_compare_versions_returns_none_message() {
852        let result = compare_versions("1.0.0", "2.0.0".to_string(), false)
853            .unwrap()
854            .unwrap();
855        assert_eq!(result.current, "1.0.0");
856        assert_eq!(result.latest, "2.0.0");
857    }
858
859    #[test]
860    fn test_detailed_update_info_with_message() {
861        let info = DetailedUpdateInfo {
862            current: "1.0.0".to_string(),
863            latest: "2.0.0".to_string(),
864            message: Some("Please update!".to_string()),
865            #[cfg(feature = "response-body")]
866            response_body: None,
867        };
868        assert_eq!(info.message.as_deref(), Some("Please update!"));
869    }
870
871    #[cfg(feature = "response-body")]
872    #[test]
873    fn test_detailed_update_info_with_response_body() {
874        let info = DetailedUpdateInfo {
875            current: "1.0.0".to_string(),
876            latest: "2.0.0".to_string(),
877            message: None,
878            response_body: Some("{\"crate\":{}}".to_string()),
879        };
880        assert_eq!(info.response_body.as_deref(), Some("{\"crate\":{}}"));
881    }
882
883    #[test]
884    fn test_truncate_message_empty() {
885        assert_eq!(truncate_message(""), None);
886    }
887
888    #[test]
889    fn test_truncate_message_whitespace_only() {
890        assert_eq!(truncate_message("   \n\t  "), None);
891    }
892
893    #[test]
894    fn test_truncate_message_ascii_within_limit() {
895        assert_eq!(
896            truncate_message("hello world"),
897            Some("hello world".to_string())
898        );
899    }
900
901    #[test]
902    fn test_truncate_message_trims_whitespace() {
903        assert_eq!(
904            truncate_message("  hello world  \n"),
905            Some("hello world".to_string())
906        );
907    }
908
909    #[test]
910    fn test_truncate_message_exactly_at_limit() {
911        let msg = "a".repeat(4096);
912        let result = truncate_message(&msg).unwrap();
913        assert_eq!(result.len(), 4096);
914    }
915
916    #[test]
917    fn test_truncate_message_ascii_over_limit() {
918        let msg = "a".repeat(5000);
919        let result = truncate_message(&msg).unwrap();
920        assert_eq!(result.len(), 4096);
921    }
922
923    #[test]
924    fn test_truncate_message_multibyte_at_boundary() {
925        // '€' is 3 bytes in UTF-8. Fill so the 4096 boundary falls mid-character.
926        let unit = "€"; // 3 bytes
927        let count = 4096 / 3 + 1; // enough to exceed 4096 bytes
928        let msg: String = unit.repeat(count);
929        let result = truncate_message(&msg).unwrap();
930        assert!(result.len() <= 4096);
931        // Must end on a valid char boundary (no panic on further use)
932        assert!(result.is_char_boundary(result.len()));
933        // Should be the largest multiple of 3 that fits
934        assert_eq!(result.len(), (4096 / 3) * 3);
935    }
936}