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 `minreq` + system TLS. Smallest binary (~540 KB).
44//! - `rustls`: Uses `ureq` + rustls/ring. Pure-Rust TLS, no system dependencies.
45//!   Uses `ureq` rather than `minreq` because `minreq`'s rustls backend pulls in `aws-lc-rs`,
46//!   adding ~1.7 MB. `ureq`'s rustls uses `ring`, keeping the binary small.
47//! - `async`: Enables async support using `reqwest`
48//! - `do-not-track` (default): Respects [`DO_NOT_TRACK`] environment variable
49//! - `response-body`: Includes the raw crates.io response body in [`DetailedUpdateInfo`]
50//!
51//! ## Update Messages
52//!
53//! You can attach a message to update notifications by hosting a plain text file
54//! at a URL and configuring the checker with [`UpdateChecker::message_url`]:
55//!
56//! ```no_run
57//! use tiny_update_check::UpdateChecker;
58//!
59//! let checker = UpdateChecker::new("my-crate", "1.0.0")
60//!     .message_url("https://example.com/my-crate-update-message.txt");
61//!
62//! if let Ok(Some(update)) = checker.check_detailed() {
63//!     eprintln!("Update available: {} -> {}", update.current, update.latest);
64//!     if let Some(msg) = &update.message {
65//!         eprintln!("{msg}");
66//!     }
67//! }
68//! ```
69//!
70//! ## `DO_NOT_TRACK` Support
71//!
72//! When the `do-not-track` feature is enabled (default), the checker respects
73//! the [`DO_NOT_TRACK`] environment variable standard. If `DO_NOT_TRACK=1` is set,
74//! update checks will return `Ok(None)` without making network requests.
75//!
76//! To disable `DO_NOT_TRACK` support, disable the feature at compile time:
77//!
78//! ```toml
79//! [dependencies]
80//! tiny-update-check = { version = "1", default-features = false, features = ["native-tls"] }
81//! ```
82//!
83//! [`DO_NOT_TRACK`]: https://consoledonottrack.com/
84
85/// Async update checking module (requires `async` feature).
86///
87/// This module provides async versions of the update checker using `reqwest`.
88#[cfg(feature = "async")]
89pub mod r#async;
90
91use std::fs;
92use std::path::PathBuf;
93use std::time::{Duration, SystemTime};
94
95#[cfg(not(any(feature = "native-tls", feature = "rustls")))]
96compile_error!("At least one TLS feature must be enabled: `native-tls` or `rustls`");
97
98pub(crate) const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
99
100const MAX_MESSAGE_SIZE: usize = 4096;
101
102/// Trim and truncate a message body to at most [`MAX_MESSAGE_SIZE`] bytes,
103/// splitting on a valid UTF-8 char boundary.
104///
105/// Returns `None` if the input is empty or whitespace-only.
106pub(crate) fn truncate_message(text: &str) -> Option<String> {
107    let trimmed = text.trim();
108    if trimmed.is_empty() {
109        return None;
110    }
111    if trimmed.len() > MAX_MESSAGE_SIZE {
112        let mut end = MAX_MESSAGE_SIZE;
113        while !trimmed.is_char_boundary(end) {
114            end -= 1;
115        }
116        Some(trimmed[..end].to_string())
117    } else {
118        Some(trimmed.to_string())
119    }
120}
121
122/// Information about an available update.
123///
124/// # Stability
125///
126/// In 2.0, this struct should be marked `#[non_exhaustive]` to allow adding
127/// fields without breaking changes.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct UpdateInfo {
130    /// The currently running version.
131    pub current: String,
132    /// The latest available version on crates.io.
133    pub latest: String,
134}
135
136/// Extended update information with optional message and response data.
137///
138/// Returned by [`UpdateChecker::check_detailed`]. Contains the same version
139/// information as [`UpdateInfo`] plus additional metadata.
140#[derive(Debug, Clone, PartialEq, Eq)]
141#[non_exhaustive]
142pub struct DetailedUpdateInfo {
143    /// The currently running version.
144    pub current: String,
145    /// The latest available version on crates.io.
146    pub latest: String,
147    /// An optional message from the crate author.
148    ///
149    /// Populated when [`UpdateChecker::message_url`] is configured and the
150    /// message was successfully fetched. The message is plain text, trimmed,
151    /// and truncated to 4KB.
152    pub message: Option<String>,
153    /// The raw response body from crates.io.
154    ///
155    /// Only available when the `response-body` feature is enabled. This lets
156    /// you extract any field from the crates.io API response using your own
157    /// parsing logic.
158    ///
159    /// This is `None` when the version was served from cache.
160    #[cfg(feature = "response-body")]
161    pub response_body: Option<String>,
162}
163
164impl From<UpdateInfo> for DetailedUpdateInfo {
165    fn from(info: UpdateInfo) -> Self {
166        Self {
167            current: info.current,
168            latest: info.latest,
169            message: None,
170            #[cfg(feature = "response-body")]
171            response_body: None,
172        }
173    }
174}
175
176impl From<DetailedUpdateInfo> for UpdateInfo {
177    fn from(info: DetailedUpdateInfo) -> Self {
178        Self {
179            current: info.current,
180            latest: info.latest,
181        }
182    }
183}
184
185/// Errors that can occur during update checking.
186#[derive(Debug)]
187pub enum Error {
188    /// Failed to make HTTP request to crates.io.
189    HttpError(String),
190    /// Failed to parse response from crates.io.
191    ParseError(String),
192    /// Failed to parse version string.
193    VersionError(String),
194    /// Cache I/O error.
195    CacheError(String),
196    /// Invalid crate name provided.
197    InvalidCrateName(String),
198}
199
200impl std::fmt::Display for Error {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        match self {
203            Self::HttpError(msg) => write!(f, "HTTP error: {msg}"),
204            Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
205            Self::VersionError(msg) => write!(f, "Version error: {msg}"),
206            Self::CacheError(msg) => write!(f, "Cache error: {msg}"),
207            Self::InvalidCrateName(msg) => write!(f, "Invalid crate name: {msg}"),
208        }
209    }
210}
211
212impl std::error::Error for Error {}
213
214/// A lightweight update checker for crates.io.
215///
216/// # Example
217///
218/// ```no_run
219/// use tiny_update_check::UpdateChecker;
220///
221/// let checker = UpdateChecker::new("my-crate", "1.0.0");
222/// match checker.check() {
223///     Ok(Some(update)) => println!("Update available: {}", update.latest),
224///     Ok(None) => println!("Already on latest version"),
225///     Err(e) => eprintln!("Failed to check for updates: {}", e),
226/// }
227/// ```
228#[derive(Debug, Clone)]
229pub struct UpdateChecker {
230    crate_name: String,
231    current_version: String,
232    cache_duration: Duration,
233    timeout: Duration,
234    cache_dir: Option<PathBuf>,
235    include_prerelease: bool,
236    message_url: Option<String>,
237}
238
239impl UpdateChecker {
240    /// Create a new update checker for the given crate.
241    ///
242    /// # Arguments
243    ///
244    /// * `crate_name` - The name of your crate on crates.io
245    /// * `current_version` - The currently running version (typically from `env!("CARGO_PKG_VERSION")`)
246    #[must_use]
247    pub fn new(crate_name: impl Into<String>, current_version: impl Into<String>) -> Self {
248        Self {
249            crate_name: crate_name.into(),
250            current_version: current_version.into(),
251            cache_duration: Duration::from_secs(24 * 60 * 60), // 24 hours
252            timeout: Duration::from_secs(5),
253            cache_dir: cache_dir(),
254            include_prerelease: false,
255            message_url: None,
256        }
257    }
258
259    /// Set the cache duration. Defaults to 24 hours.
260    ///
261    /// Set to `Duration::ZERO` to disable caching.
262    #[must_use]
263    pub const fn cache_duration(mut self, duration: Duration) -> Self {
264        self.cache_duration = duration;
265        self
266    }
267
268    /// Set the HTTP request timeout. Defaults to 5 seconds.
269    #[must_use]
270    pub const fn timeout(mut self, timeout: Duration) -> Self {
271        self.timeout = timeout;
272        self
273    }
274
275    /// Set a custom cache directory. Defaults to system cache directory.
276    ///
277    /// Set to `None` to disable caching.
278    #[must_use]
279    pub fn cache_dir(mut self, dir: Option<PathBuf>) -> Self {
280        self.cache_dir = dir;
281        self
282    }
283
284    /// Include pre-release versions in update checks. Defaults to `false`.
285    ///
286    /// When `false` (the default), versions like `2.0.0-alpha.1` or `2.0.0-beta`
287    /// will not be reported as available updates. Set to `true` to receive
288    /// notifications about pre-release versions.
289    #[must_use]
290    pub const fn include_prerelease(mut self, include: bool) -> Self {
291        self.include_prerelease = include;
292        self
293    }
294
295    /// Set a URL to fetch an update message from.
296    ///
297    /// When an update is available, the checker will make a separate HTTP request
298    /// to this URL and include the response as [`DetailedUpdateInfo::message`]. The URL
299    /// should serve plain text.
300    ///
301    /// The fetch is best-effort: if it fails, the update check still succeeds
302    /// with `message` set to `None`. The message is trimmed and truncated to 4KB.
303    #[must_use]
304    pub fn message_url(mut self, url: impl Into<String>) -> Self {
305        self.message_url = Some(url.into());
306        self
307    }
308
309    /// Check for updates.
310    ///
311    /// Returns `Ok(Some(UpdateInfo))` if a newer version is available,
312    /// `Ok(None)` if already on the latest version (or if `DO_NOT_TRACK=1` is set
313    /// and the `do-not-track` feature is enabled),
314    /// or `Err` if the check failed.
315    ///
316    /// For additional metadata (update messages, response body), use
317    /// [`check_detailed`](Self::check_detailed) instead.
318    ///
319    /// # Stability
320    ///
321    /// In 2.0, `check` and `check_detailed` will likely be combined into a
322    /// single method returning `DetailedUpdateInfo` (with `UpdateInfo` removed).
323    ///
324    /// # Errors
325    ///
326    /// Returns an error if the crate name is invalid, the HTTP request fails,
327    /// the response cannot be parsed, or version comparison fails.
328    pub fn check(&self) -> Result<Option<UpdateInfo>, Error> {
329        #[cfg(feature = "do-not-track")]
330        if do_not_track_enabled() {
331            return Ok(None);
332        }
333
334        validate_crate_name(&self.crate_name)?;
335        let (latest, _) = self.get_latest_version()?;
336
337        compare_versions(&self.current_version, latest, self.include_prerelease)
338    }
339
340    /// Check for updates with extended metadata.
341    ///
342    /// Like [`check`](Self::check), but returns [`DetailedUpdateInfo`] which
343    /// includes an optional author message and (with the `response-body`
344    /// feature) the raw crates.io response.
345    ///
346    /// # Stability
347    ///
348    /// In 2.0, `check` and `check_detailed` will likely be combined into a
349    /// single method returning `DetailedUpdateInfo` (with `UpdateInfo` removed).
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if the crate name is invalid, the HTTP request fails,
354    /// the response cannot be parsed, or version comparison fails.
355    pub fn check_detailed(&self) -> Result<Option<DetailedUpdateInfo>, Error> {
356        #[cfg(feature = "do-not-track")]
357        if do_not_track_enabled() {
358            return Ok(None);
359        }
360
361        validate_crate_name(&self.crate_name)?;
362        #[cfg(feature = "response-body")]
363        let (latest, response_body) = self.get_latest_version()?;
364        #[cfg(not(feature = "response-body"))]
365        let (latest, _) = self.get_latest_version()?;
366
367        let update = compare_versions(&self.current_version, latest, self.include_prerelease)?;
368
369        Ok(update.map(|info| {
370            let mut detailed = DetailedUpdateInfo::from(info);
371            if let Some(ref url) = self.message_url {
372                detailed.message = self.fetch_message(url);
373            }
374            #[cfg(feature = "response-body")]
375            {
376                detailed.response_body = response_body;
377            }
378            detailed
379        }))
380    }
381
382    /// Get the latest version, using cache if available and fresh.
383    fn get_latest_version(&self) -> Result<(String, Option<String>), Error> {
384        let path = self
385            .cache_dir
386            .as_ref()
387            .map(|d| d.join(format!("{}-update-check", self.crate_name)));
388
389        // Check cache first
390        if self.cache_duration > Duration::ZERO {
391            if let Some(ref path) = path {
392                if let Some(cached) = read_cache(path, self.cache_duration) {
393                    return Ok((cached, None));
394                }
395            }
396        }
397
398        // Fetch from crates.io
399        let (latest, response_body) = self.fetch_latest_version()?;
400
401        // Update cache
402        if let Some(ref path) = path {
403            let _ = fs::write(path, &latest);
404        }
405
406        Ok((latest, response_body))
407    }
408
409    /// Build a ureq agent with the configured timeout.
410    ///
411    /// ureq is used for the `rustls` feature because its rustls backend uses ring
412    /// rather than aws-lc-rs, avoiding the ~1.7 MB binary size increase that
413    /// minreq's https-rustls feature would add.
414    #[cfg(feature = "rustls")]
415    fn build_ureq_agent(&self) -> ureq::Agent {
416        ureq::Agent::config_builder()
417            .timeout_global(Some(self.timeout))
418            .build()
419            .into()
420    }
421
422    /// Fetch the latest version from crates.io.
423    fn fetch_latest_version(&self) -> Result<(String, Option<String>), Error> {
424        let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
425
426        // rustls uses ureq (ring-based, small binary); native-tls uses minreq (system TLS, smallest binary).
427        // See Cargo.toml for why the two features use different HTTP clients.
428        #[cfg(feature = "rustls")]
429        let body = self
430            .build_ureq_agent()
431            .get(&url)
432            .header("User-Agent", USER_AGENT)
433            .call()
434            .map_err(|e| Error::HttpError(e.to_string()))?
435            .body_mut()
436            .read_to_string()
437            .map_err(|e| Error::HttpError(e.to_string()))?;
438
439        #[cfg(not(feature = "rustls"))]
440        let body = {
441            let response = minreq::get(&url)
442                .with_timeout(self.timeout.as_secs())
443                .with_header("User-Agent", USER_AGENT)
444                .send()
445                .map_err(|e| Error::HttpError(e.to_string()))?;
446            response
447                .as_str()
448                .map_err(|e| Error::HttpError(e.to_string()))?
449                .to_string()
450        };
451
452        let version = extract_newest_version(&body)?;
453
454        #[cfg(feature = "response-body")]
455        return Ok((version, Some(body)));
456
457        #[cfg(not(feature = "response-body"))]
458        Ok((version, None))
459    }
460
461    /// Fetch a plain text message from the configured URL.
462    ///
463    /// Best-effort: returns `None` on any failure.
464    fn fetch_message(&self, url: &str) -> Option<String> {
465        // Same client split as fetch_latest_version — see Cargo.toml for rationale.
466        #[cfg(feature = "rustls")]
467        let body = self
468            .build_ureq_agent()
469            .get(url)
470            .header("User-Agent", USER_AGENT)
471            .call()
472            .ok()?
473            .body_mut()
474            .read_to_string()
475            .ok()?;
476
477        #[cfg(not(feature = "rustls"))]
478        let body = {
479            let response = minreq::get(url)
480                .with_timeout(self.timeout.as_secs())
481                .with_header("User-Agent", USER_AGENT)
482                .send()
483                .ok()?;
484            response.as_str().ok()?.to_string()
485        };
486
487        truncate_message(&body)
488    }
489}
490
491/// Compare current and latest versions, returning `UpdateInfo` if an update is available.
492pub(crate) fn compare_versions(
493    current_version: &str,
494    latest: String,
495    include_prerelease: bool,
496) -> Result<Option<UpdateInfo>, Error> {
497    let current = semver::Version::parse(current_version)
498        .map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
499    let latest_ver = semver::Version::parse(&latest)
500        .map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
501
502    if !include_prerelease && !latest_ver.pre.is_empty() {
503        return Ok(None);
504    }
505
506    if latest_ver > current {
507        Ok(Some(UpdateInfo {
508            current: current_version.to_string(),
509            latest,
510        }))
511    } else {
512        Ok(None)
513    }
514}
515
516/// Read from cache if it exists and is fresh.
517pub(crate) fn read_cache(path: &std::path::Path, cache_duration: Duration) -> Option<String> {
518    let metadata = fs::metadata(path).ok()?;
519    let modified = metadata.modified().ok()?;
520    let age = SystemTime::now().duration_since(modified).ok()?;
521
522    if age < cache_duration {
523        fs::read_to_string(path).ok().map(|s| s.trim().to_string())
524    } else {
525        None
526    }
527}
528
529/// Extract the `newest_version` field from a crates.io API response.
530///
531/// Parses the JSON response and extracts `crate.newest_version`.
532pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
533    let json: serde_json::Value =
534        serde_json::from_str(body).map_err(|e| Error::ParseError(e.to_string()))?;
535
536    json["crate"]["newest_version"]
537        .as_str()
538        .map(String::from)
539        .ok_or_else(|| {
540            if json.get("crate").is_none() {
541                Error::ParseError("'crate' field not found in response".to_string())
542            } else {
543                Error::ParseError("'newest_version' field not found in response".to_string())
544            }
545        })
546}
547
548/// Check if the `DO_NOT_TRACK` environment variable is set to a truthy value.
549///
550/// Returns `true` if `DO_NOT_TRACK` is set to `1` or `true` (case-insensitive).
551#[cfg(feature = "do-not-track")]
552pub(crate) fn do_not_track_enabled() -> bool {
553    std::env::var("DO_NOT_TRACK")
554        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
555        .unwrap_or(false)
556}
557
558/// Validate a crate name according to Cargo's rules.
559///
560/// Valid crate names must:
561/// - Be non-empty
562/// - Start with an ASCII alphabetic character
563/// - Contain only ASCII alphanumeric characters, `-`, or `_`
564/// - Be at most 64 characters long
565fn validate_crate_name(name: &str) -> Result<(), Error> {
566    if name.is_empty() {
567        return Err(Error::InvalidCrateName(
568            "crate name cannot be empty".to_string(),
569        ));
570    }
571
572    if name.len() > 64 {
573        return Err(Error::InvalidCrateName(format!(
574            "crate name exceeds 64 characters: {}",
575            name.len()
576        )));
577    }
578
579    let first_char = name.chars().next().unwrap(); // safe: checked non-empty
580    if !first_char.is_ascii_alphabetic() {
581        return Err(Error::InvalidCrateName(format!(
582            "crate name must start with a letter, found: '{first_char}'"
583        )));
584    }
585
586    for ch in name.chars() {
587        if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
588            return Err(Error::InvalidCrateName(format!(
589                "invalid character in crate name: '{ch}'"
590            )));
591        }
592    }
593
594    Ok(())
595}
596
597/// Returns the platform-specific user cache directory.
598///
599/// - **Linux**: `$XDG_CACHE_HOME` or `$HOME/.cache`
600/// - **macOS**: `$HOME/Library/Caches`
601/// - **Windows**: `%LOCALAPPDATA%`
602pub(crate) fn cache_dir() -> Option<PathBuf> {
603    #[cfg(target_os = "macos")]
604    {
605        std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
606    }
607
608    #[cfg(target_os = "linux")]
609    {
610        std::env::var_os("XDG_CACHE_HOME")
611            .map(PathBuf::from)
612            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
613    }
614
615    #[cfg(target_os = "windows")]
616    {
617        std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
618    }
619
620    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
621    {
622        None
623    }
624}
625
626/// Convenience function to check for updates with default settings.
627///
628/// # Example
629///
630/// ```no_run
631/// if let Ok(Some(update)) = tiny_update_check::check("my-crate", "1.0.0") {
632///     eprintln!("Update available: {} -> {}", update.current, update.latest);
633/// }
634/// ```
635///
636/// # Errors
637///
638/// Returns an error if the update check fails.
639pub fn check(
640    crate_name: impl Into<String>,
641    current_version: impl Into<String>,
642) -> Result<Option<UpdateInfo>, Error> {
643    UpdateChecker::new(crate_name, current_version).check()
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    use std::fs;
650
651    #[test]
652    fn test_update_info_display() {
653        let info = UpdateInfo {
654            current: "1.0.0".to_string(),
655            latest: "2.0.0".to_string(),
656        };
657        assert_eq!(info.current, "1.0.0");
658        assert_eq!(info.latest, "2.0.0");
659    }
660
661    #[test]
662    fn test_checker_builder() {
663        let checker = UpdateChecker::new("test-crate", "1.0.0")
664            .cache_duration(Duration::from_secs(3600))
665            .timeout(Duration::from_secs(10));
666
667        assert_eq!(checker.crate_name, "test-crate");
668        assert_eq!(checker.current_version, "1.0.0");
669        assert_eq!(checker.cache_duration, Duration::from_secs(3600));
670        assert_eq!(checker.timeout, Duration::from_secs(10));
671        assert!(checker.message_url.is_none());
672    }
673
674    #[test]
675    fn test_cache_disabled() {
676        let checker = UpdateChecker::new("test-crate", "1.0.0")
677            .cache_duration(Duration::ZERO)
678            .cache_dir(None);
679
680        assert_eq!(checker.cache_duration, Duration::ZERO);
681        assert!(checker.cache_dir.is_none());
682    }
683
684    #[test]
685    fn test_error_display() {
686        let err = Error::HttpError("connection failed".to_string());
687        assert_eq!(err.to_string(), "HTTP error: connection failed");
688
689        let err = Error::ParseError("invalid json".to_string());
690        assert_eq!(err.to_string(), "Parse error: invalid json");
691
692        let err = Error::InvalidCrateName("empty".to_string());
693        assert_eq!(err.to_string(), "Invalid crate name: empty");
694
695        let err = Error::VersionError("bad semver".to_string());
696        assert_eq!(err.to_string(), "Version error: bad semver");
697
698        let err = Error::CacheError("permission denied".to_string());
699        assert_eq!(err.to_string(), "Cache error: permission denied");
700    }
701
702    #[test]
703    fn test_from_update_info_to_detailed() {
704        let info = UpdateInfo {
705            current: "1.0.0".to_string(),
706            latest: "2.0.0".to_string(),
707        };
708        let detailed = DetailedUpdateInfo::from(info);
709        assert_eq!(detailed.current, "1.0.0");
710        assert_eq!(detailed.latest, "2.0.0");
711        assert!(detailed.message.is_none());
712    }
713
714    #[test]
715    fn test_from_detailed_to_update_info() {
716        let info = UpdateInfo {
717            current: "1.0.0".to_string(),
718            latest: "2.0.0".to_string(),
719        };
720        let mut detailed = DetailedUpdateInfo::from(info);
721        detailed.message = Some("please upgrade".to_string());
722        let info = UpdateInfo::from(detailed);
723        assert_eq!(info.current, "1.0.0");
724        assert_eq!(info.latest, "2.0.0");
725    }
726
727    #[test]
728    fn compare_versions_rejects_invalid_current() {
729        let err = compare_versions("not-semver", "1.0.0".to_string(), false).unwrap_err();
730        assert!(matches!(err, Error::VersionError(_)));
731    }
732
733    #[test]
734    fn compare_versions_rejects_invalid_latest() {
735        let err = compare_versions("1.0.0", "not-semver".to_string(), false).unwrap_err();
736        assert!(matches!(err, Error::VersionError(_)));
737    }
738
739    #[test]
740    fn read_cache_returns_none_for_expired_entry() {
741        let dir = tempfile::tempdir().unwrap();
742        let path = dir.path().join("test-cache");
743        fs::write(&path, "1.2.3").unwrap();
744
745        // Zero duration means any age is expired
746        let result = read_cache(&path, Duration::ZERO);
747        assert!(result.is_none());
748    }
749
750    #[test]
751    fn read_cache_returns_value_when_fresh() {
752        let dir = tempfile::tempdir().unwrap();
753        let path = dir.path().join("test-cache");
754        fs::write(&path, "  1.2.3  ").unwrap();
755
756        let result = read_cache(&path, Duration::from_secs(3600));
757        assert_eq!(result.unwrap(), "1.2.3");
758    }
759
760    #[test]
761    fn test_include_prerelease_default() {
762        let checker = UpdateChecker::new("test-crate", "1.0.0");
763        assert!(!checker.include_prerelease);
764    }
765
766    #[test]
767    fn test_include_prerelease_enabled() {
768        let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
769        assert!(checker.include_prerelease);
770    }
771
772    #[test]
773    fn test_include_prerelease_disabled() {
774        let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
775        assert!(!checker.include_prerelease);
776    }
777
778    // Parsing tests (moved from tests/parsing.rs)
779    const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
780    const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
781    const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
782    const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
783    const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
784    const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
785    const ESCAPED_CHARS: &str = include_str!("../tests/fixtures/escaped_chars.json");
786    const NESTED_VERSION: &str = include_str!("../tests/fixtures/nested_version.json");
787    const NULL_VERSION: &str = include_str!("../tests/fixtures/null_version.json");
788
789    #[test]
790    fn parses_real_crates_io_response() {
791        let version = extract_newest_version(REAL_RESPONSE).unwrap();
792        assert_eq!(version, "1.0.228");
793    }
794
795    #[test]
796    fn parses_compact_json() {
797        let version = extract_newest_version(COMPACT_JSON).unwrap();
798        assert_eq!(version, "2.0.0");
799    }
800
801    #[test]
802    fn parses_pretty_json() {
803        let version = extract_newest_version(PRETTY_JSON).unwrap();
804        assert_eq!(version, "3.1.4");
805    }
806
807    #[test]
808    fn parses_whitespace_around_colon() {
809        let version = extract_newest_version(SPACED_COLON).unwrap();
810        assert_eq!(version, "1.2.3");
811    }
812
813    #[test]
814    fn fails_on_missing_crate_field() {
815        let result = extract_newest_version(MISSING_CRATE);
816        assert!(result.is_err());
817        let err = result.unwrap_err().to_string();
818        assert!(
819            err.contains("crate"),
820            "Error should mention 'crate' field: {err}"
821        );
822    }
823
824    #[test]
825    fn fails_on_missing_newest_version() {
826        let result = extract_newest_version(MISSING_VERSION);
827        assert!(result.is_err());
828        let err = result.unwrap_err().to_string();
829        assert!(
830            err.contains("newest_version"),
831            "Error should mention 'newest_version' field: {err}"
832        );
833    }
834
835    #[test]
836    fn fails_on_empty_input() {
837        let result = extract_newest_version("");
838        assert!(result.is_err());
839    }
840
841    #[test]
842    fn fails_on_malformed_json() {
843        let result = extract_newest_version("not json at all");
844        assert!(result.is_err());
845    }
846
847    #[test]
848    fn parses_json_with_escaped_characters() {
849        let version = extract_newest_version(ESCAPED_CHARS).unwrap();
850        assert_eq!(version, "4.0.0");
851    }
852
853    #[test]
854    fn parses_version_from_crate_object_not_versions_array() {
855        // The "newest_version" in the versions array should be ignored;
856        // only the one inside the top-level "crate" object matters.
857        let version = extract_newest_version(NESTED_VERSION).unwrap();
858        assert_eq!(version, "5.0.0");
859    }
860
861    #[test]
862    fn fails_on_null_version() {
863        let result = extract_newest_version(NULL_VERSION);
864        assert!(result.is_err());
865    }
866
867    // DO_NOT_TRACK tests
868    #[cfg(feature = "do-not-track")]
869    mod do_not_track_tests {
870        use super::*;
871
872        #[test]
873        fn do_not_track_detects_1() {
874            temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
875                assert!(do_not_track_enabled());
876            });
877        }
878
879        #[test]
880        fn do_not_track_detects_true() {
881            temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
882                assert!(do_not_track_enabled());
883            });
884        }
885
886        #[test]
887        fn do_not_track_detects_true_case_insensitive() {
888            temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
889                assert!(do_not_track_enabled());
890            });
891        }
892
893        #[test]
894        fn do_not_track_ignores_other_values() {
895            temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
896                assert!(!do_not_track_enabled());
897            });
898            temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
899                assert!(!do_not_track_enabled());
900            });
901            temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
902                assert!(!do_not_track_enabled());
903            });
904        }
905
906        #[test]
907        fn do_not_track_disabled_when_unset() {
908            temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
909                assert!(!do_not_track_enabled());
910            });
911        }
912    }
913
914    // Tests that are specific to the rustls feature (ureq HTTP client path).
915    // The native-tls path is covered by the tests above, which run with default features.
916    #[cfg(feature = "rustls")]
917    mod rustls_tests {
918        use super::*;
919        use std::fs;
920
921        #[test]
922        fn builder_works_with_rustls_feature() {
923            let checker = UpdateChecker::new("test-crate", "1.0.0")
924                .cache_duration(Duration::from_secs(3600))
925                .timeout(Duration::from_secs(10));
926            assert_eq!(checker.crate_name, "test-crate");
927            assert_eq!(checker.timeout, Duration::from_secs(10));
928        }
929
930        #[test]
931        fn cache_hit_does_not_invoke_http() {
932            // Verifies the cache layer works correctly with the ureq path:
933            // a fresh cache entry must be returned without making any network call.
934            let dir = tempfile::tempdir().unwrap();
935            let cache_file = dir.path().join("test-crate-update-check");
936            fs::write(&cache_file, "99.0.0").unwrap();
937
938            let checker = UpdateChecker::new("test-crate", "1.0.0")
939                .cache_dir(Some(dir.path().to_path_buf()))
940                .cache_duration(Duration::from_secs(3600));
941
942            let result = checker.check().unwrap();
943            assert!(result.is_some());
944            assert_eq!(result.unwrap().latest, "99.0.0");
945        }
946
947        #[cfg(feature = "do-not-track")]
948        #[test]
949        fn do_not_track_returns_none_with_rustls() {
950            temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
951                let checker = UpdateChecker::new("test-crate", "1.0.0").cache_dir(None);
952                assert!(checker.check().unwrap().is_none());
953                assert!(checker.check_detailed().unwrap().is_none());
954            });
955        }
956
957        #[test]
958        fn invalid_crate_name_rejected_before_http() {
959            // Ensures validation fires before any HTTP call in the ureq path.
960            let checker = UpdateChecker::new("", "1.0.0").cache_dir(None);
961            assert!(matches!(checker.check(), Err(Error::InvalidCrateName(_))));
962        }
963    }
964
965    #[test]
966    fn test_message_url_default() {
967        let checker = UpdateChecker::new("test-crate", "1.0.0");
968        assert!(checker.message_url.is_none());
969    }
970
971    #[test]
972    fn test_message_url_builder() {
973        let checker = UpdateChecker::new("test-crate", "1.0.0")
974            .message_url("https://example.com/message.txt");
975        assert_eq!(
976            checker.message_url.as_deref(),
977            Some("https://example.com/message.txt")
978        );
979    }
980
981    #[test]
982    fn test_message_url_chainable() {
983        let checker = UpdateChecker::new("test-crate", "1.0.0")
984            .cache_duration(Duration::from_secs(3600))
985            .message_url("https://example.com/msg.txt")
986            .timeout(Duration::from_secs(10));
987        assert_eq!(
988            checker.message_url.as_deref(),
989            Some("https://example.com/msg.txt")
990        );
991        assert_eq!(checker.timeout, Duration::from_secs(10));
992    }
993
994    #[test]
995    fn test_compare_versions_returns_none_message() {
996        let result = compare_versions("1.0.0", "2.0.0".to_string(), false)
997            .unwrap()
998            .unwrap();
999        assert_eq!(result.current, "1.0.0");
1000        assert_eq!(result.latest, "2.0.0");
1001    }
1002
1003    #[test]
1004    fn test_detailed_update_info_with_message() {
1005        let info = DetailedUpdateInfo {
1006            current: "1.0.0".to_string(),
1007            latest: "2.0.0".to_string(),
1008            message: Some("Please update!".to_string()),
1009            #[cfg(feature = "response-body")]
1010            response_body: None,
1011        };
1012        assert_eq!(info.message.as_deref(), Some("Please update!"));
1013    }
1014
1015    #[cfg(feature = "response-body")]
1016    #[test]
1017    fn test_detailed_update_info_with_response_body() {
1018        let info = DetailedUpdateInfo {
1019            current: "1.0.0".to_string(),
1020            latest: "2.0.0".to_string(),
1021            message: None,
1022            response_body: Some("{\"crate\":{}}".to_string()),
1023        };
1024        assert_eq!(info.response_body.as_deref(), Some("{\"crate\":{}}"));
1025    }
1026
1027    #[test]
1028    fn test_truncate_message_empty() {
1029        assert_eq!(truncate_message(""), None);
1030    }
1031
1032    #[test]
1033    fn test_truncate_message_whitespace_only() {
1034        assert_eq!(truncate_message("   \n\t  "), None);
1035    }
1036
1037    #[test]
1038    fn test_truncate_message_ascii_within_limit() {
1039        assert_eq!(
1040            truncate_message("hello world"),
1041            Some("hello world".to_string())
1042        );
1043    }
1044
1045    #[test]
1046    fn test_truncate_message_trims_whitespace() {
1047        assert_eq!(
1048            truncate_message("  hello world  \n"),
1049            Some("hello world".to_string())
1050        );
1051    }
1052
1053    #[test]
1054    fn test_truncate_message_exactly_at_limit() {
1055        let msg = "a".repeat(4096);
1056        let result = truncate_message(&msg).unwrap();
1057        assert_eq!(result.len(), 4096);
1058    }
1059
1060    #[test]
1061    fn test_truncate_message_ascii_over_limit() {
1062        let msg = "a".repeat(5000);
1063        let result = truncate_message(&msg).unwrap();
1064        assert_eq!(result.len(), 4096);
1065    }
1066
1067    #[test]
1068    fn test_truncate_message_multibyte_at_boundary() {
1069        // '€' is 3 bytes in UTF-8. Fill so the 4096 boundary falls mid-character.
1070        let unit = "€"; // 3 bytes
1071        let count = 4096 / 3 + 1; // enough to exceed 4096 bytes
1072        let msg: String = unit.repeat(count);
1073        let result = truncate_message(&msg).unwrap();
1074        assert!(result.len() <= 4096);
1075        // Must end on a valid char boundary (no panic on further use)
1076        assert!(result.is_char_boundary(result.len()));
1077        // Should be the largest multiple of 3 that fits
1078        assert_eq!(result.len(), (4096 / 3) * 3);
1079    }
1080}