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` and `semver` (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/// This function parses the JSON response without requiring a full JSON parser,
483/// handling various whitespace formats that the API might return.
484pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
485    // Find the "crate" object first to ensure we're in the right context
486    let crate_start = body
487        .find(r#""crate""#)
488        .ok_or_else(|| Error::ParseError("'crate' field not found in response".to_string()))?;
489
490    // Search from the crate field onward
491    let search_region = &body[crate_start..];
492
493    // Find "newest_version" within the crate object
494    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    // Move past the key
500    let after_key = &search_region[key_pos + version_key.len()..];
501
502    // Find the colon (handles optional whitespace)
503    let colon_pos = after_key.find(':').ok_or_else(|| {
504        Error::ParseError("malformed JSON: missing colon after newest_version".to_string())
505    })?;
506
507    // Move past the colon and any whitespace
508    let after_colon = &after_key[colon_pos + 1..];
509    let after_colon_trimmed = after_colon.trim_start();
510
511    // Find the opening quote
512    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    // Extract the version string (everything until the closing quote)
519    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/// Check if the `DO_NOT_TRACK` environment variable is set to a truthy value.
528///
529/// Returns `true` if `DO_NOT_TRACK` is set to `1` or `true` (case-insensitive).
530#[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
537/// Validate a crate name according to Cargo's rules.
538///
539/// Valid crate names must:
540/// - Be non-empty
541/// - Start with an ASCII alphabetic character
542/// - Contain only ASCII alphanumeric characters, `-`, or `_`
543/// - Be at most 64 characters long
544fn 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(); // safe: checked non-empty
559    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
576/// Returns the platform-specific user cache directory.
577///
578/// - **Linux**: `$XDG_CACHE_HOME` or `$HOME/.cache`
579/// - **macOS**: `$HOME/Library/Caches`
580/// - **Windows**: `%LOCALAPPDATA%`
581pub(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
605/// Convenience function to check for updates with default settings.
606///
607/// # Example
608///
609/// ```no_run
610/// if let Ok(Some(update)) = tiny_update_check::check("my-crate", "1.0.0") {
611///     eprintln!("Update available: {} -> {}", update.current, update.latest);
612/// }
613/// ```
614///
615/// # Errors
616///
617/// Returns an error if the update check fails.
618pub 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    // Parsing tests (moved from tests/parsing.rs)
693    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    // DO_NOT_TRACK tests
759    #[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        // '€' is 3 bytes in UTF-8. Fill so the 4096 boundary falls mid-character.
910        let unit = "€"; // 3 bytes
911        let count = 4096 / 3 + 1; // enough to exceed 4096 bytes
912        let msg: String = unit.repeat(count);
913        let result = truncate_message(&msg).unwrap();
914        assert!(result.len() <= 4096);
915        // Must end on a valid char boundary (no panic on further use)
916        assert!(result.is_char_boundary(result.len()));
917        // Should be the largest multiple of 3 that fits
918        assert_eq!(result.len(), (4096 / 3) * 3);
919    }
920}