Skip to main content

coding_tools/
update.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! Best-effort "is there a newer release?" check against the crates.io **sparse
5//! index**, wired into the `ct` umbrella so the suite can tell you when an update
6//! is available without ever getting in your way.
7//!
8//! The design follows the crates.io guidance for polite, CDN-friendly polling:
9//!
10//! * **Sparse protocol over the CDN.** We `GET` the crate's index file at
11//!   `https://index.crates.io/<path>` (the same host cargo's sparse registry
12//!   uses, fronted by a CDN), rather than cloning the git index. The path is
13//!   built from the crate name by cargo's rule ([`index_path`]).
14//! * **Conditional requests.** We send the previous response's `ETag` back as
15//!   `If-None-Match`, so an unchanged index answers `304 Not Modified` with no
16//!   body — the cheap path the CDN is built for.
17//! * **Throttled.** At most one network poll per interval (daily by default,
18//!   `CT_UPDATE_CHECK` overrides), recorded in a small state file under the
19//!   user's cache directory.
20//! * **Never blocking.** The foreground `ct` invocation only reads that cached
21//!   state ([`on_invocation`]); the actual network poll runs in a **detached
22//!   background process** ([`run_background_poll`]) that writes the state for a
23//!   later run to notice. A `ct` command never waits on the network.
24//!
25//! Everything here is best-effort: any error — no network, a malformed index, an
26//! unwritable cache — is swallowed silently. An update check must never fail a
27//! command or print a diagnostic of its own.
28
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32
33use serde_json::{Value, json};
34
35/// The published crate name (hyphenated), used for the index path and messages.
36const PKG_NAME: &str = "coding-tools";
37/// The project URL, included in the `User-Agent` so crates.io can identify us.
38const REPO: &str = "https://github.com/jshook/coding-tools";
39/// The sparse-index host (the CDN-fronted endpoint cargo itself uses).
40const INDEX_HOST: &str = "https://index.crates.io";
41/// The state file under the user cache dir.
42const STATE_FILE: &str = "update-check.json";
43/// The hidden `ct` flag that runs the background network poll.
44pub const BG_FLAG: &str = "--update-check-run";
45/// The default poll interval: once a day.
46const DAILY: u64 = 86_400;
47
48// ----- Configuration -----------------------------------------------------------
49
50/// Parse a `CT_UPDATE_CHECK` value into a poll interval in seconds, or [`None`]
51/// to disable the check entirely.
52///
53/// Accepts the friendly words `daily` (the default), `weekly`, `hourly`,
54/// `always` (every run — for testing), and the off-switches `never` / `off` /
55/// `no` / `false` / `0`; a bare positive integer is taken as seconds. Anything
56/// unrecognised falls back to the daily default rather than disabling.
57///
58/// ```
59/// use coding_tools::update::parse_interval;
60/// assert_eq!(parse_interval(None), Some(86_400));
61/// assert_eq!(parse_interval(Some("daily")), Some(86_400));
62/// assert_eq!(parse_interval(Some("weekly")), Some(604_800));
63/// assert_eq!(parse_interval(Some("never")), None);
64/// assert_eq!(parse_interval(Some("0")), None);
65/// assert_eq!(parse_interval(Some("3600")), Some(3_600));
66/// assert_eq!(parse_interval(Some("always")), Some(0));
67/// assert_eq!(parse_interval(Some("garbage")), Some(86_400));
68/// ```
69pub fn parse_interval(value: Option<&str>) -> Option<u64> {
70    match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
71        None | Some("") | Some("daily") => Some(DAILY),
72        Some("never" | "off" | "no" | "false" | "0") => None,
73        Some("weekly") => Some(7 * DAILY),
74        Some("hourly") => Some(3_600),
75        Some("always") => Some(0),
76        Some(other) => Some(other.parse::<u64>().unwrap_or(DAILY)),
77    }
78}
79
80/// The configured interval from the environment (`CT_UPDATE_CHECK`).
81fn interval_from_env() -> Option<u64> {
82    parse_interval(std::env::var("CT_UPDATE_CHECK").ok().as_deref())
83}
84
85// ----- Index path + version pick (pure) ----------------------------------------
86
87/// The crates.io sparse-index path for a crate name, by cargo's rule: 1- and
88/// 2-char names live under `1/`/`2/`, 3-char under `3/<first>/`, and everything
89/// else under `<first-two>/<next-two>/`. The name is lower-cased.
90///
91/// ```
92/// use coding_tools::update::index_path;
93/// assert_eq!(index_path("coding-tools"), "co/di/coding-tools");
94/// assert_eq!(index_path("a"), "1/a");
95/// assert_eq!(index_path("ab"), "2/ab");
96/// assert_eq!(index_path("abc"), "3/a/abc");
97/// assert_eq!(index_path("serde"), "se/rd/serde");
98/// ```
99pub fn index_path(name: &str) -> String {
100    let n = name.to_ascii_lowercase();
101    match n.len() {
102        0 => n,
103        1 => format!("1/{n}"),
104        2 => format!("2/{n}"),
105        3 => format!("3/{}/{}", &n[0..1], n),
106        _ => format!("{}/{}/{}", &n[0..2], &n[2..4], n),
107    }
108}
109
110/// The full sparse-index URL for a crate.
111///
112/// ```
113/// use coding_tools::update::index_url;
114/// assert_eq!(index_url("coding-tools"), "https://index.crates.io/co/di/coding-tools");
115/// ```
116pub fn index_url(name: &str) -> String {
117    format!("{INDEX_HOST}/{}", index_path(name))
118}
119
120/// The highest non-yanked version in a sparse-index document (one JSON object
121/// per line). Lines that don't parse, lack a `vers`, or are yanked are skipped;
122/// [`None`] means nothing usable was found.
123///
124/// ```
125/// use coding_tools::update::latest_from_index;
126/// let body = r#"{"name":"x","vers":"0.8.3","yanked":false}
127/// {"name":"x","vers":"0.9.0","yanked":false}
128/// {"name":"x","vers":"0.10.0","yanked":true}"#;
129/// assert_eq!(latest_from_index(body).as_deref(), Some("0.9.0"));
130/// ```
131pub fn latest_from_index(body: &str) -> Option<String> {
132    let mut best: Option<(Version, String)> = None;
133    for line in body.lines() {
134        let line = line.trim();
135        if line.is_empty() {
136            continue;
137        }
138        let Ok(v) = serde_json::from_str::<Value>(line) else {
139            continue;
140        };
141        if v.get("yanked").and_then(Value::as_bool) == Some(true) {
142            continue;
143        }
144        let Some(vers) = v.get("vers").and_then(Value::as_str) else {
145            continue;
146        };
147        let Some(parsed) = Version::parse(vers) else {
148            continue;
149        };
150        if best.as_ref().is_none_or(|(b, _)| parsed > *b) {
151            best = Some((parsed, vers.to_string()));
152        }
153    }
154    best.map(|(_, s)| s)
155}
156
157/// Whether `latest` is a strictly newer release than `current`. Unparsable
158/// versions compare as "not newer" — we never nag on garbage.
159///
160/// ```
161/// use coding_tools::update::is_newer;
162/// assert!(is_newer("0.9.0", "0.8.4"));
163/// assert!(is_newer("1.0.0", "1.0.0-rc.1")); // a release beats its pre-release
164/// assert!(!is_newer("0.8.4", "0.8.4"));
165/// assert!(!is_newer("0.8.3", "0.8.4"));
166/// assert!(!is_newer("nonsense", "0.8.4"));
167/// ```
168pub fn is_newer(latest: &str, current: &str) -> bool {
169    match (Version::parse(latest), Version::parse(current)) {
170        (Some(l), Some(c)) => l > c,
171        _ => false,
172    }
173}
174
175/// A minimal semantic version: the `major.minor.patch` core plus a pre-release
176/// marker. Build metadata is ignored; pre-release identifiers compare as a
177/// lexical fallback, which is ample for "is there a newer release than mine?".
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct Version {
180    core: (u64, u64, u64),
181    /// `None` for a release; `Some(ids)` for a pre-release (e.g. `rc.1`).
182    pre: Option<String>,
183}
184
185impl Version {
186    /// Parse `MAJOR.MINOR.PATCH[-pre][+build]`; [`None`] if the core isn't three
187    /// integers.
188    pub fn parse(s: &str) -> Option<Version> {
189        let s = s.trim();
190        let s = s.split('+').next().unwrap_or(s); // drop build metadata
191        let (core_str, pre) = match s.split_once('-') {
192            Some((c, p)) => (c, Some(p.to_string())),
193            None => (s, None),
194        };
195        let mut it = core_str.split('.');
196        let major = it.next()?.parse().ok()?;
197        let minor = it.next()?.parse().ok()?;
198        let patch = it.next()?.parse().ok()?;
199        if it.next().is_some() {
200            return None; // more than three core components
201        }
202        Some(Version {
203            core: (major, minor, patch),
204            pre,
205        })
206    }
207}
208
209impl PartialOrd for Version {
210    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
211        Some(self.cmp(other))
212    }
213}
214
215impl Ord for Version {
216    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
217        use std::cmp::Ordering::Equal;
218        match self.core.cmp(&other.core) {
219            Equal => match (&self.pre, &other.pre) {
220                // A release outranks a pre-release of the same core.
221                (None, None) => Equal,
222                (None, Some(_)) => std::cmp::Ordering::Greater,
223                (Some(_), None) => std::cmp::Ordering::Less,
224                (Some(a), Some(b)) => a.cmp(b),
225            },
226            ord => ord,
227        }
228    }
229}
230
231// ----- State (the cache file) --------------------------------------------------
232
233/// The cached check state. All best-effort: a missing or corrupt file reads as
234/// the default (a fresh install that has never checked).
235#[derive(Debug, Default, Clone, PartialEq, Eq)]
236struct State {
237    /// Unix seconds of the last poll attempt (0 = never).
238    last_check: u64,
239    /// Unix seconds we last printed the "update available" notice (0 = never).
240    last_notified: u64,
241    /// The highest version seen at the index, if any.
242    latest: Option<String>,
243    /// The `ETag` of the last index response, for conditional requests.
244    etag: Option<String>,
245    /// Whether the one-time "this checks for updates" notice has been shown.
246    notice_shown: bool,
247}
248
249impl State {
250    /// Read state from `path`, defaulting on any error.
251    fn load(path: &Path) -> State {
252        let Ok(text) = std::fs::read_to_string(path) else {
253            return State::default();
254        };
255        let Ok(v) = serde_json::from_str::<Value>(&text) else {
256            return State::default();
257        };
258        let u64f = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
259        let strf = |k: &str| {
260            v.get(k)
261                .and_then(Value::as_str)
262                .map(str::to_string)
263                .filter(|s| !s.is_empty())
264        };
265        State {
266            last_check: u64f("last_check"),
267            last_notified: u64f("last_notified"),
268            latest: strf("latest"),
269            etag: strf("etag"),
270            notice_shown: v
271                .get("notice_shown")
272                .and_then(Value::as_bool)
273                .unwrap_or(false),
274        }
275    }
276
277    /// Write state to `path` (creating the parent dir). Errors are ignored.
278    fn save(&self, path: &Path) {
279        if let Some(dir) = path.parent() {
280            let _ = std::fs::create_dir_all(dir);
281        }
282        let v = json!({
283            "last_check": self.last_check,
284            "last_notified": self.last_notified,
285            "latest": self.latest,
286            "etag": self.etag,
287            "notice_shown": self.notice_shown,
288        });
289        let _ = std::fs::write(path, format!("{v}\n"));
290    }
291}
292
293/// The user cache directory for the suite's state, honoring an explicit
294/// `CT_STATE_DIR` override (handy for tests and unusual setups). Platform
295/// defaults: `%LOCALAPPDATA%` on Windows, `~/Library/Caches` on macOS,
296/// `$XDG_CACHE_HOME` (or `~/.cache`) elsewhere. [`None`] if none can be found.
297fn state_dir() -> Option<PathBuf> {
298    if let Some(d) = std::env::var_os("CT_STATE_DIR") {
299        return Some(PathBuf::from(d));
300    }
301    #[cfg(windows)]
302    {
303        std::env::var_os("LOCALAPPDATA").map(|p| PathBuf::from(p).join(PKG_NAME))
304    }
305    #[cfg(target_os = "macos")]
306    {
307        std::env::var_os("HOME").map(|p| PathBuf::from(p).join("Library/Caches").join(PKG_NAME))
308    }
309    #[cfg(all(unix, not(target_os = "macos")))]
310    {
311        std::env::var_os("XDG_CACHE_HOME")
312            .map(PathBuf::from)
313            .or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".cache")))
314            .map(|p| p.join(PKG_NAME))
315    }
316}
317
318/// Unix seconds now (0 if the clock is before the epoch — never panics).
319fn unix_now() -> u64 {
320    SystemTime::now()
321        .duration_since(UNIX_EPOCH)
322        .map(|d| d.as_secs())
323        .unwrap_or(0)
324}
325
326// ----- Foreground: cheap, never blocks -----------------------------------------
327
328/// Called once per real `ct` invocation. Reads the cached state, prints the
329/// first-run and "update available" notices when due (only to a terminal, so
330/// scripts and pipes stay clean), and — if a poll is due — claims the slot and
331/// spawns the detached background poll. Does **no** network I/O itself. Silent
332/// and infallible: any problem is swallowed.
333pub fn on_invocation() {
334    let _ = try_on_invocation();
335}
336
337fn try_on_invocation() -> Option<()> {
338    let interval = interval_from_env()?; // None → disabled
339    let dir = state_dir()?;
340    let path = dir.join(STATE_FILE);
341    let mut state = State::load(&path);
342
343    let now = unix_now();
344    let current = env!("CARGO_PKG_VERSION");
345    let tty = {
346        use std::io::IsTerminal;
347        std::io::stderr().is_terminal()
348    };
349
350    // One-time "we check for updates" notice (only shown interactively, and only
351    // marked shown once it actually has been).
352    if tty && !state.notice_shown {
353        eprint!("{}", first_run_notice());
354        state.notice_shown = true;
355    }
356
357    // "A newer version is available", from cache, at most once per interval.
358    if tty
359        && let Some(latest) = state.latest.clone()
360        && is_newer(&latest, current)
361        && now.saturating_sub(state.last_notified) >= interval
362    {
363        eprint!("{}", update_available_notice(&latest, current));
364        state.last_notified = now;
365    }
366
367    // Claim and spawn the background poll when due. The claim is persisted before
368    // spawning so concurrent `ct` runs don't each launch a poller.
369    let due = now.saturating_sub(state.last_check) >= interval;
370    if due {
371        state.last_check = now;
372    }
373    state.save(&path);
374    if due {
375        spawn_background();
376    }
377    Some(())
378}
379
380/// Spawn `ct --update-check-run` as a detached, output-suppressed background
381/// process. Best-effort: a spawn failure is ignored.
382fn spawn_background() {
383    let Ok(exe) = std::env::current_exe() else {
384        return;
385    };
386    let mut cmd = Command::new(exe);
387    cmd.arg(BG_FLAG)
388        .stdin(Stdio::null())
389        .stdout(Stdio::null())
390        .stderr(Stdio::null());
391    #[cfg(windows)]
392    {
393        use std::os::windows::process::CommandExt;
394        const DETACHED_PROCESS: u32 = 0x0000_0008;
395        cmd.creation_flags(DETACHED_PROCESS);
396    }
397    let _ = cmd.spawn();
398}
399
400// ----- Background: the actual network poll -------------------------------------
401
402/// The entry point for `ct --update-check-run`: perform one conditional GET
403/// against the sparse index and update the cached state. Silent and infallible.
404pub fn run_background_poll() {
405    let _ = try_poll();
406}
407
408fn try_poll() -> Option<()> {
409    interval_from_env()?; // honor `CT_UPDATE_CHECK=never` even here
410    let dir = state_dir()?;
411    let path = dir.join(STATE_FILE);
412    let mut state = State::load(&path);
413
414    match fetch(env!("CARGO_PKG_VERSION"), state.etag.as_deref()) {
415        Fetch::Updated { latest, etag } => {
416            state.latest = Some(latest);
417            if etag.is_some() {
418                state.etag = etag;
419            }
420        }
421        Fetch::NotModified | Fetch::Failed => {}
422    }
423    state.last_check = unix_now();
424    state.save(&path);
425    Some(())
426}
427
428/// The outcome of one index fetch.
429enum Fetch {
430    /// `200 OK`: the highest version parsed from the body, plus the new `ETag`.
431    Updated {
432        latest: String,
433        etag: Option<String>,
434    },
435    /// `304 Not Modified`: the cached `latest`/`etag` still stand.
436    NotModified,
437    /// Any network or protocol error — left for the next interval.
438    Failed,
439}
440
441/// One conditional GET of the crate's sparse-index file.
442fn fetch(current: &str, etag: Option<&str>) -> Fetch {
443    let url = index_url(PKG_NAME);
444    let ua = format!("{PKG_NAME}/{current} ({REPO})");
445    // `http_status_as_error(false)` so a 304 arrives as a normal response we can
446    // inspect, rather than an error.
447    let agent: ureq::Agent = ureq::Agent::config_builder()
448        .http_status_as_error(false)
449        .timeout_global(Some(Duration::from_secs(10)))
450        .build()
451        .into();
452    let mut req = agent.get(&url).header("User-Agent", &ua);
453    if let Some(e) = etag {
454        req = req.header("If-None-Match", e);
455    }
456    let Ok(mut resp) = req.call() else {
457        return Fetch::Failed;
458    };
459    let status = resp.status().as_u16();
460    if status == 304 {
461        return Fetch::NotModified;
462    }
463    if status != 200 {
464        return Fetch::Failed;
465    }
466    let new_etag = resp
467        .headers()
468        .get("etag")
469        .and_then(|v| v.to_str().ok())
470        .map(str::to_string);
471    match resp.body_mut().read_to_string() {
472        Ok(body) => match latest_from_index(&body) {
473            Some(latest) => Fetch::Updated {
474                latest,
475                etag: new_etag,
476            },
477            None => Fetch::Failed,
478        },
479        Err(_) => Fetch::Failed,
480    }
481}
482
483// ----- Notices -----------------------------------------------------------------
484
485/// The one-time notice shown on first interactive use.
486fn first_run_notice() -> String {
487    format!(
488        "{PKG_NAME}: checking crates.io for updates about once a day, in the background.\n\
489         {PKG_NAME}: set CT_UPDATE_CHECK=never to disable (or =weekly / =hourly / a number of seconds).\n"
490    )
491}
492
493/// The "a newer version is available" notice.
494fn update_available_notice(latest: &str, current: &str) -> String {
495    format!(
496        "{PKG_NAME}: a newer version is available: {latest} (you have {current}).\n\
497         {PKG_NAME}: update with `cargo install {PKG_NAME}` — or set CT_UPDATE_CHECK=never to silence.\n"
498    )
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn version_orders_core_and_prerelease() {
507        let v = Version::parse;
508        assert!(v("0.9.0").unwrap() > v("0.8.4").unwrap());
509        assert!(v("1.0.0").unwrap() > v("0.99.99").unwrap());
510        assert!(v("1.2.10").unwrap() > v("1.2.9").unwrap());
511        // a release outranks its own pre-release; pre-releases order lexically
512        assert!(v("1.0.0").unwrap() > v("1.0.0-rc.1").unwrap());
513        assert!(v("1.0.0-rc.2").unwrap() > v("1.0.0-rc.1").unwrap());
514        // build metadata is ignored
515        assert_eq!(v("1.2.3+abc").unwrap(), v("1.2.3").unwrap());
516        // malformed cores don't parse
517        assert!(v("1.2").is_none());
518        assert!(v("1.2.3.4").is_none());
519        assert!(v("x.y.z").is_none());
520    }
521
522    #[test]
523    fn latest_from_index_picks_highest_unyanked() {
524        let body = "\
525{\"name\":\"coding-tools\",\"vers\":\"0.8.3\",\"yanked\":false}\n\
526{\"name\":\"coding-tools\",\"vers\":\"0.8.4\",\"yanked\":false}\n\
527{\"name\":\"coding-tools\",\"vers\":\"0.9.0\",\"yanked\":true}\n\
528not even json\n\
529{\"name\":\"coding-tools\",\"vers\":\"0.8.10\",\"yanked\":false}\n";
530        assert_eq!(latest_from_index(body).as_deref(), Some("0.8.10"));
531        // an all-yanked / empty document yields nothing
532        assert_eq!(latest_from_index("").as_deref(), None);
533        assert_eq!(
534            latest_from_index("{\"vers\":\"1.0.0\",\"yanked\":true}").as_deref(),
535            None
536        );
537    }
538
539    #[test]
540    fn state_round_trips_through_a_file() {
541        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/update");
542        let _ = std::fs::create_dir_all(&dir);
543        let path = dir.join("state.json");
544        let _ = std::fs::remove_file(&path);
545
546        // a missing file reads as default
547        assert_eq!(State::load(&path), State::default());
548
549        let s = State {
550            last_check: 111,
551            last_notified: 222,
552            latest: Some("0.9.0".to_string()),
553            etag: Some("\"abc\"".to_string()),
554            notice_shown: true,
555        };
556        s.save(&path);
557        assert_eq!(State::load(&path), s);
558
559        // a corrupt file also reads as default
560        std::fs::write(&path, "{ not json").unwrap();
561        assert_eq!(State::load(&path), State::default());
562    }
563
564    #[test]
565    fn notices_name_the_versions_and_the_off_switch() {
566        let avail = update_available_notice("0.9.0", "0.8.4");
567        assert!(
568            avail.contains("0.9.0") && avail.contains("0.8.4"),
569            "{avail}"
570        );
571        assert!(avail.contains("cargo install coding-tools"), "{avail}");
572        assert!(avail.contains("CT_UPDATE_CHECK=never"), "{avail}");
573
574        let first = first_run_notice();
575        assert!(first.contains("once a day"), "{first}");
576        assert!(first.contains("CT_UPDATE_CHECK=never"), "{first}");
577    }
578
579    #[test]
580    fn empty_string_etag_loads_as_none() {
581        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/update");
582        let _ = std::fs::create_dir_all(&dir);
583        let path = dir.join("state-empty.json");
584        std::fs::write(&path, r#"{"etag":"","latest":""}"#).unwrap();
585        let s = State::load(&path);
586        assert_eq!(s.etag, None);
587        assert_eq!(s.latest, None);
588    }
589}