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` and `semver`
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//!
48//! ## `DO_NOT_TRACK` Support
49//!
50//! When the `do-not-track` feature is enabled (default), the checker respects
51//! the [`DO_NOT_TRACK`] environment variable standard. If `DO_NOT_TRACK=1` is set,
52//! update checks will return `Ok(None)` without making network requests.
53//!
54//! To disable `DO_NOT_TRACK` support, disable the feature at compile time:
55//!
56//! ```toml
57//! [dependencies]
58//! tiny-update-check = { version = "0.1", default-features = false, features = ["native-tls"] }
59//! ```
60//!
61//! [`DO_NOT_TRACK`]: https://consoledonottrack.com/
62
63/// Async update checking module (requires `async` feature).
64///
65/// This module provides async versions of the update checker using `reqwest`.
66#[cfg(feature = "async")]
67pub mod r#async;
68
69use std::fs;
70use std::path::PathBuf;
71use std::time::{Duration, SystemTime};
72
73/// Information about an available update.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct UpdateInfo {
76    /// The currently running version.
77    pub current: String,
78    /// The latest available version on crates.io.
79    pub latest: String,
80}
81
82/// Errors that can occur during update checking.
83#[derive(Debug)]
84pub enum Error {
85    /// Failed to make HTTP request to crates.io.
86    HttpError(String),
87    /// Failed to parse response from crates.io.
88    ParseError(String),
89    /// Failed to parse version string.
90    VersionError(String),
91    /// Cache I/O error.
92    CacheError(String),
93    /// Invalid crate name provided.
94    InvalidCrateName(String),
95}
96
97impl std::fmt::Display for Error {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::HttpError(msg) => write!(f, "HTTP error: {msg}"),
101            Self::ParseError(msg) => write!(f, "Parse error: {msg}"),
102            Self::VersionError(msg) => write!(f, "Version error: {msg}"),
103            Self::CacheError(msg) => write!(f, "Cache error: {msg}"),
104            Self::InvalidCrateName(msg) => write!(f, "Invalid crate name: {msg}"),
105        }
106    }
107}
108
109impl std::error::Error for Error {}
110
111/// A lightweight update checker for crates.io.
112///
113/// # Example
114///
115/// ```no_run
116/// use tiny_update_check::UpdateChecker;
117///
118/// let checker = UpdateChecker::new("my-crate", "1.0.0");
119/// match checker.check() {
120///     Ok(Some(update)) => println!("Update available: {}", update.latest),
121///     Ok(None) => println!("Already on latest version"),
122///     Err(e) => eprintln!("Failed to check for updates: {}", e),
123/// }
124/// ```
125#[derive(Debug, Clone)]
126pub struct UpdateChecker {
127    crate_name: String,
128    current_version: String,
129    cache_duration: Duration,
130    timeout: Duration,
131    cache_dir: Option<PathBuf>,
132    include_prerelease: bool,
133}
134
135impl UpdateChecker {
136    /// Create a new update checker for the given crate.
137    ///
138    /// # Arguments
139    ///
140    /// * `crate_name` - The name of your crate on crates.io
141    /// * `current_version` - The currently running version (typically from `env!("CARGO_PKG_VERSION")`)
142    #[must_use]
143    pub fn new(crate_name: impl Into<String>, current_version: impl Into<String>) -> Self {
144        Self {
145            crate_name: crate_name.into(),
146            current_version: current_version.into(),
147            cache_duration: Duration::from_secs(24 * 60 * 60), // 24 hours
148            timeout: Duration::from_secs(5),
149            cache_dir: dirs::cache_dir(),
150            include_prerelease: false,
151        }
152    }
153
154    /// Set the cache duration. Defaults to 24 hours.
155    ///
156    /// Set to `Duration::ZERO` to disable caching.
157    #[must_use]
158    pub const fn cache_duration(mut self, duration: Duration) -> Self {
159        self.cache_duration = duration;
160        self
161    }
162
163    /// Set the HTTP request timeout. Defaults to 5 seconds.
164    #[must_use]
165    pub const fn timeout(mut self, timeout: Duration) -> Self {
166        self.timeout = timeout;
167        self
168    }
169
170    /// Set a custom cache directory. Defaults to system cache directory.
171    ///
172    /// Set to `None` to disable caching.
173    #[must_use]
174    pub fn cache_dir(mut self, dir: Option<PathBuf>) -> Self {
175        self.cache_dir = dir;
176        self
177    }
178
179    /// Include pre-release versions in update checks. Defaults to `false`.
180    ///
181    /// When `false` (the default), versions like `2.0.0-alpha.1` or `2.0.0-beta`
182    /// will not be reported as available updates. Set to `true` to receive
183    /// notifications about pre-release versions.
184    #[must_use]
185    pub const fn include_prerelease(mut self, include: bool) -> Self {
186        self.include_prerelease = include;
187        self
188    }
189
190    /// Check for updates.
191    ///
192    /// Returns `Ok(Some(UpdateInfo))` if a newer version is available,
193    /// `Ok(None)` if already on the latest version (or if `DO_NOT_TRACK=1` is set
194    /// and the `do-not-track` feature is enabled),
195    /// or `Err` if the check failed.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the crate name is invalid, the HTTP request fails,
200    /// the response cannot be parsed, or version comparison fails.
201    pub fn check(&self) -> Result<Option<UpdateInfo>, Error> {
202        #[cfg(feature = "do-not-track")]
203        if do_not_track_enabled() {
204            return Ok(None);
205        }
206
207        validate_crate_name(&self.crate_name)?;
208        let latest = self.get_latest_version()?;
209
210        let current = semver::Version::parse(&self.current_version)
211            .map_err(|e| Error::VersionError(format!("Invalid current version: {e}")))?;
212        let latest_ver = semver::Version::parse(&latest)
213            .map_err(|e| Error::VersionError(format!("Invalid latest version: {e}")))?;
214
215        // Filter out pre-release versions unless explicitly included
216        if !self.include_prerelease && !latest_ver.pre.is_empty() {
217            return Ok(None);
218        }
219
220        if latest_ver > current {
221            Ok(Some(UpdateInfo {
222                current: self.current_version.clone(),
223                latest,
224            }))
225        } else {
226            Ok(None)
227        }
228    }
229
230    /// Get the latest version, using cache if available and fresh.
231    fn get_latest_version(&self) -> Result<String, Error> {
232        let cache_path = self.cache_path();
233
234        // Check cache first
235        if self.cache_duration > Duration::ZERO {
236            if let Some(ref path) = cache_path {
237                if let Some(cached) = self.read_cache(path) {
238                    return Ok(cached);
239                }
240            }
241        }
242
243        // Fetch from crates.io
244        let latest = self.fetch_latest_version()?;
245
246        // Update cache
247        if let Some(ref path) = cache_path {
248            let _ = fs::write(path, &latest);
249        }
250
251        Ok(latest)
252    }
253
254    /// Get the cache file path.
255    fn cache_path(&self) -> Option<PathBuf> {
256        self.cache_dir
257            .as_ref()
258            .map(|d| d.join(format!("{}-update-check", self.crate_name)))
259    }
260
261    /// Read from cache if it exists and is fresh.
262    fn read_cache(&self, path: &std::path::Path) -> Option<String> {
263        let metadata = fs::metadata(path).ok()?;
264        let modified = metadata.modified().ok()?;
265        let age = SystemTime::now().duration_since(modified).ok()?;
266
267        if age < self.cache_duration {
268            fs::read_to_string(path).ok().map(|s| s.trim().to_string())
269        } else {
270            None
271        }
272    }
273
274    /// Fetch the latest version from crates.io.
275    fn fetch_latest_version(&self) -> Result<String, Error> {
276        let url = format!("https://crates.io/api/v1/crates/{}", self.crate_name);
277
278        let agent: ureq::Agent = ureq::Agent::config_builder()
279            .timeout_global(Some(self.timeout))
280            .user_agent(concat!(
281                env!("CARGO_PKG_NAME"),
282                "/",
283                env!("CARGO_PKG_VERSION")
284            ))
285            .tls_config(build_tls_config())
286            .build()
287            .into();
288
289        let body = agent
290            .get(&url)
291            .call()
292            .map_err(|e| Error::HttpError(e.to_string()))?
293            .into_body()
294            .read_to_string()
295            .map_err(|e| Error::HttpError(e.to_string()))?;
296
297        extract_newest_version(&body)
298    }
299}
300
301/// Extract the `newest_version` field from a crates.io API response.
302///
303/// This function parses the JSON response without requiring a full JSON parser,
304/// handling various whitespace formats that the API might return.
305pub(crate) fn extract_newest_version(body: &str) -> Result<String, Error> {
306    // Find the "crate" object first to ensure we're in the right context
307    let crate_start = body
308        .find(r#""crate""#)
309        .ok_or_else(|| Error::ParseError("'crate' field not found in response".to_string()))?;
310
311    // Search from the crate field onward
312    let search_region = &body[crate_start..];
313
314    // Find "newest_version" within the crate object
315    let version_key = r#""newest_version""#;
316    let key_pos = search_region.find(version_key).ok_or_else(|| {
317        Error::ParseError("'newest_version' field not found in response".to_string())
318    })?;
319
320    // Move past the key
321    let after_key = &search_region[key_pos + version_key.len()..];
322
323    // Find the colon (handles optional whitespace)
324    let colon_pos = after_key.find(':').ok_or_else(|| {
325        Error::ParseError("malformed JSON: missing colon after newest_version".to_string())
326    })?;
327
328    // Move past the colon and any whitespace
329    let after_colon = &after_key[colon_pos + 1..];
330    let after_colon_trimmed = after_colon.trim_start();
331
332    // Find the opening quote
333    if !after_colon_trimmed.starts_with('"') {
334        return Err(Error::ParseError(
335            "malformed JSON: expected quote after newest_version colon".to_string(),
336        ));
337    }
338
339    // Extract the version string (everything until the closing quote)
340    let version_start = &after_colon_trimmed[1..];
341    let quote_end = version_start
342        .find('"')
343        .ok_or_else(|| Error::ParseError("malformed JSON: unclosed version string".to_string()))?;
344
345    Ok(version_start[..quote_end].to_string())
346}
347
348/// Check if the `DO_NOT_TRACK` environment variable is set to a truthy value.
349///
350/// Returns `true` if `DO_NOT_TRACK` is set to `1` or `true` (case-insensitive).
351#[cfg(feature = "do-not-track")]
352pub(crate) fn do_not_track_enabled() -> bool {
353    std::env::var("DO_NOT_TRACK")
354        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
355        .unwrap_or(false)
356}
357
358/// Validate a crate name according to Cargo's rules.
359///
360/// Valid crate names must:
361/// - Be non-empty
362/// - Start with an ASCII alphabetic character
363/// - Contain only ASCII alphanumeric characters, `-`, or `_`
364/// - Be at most 64 characters long
365fn validate_crate_name(name: &str) -> Result<(), Error> {
366    if name.is_empty() {
367        return Err(Error::InvalidCrateName(
368            "crate name cannot be empty".to_string(),
369        ));
370    }
371
372    if name.len() > 64 {
373        return Err(Error::InvalidCrateName(format!(
374            "crate name exceeds 64 characters: {}",
375            name.len()
376        )));
377    }
378
379    let first_char = name.chars().next().unwrap(); // safe: checked non-empty
380    if !first_char.is_ascii_alphabetic() {
381        return Err(Error::InvalidCrateName(format!(
382            "crate name must start with a letter, found: '{first_char}'"
383        )));
384    }
385
386    for ch in name.chars() {
387        if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
388            return Err(Error::InvalidCrateName(format!(
389                "invalid character in crate name: '{ch}'"
390            )));
391        }
392    }
393
394    Ok(())
395}
396
397/// Build TLS configuration based on enabled features.
398fn build_tls_config() -> ureq::tls::TlsConfig {
399    #[cfg(not(any(feature = "native-tls", feature = "rustls")))]
400    compile_error!("Either 'native-tls' or 'rustls' feature must be enabled");
401
402    #[cfg(feature = "native-tls")]
403    let provider = ureq::tls::TlsProvider::NativeTls;
404
405    #[cfg(all(feature = "rustls", not(feature = "native-tls")))]
406    let provider = ureq::tls::TlsProvider::Rustls;
407
408    ureq::tls::TlsConfig::builder().provider(provider).build()
409}
410
411/// Convenience function to check for updates with default settings.
412///
413/// # Example
414///
415/// ```no_run
416/// if let Ok(Some(update)) = tiny_update_check::check("my-crate", "1.0.0") {
417///     eprintln!("Update available: {} -> {}", update.current, update.latest);
418/// }
419/// ```
420///
421/// # Errors
422///
423/// Returns an error if the update check fails.
424pub fn check(
425    crate_name: impl Into<String>,
426    current_version: impl Into<String>,
427) -> Result<Option<UpdateInfo>, Error> {
428    UpdateChecker::new(crate_name, current_version).check()
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn test_update_info_display() {
437        let info = UpdateInfo {
438            current: "1.0.0".to_string(),
439            latest: "2.0.0".to_string(),
440        };
441        assert_eq!(info.current, "1.0.0");
442        assert_eq!(info.latest, "2.0.0");
443    }
444
445    #[test]
446    fn test_checker_builder() {
447        let checker = UpdateChecker::new("test-crate", "1.0.0")
448            .cache_duration(Duration::from_secs(3600))
449            .timeout(Duration::from_secs(10));
450
451        assert_eq!(checker.crate_name, "test-crate");
452        assert_eq!(checker.current_version, "1.0.0");
453        assert_eq!(checker.cache_duration, Duration::from_secs(3600));
454        assert_eq!(checker.timeout, Duration::from_secs(10));
455    }
456
457    #[test]
458    fn test_cache_disabled() {
459        let checker = UpdateChecker::new("test-crate", "1.0.0")
460            .cache_duration(Duration::ZERO)
461            .cache_dir(None);
462
463        assert_eq!(checker.cache_duration, Duration::ZERO);
464        assert!(checker.cache_dir.is_none());
465    }
466
467    #[test]
468    fn test_error_display() {
469        let err = Error::HttpError("connection failed".to_string());
470        assert_eq!(err.to_string(), "HTTP error: connection failed");
471
472        let err = Error::ParseError("invalid json".to_string());
473        assert_eq!(err.to_string(), "Parse error: invalid json");
474
475        let err = Error::InvalidCrateName("empty".to_string());
476        assert_eq!(err.to_string(), "Invalid crate name: empty");
477    }
478
479    #[test]
480    fn test_include_prerelease_default() {
481        let checker = UpdateChecker::new("test-crate", "1.0.0");
482        assert!(!checker.include_prerelease);
483    }
484
485    #[test]
486    fn test_include_prerelease_enabled() {
487        let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(true);
488        assert!(checker.include_prerelease);
489    }
490
491    #[test]
492    fn test_include_prerelease_disabled() {
493        let checker = UpdateChecker::new("test-crate", "1.0.0").include_prerelease(false);
494        assert!(!checker.include_prerelease);
495    }
496
497    // Parsing tests (moved from tests/parsing.rs)
498    const REAL_RESPONSE: &str = include_str!("../tests/fixtures/serde_response.json");
499    const COMPACT_JSON: &str = include_str!("../tests/fixtures/compact.json");
500    const PRETTY_JSON: &str = include_str!("../tests/fixtures/pretty.json");
501    const SPACED_COLON: &str = include_str!("../tests/fixtures/spaced_colon.json");
502    const MISSING_CRATE: &str = include_str!("../tests/fixtures/missing_crate.json");
503    const MISSING_VERSION: &str = include_str!("../tests/fixtures/missing_version.json");
504
505    #[test]
506    fn parses_real_crates_io_response() {
507        let version = extract_newest_version(REAL_RESPONSE).unwrap();
508        assert_eq!(version, "1.0.228");
509    }
510
511    #[test]
512    fn parses_compact_json() {
513        let version = extract_newest_version(COMPACT_JSON).unwrap();
514        assert_eq!(version, "2.0.0");
515    }
516
517    #[test]
518    fn parses_pretty_json() {
519        let version = extract_newest_version(PRETTY_JSON).unwrap();
520        assert_eq!(version, "3.1.4");
521    }
522
523    #[test]
524    fn parses_whitespace_around_colon() {
525        let version = extract_newest_version(SPACED_COLON).unwrap();
526        assert_eq!(version, "1.2.3");
527    }
528
529    #[test]
530    fn fails_on_missing_crate_field() {
531        let result = extract_newest_version(MISSING_CRATE);
532        assert!(result.is_err());
533        let err = result.unwrap_err().to_string();
534        assert!(
535            err.contains("crate"),
536            "Error should mention 'crate' field: {err}"
537        );
538    }
539
540    #[test]
541    fn fails_on_missing_newest_version() {
542        let result = extract_newest_version(MISSING_VERSION);
543        assert!(result.is_err());
544        let err = result.unwrap_err().to_string();
545        assert!(
546            err.contains("newest_version"),
547            "Error should mention 'newest_version' field: {err}"
548        );
549    }
550
551    #[test]
552    fn fails_on_empty_input() {
553        let result = extract_newest_version("");
554        assert!(result.is_err());
555    }
556
557    #[test]
558    fn fails_on_malformed_json() {
559        let result = extract_newest_version("not json at all");
560        assert!(result.is_err());
561    }
562
563    // DO_NOT_TRACK tests
564    #[cfg(feature = "do-not-track")]
565    mod do_not_track_tests {
566        use super::*;
567
568        #[test]
569        fn do_not_track_detects_1() {
570            temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
571                assert!(do_not_track_enabled());
572            });
573        }
574
575        #[test]
576        fn do_not_track_detects_true() {
577            temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
578                assert!(do_not_track_enabled());
579            });
580        }
581
582        #[test]
583        fn do_not_track_detects_true_case_insensitive() {
584            temp_env::with_var("DO_NOT_TRACK", Some("TRUE"), || {
585                assert!(do_not_track_enabled());
586            });
587        }
588
589        #[test]
590        fn do_not_track_ignores_other_values() {
591            temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
592                assert!(!do_not_track_enabled());
593            });
594            temp_env::with_var("DO_NOT_TRACK", Some("false"), || {
595                assert!(!do_not_track_enabled());
596            });
597            temp_env::with_var("DO_NOT_TRACK", Some("yes"), || {
598                assert!(!do_not_track_enabled());
599            });
600        }
601
602        #[test]
603        fn do_not_track_disabled_when_unset() {
604            temp_env::with_var("DO_NOT_TRACK", None::<&str>, || {
605                assert!(!do_not_track_enabled());
606            });
607        }
608    }
609}