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) -> bool {
279        if let Some(dir) = path.parent()
280            && std::fs::create_dir_all(dir).is_err()
281        {
282            return false;
283        }
284        let v = json!({
285            "last_check": self.last_check,
286            "last_notified": self.last_notified,
287            "latest": self.latest,
288            "etag": self.etag,
289            "notice_shown": self.notice_shown,
290        });
291        std::fs::write(path, format!("{v}\n")).is_ok()
292    }
293}
294
295/// The user cache directory for the suite's state, honoring an explicit
296/// `CT_STATE_DIR` override (handy for tests and unusual setups). Platform
297/// defaults: `%LOCALAPPDATA%` on Windows, `~/Library/Caches` on macOS,
298/// `$XDG_CACHE_HOME` (or `~/.cache`) elsewhere. [`None`] if none can be found.
299fn state_dir() -> Option<PathBuf> {
300    if let Some(d) = std::env::var_os("CT_STATE_DIR") {
301        return Some(PathBuf::from(d));
302    }
303    #[cfg(windows)]
304    {
305        std::env::var_os("LOCALAPPDATA").map(|p| PathBuf::from(p).join(PKG_NAME))
306    }
307    #[cfg(target_os = "macos")]
308    {
309        std::env::var_os("HOME").map(|p| PathBuf::from(p).join("Library/Caches").join(PKG_NAME))
310    }
311    #[cfg(all(unix, not(target_os = "macos")))]
312    {
313        std::env::var_os("XDG_CACHE_HOME")
314            .map(PathBuf::from)
315            .or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".cache")))
316            .map(|p| p.join(PKG_NAME))
317    }
318}
319
320/// Unix seconds now (0 if the clock is before the epoch — never panics).
321fn unix_now() -> u64 {
322    SystemTime::now()
323        .duration_since(UNIX_EPOCH)
324        .map(|d| d.as_secs())
325        .unwrap_or(0)
326}
327
328// ----- Foreground: cheap, never blocks -----------------------------------------
329
330/// Called once per real `ct` invocation. Reads the cached state, prints the
331/// first-run and "update available" notices when due (only to a terminal, so
332/// scripts and pipes stay clean), and — if a poll is due — claims the slot and
333/// spawns the detached background poll. Does **no** network I/O itself. Silent
334/// and infallible: any problem is swallowed.
335pub fn on_invocation() {
336    let _ = try_on_invocation();
337}
338
339fn try_on_invocation() -> Option<()> {
340    let interval = interval_from_env()?; // None → disabled
341    // Captured agent/CI calls are precisely where a nominally detached child
342    // may remain attached to the harness job and add its network timeout to the
343    // foreground command. Update notices are interactive anyway, so do not
344    // schedule polling from a non-terminal invocation.
345    {
346        use std::io::IsTerminal;
347        if !std::io::stderr().is_terminal() {
348            return Some(());
349        }
350    }
351    let dir = state_dir()?;
352    let path = dir.join(STATE_FILE);
353    let mut state = State::load(&path);
354
355    let now = unix_now();
356    let current = env!("CARGO_PKG_VERSION");
357    let tty = {
358        use std::io::IsTerminal;
359        std::io::stderr().is_terminal()
360    };
361
362    // One-time "we check for updates" notice (only shown interactively, and only
363    // marked shown once it actually has been).
364    if tty && !state.notice_shown {
365        eprint!("{}", first_run_notice());
366        state.notice_shown = true;
367    }
368
369    // "A newer version is available", from cache, at most once per interval.
370    if tty
371        && let Some(latest) = state.latest.clone()
372        && is_newer(&latest, current)
373        && now.saturating_sub(state.last_notified) >= interval
374    {
375        eprint!("{}", update_available_notice(&latest, current));
376        state.last_notified = now;
377    }
378
379    // Claim and spawn the background poll when due. The claim is persisted before
380    // spawning so concurrent `ct` runs don't each launch a poller.
381    let due = now.saturating_sub(state.last_check) >= interval;
382    if due {
383        state.last_check = now;
384    }
385    let claimed = state.save(&path);
386    if due && claimed {
387        spawn_background();
388    }
389    Some(())
390}
391
392/// Spawn `ct --update-check-run` as a detached, output-suppressed background
393/// process. Best-effort: a spawn failure is ignored.
394fn spawn_background() {
395    let Ok(exe) = std::env::current_exe() else {
396        return;
397    };
398    let mut cmd = Command::new(exe);
399    cmd.arg(BG_FLAG)
400        .stdin(Stdio::null())
401        .stdout(Stdio::null())
402        .stderr(Stdio::null());
403    #[cfg(windows)]
404    {
405        use std::os::windows::process::CommandExt;
406        const DETACHED_PROCESS: u32 = 0x0000_0008;
407        cmd.creation_flags(DETACHED_PROCESS);
408    }
409    let _ = cmd.spawn();
410}
411
412// ----- Background: the actual network poll -------------------------------------
413
414/// The entry point for `ct --update-check-run`: perform one conditional GET
415/// against the sparse index and update the cached state. Silent and infallible.
416pub fn run_background_poll() {
417    let _ = try_poll();
418}
419
420fn try_poll() -> Option<()> {
421    interval_from_env()?; // honor `CT_UPDATE_CHECK=never` even here
422    let dir = state_dir()?;
423    let path = dir.join(STATE_FILE);
424    let mut state = State::load(&path);
425
426    match fetch(env!("CARGO_PKG_VERSION"), state.etag.as_deref()) {
427        Fetch::Updated { latest, etag } => {
428            state.latest = Some(latest);
429            if etag.is_some() {
430                state.etag = etag;
431            }
432        }
433        Fetch::NotModified | Fetch::Failed => {}
434    }
435    state.last_check = unix_now();
436    let _ = state.save(&path);
437    Some(())
438}
439
440/// The outcome of one index fetch.
441enum Fetch {
442    /// `200 OK`: the highest version parsed from the body, plus the new `ETag`.
443    Updated {
444        latest: String,
445        etag: Option<String>,
446    },
447    /// `304 Not Modified`: the cached `latest`/`etag` still stand.
448    NotModified,
449    /// Any network or protocol error — left for the next interval.
450    Failed,
451}
452
453/// One conditional GET of the crate's sparse-index file.
454fn fetch(current: &str, etag: Option<&str>) -> Fetch {
455    let url = index_url(PKG_NAME);
456    let ua = format!("{PKG_NAME}/{current} ({REPO})");
457    // `http_status_as_error(false)` so a 304 arrives as a normal response we can
458    // inspect, rather than an error.
459    let agent: ureq::Agent = ureq::Agent::config_builder()
460        .http_status_as_error(false)
461        .timeout_global(Some(Duration::from_secs(10)))
462        .build()
463        .into();
464    let mut req = agent.get(&url).header("User-Agent", &ua);
465    if let Some(e) = etag {
466        req = req.header("If-None-Match", e);
467    }
468    let Ok(mut resp) = req.call() else {
469        return Fetch::Failed;
470    };
471    let status = resp.status().as_u16();
472    if status == 304 {
473        return Fetch::NotModified;
474    }
475    if status != 200 {
476        return Fetch::Failed;
477    }
478    let new_etag = resp
479        .headers()
480        .get("etag")
481        .and_then(|v| v.to_str().ok())
482        .map(str::to_string);
483    match resp.body_mut().read_to_string() {
484        Ok(body) => match latest_from_index(&body) {
485            Some(latest) => Fetch::Updated {
486                latest,
487                etag: new_etag,
488            },
489            None => Fetch::Failed,
490        },
491        Err(_) => Fetch::Failed,
492    }
493}
494
495// ----- Notices -----------------------------------------------------------------
496
497/// The one-time notice shown on first interactive use.
498fn first_run_notice() -> String {
499    format!(
500        "{PKG_NAME}: checking crates.io for updates about once a day, in the background.\n\
501         {PKG_NAME}: set CT_UPDATE_CHECK=never to disable (or =weekly / =hourly / a number of seconds).\n"
502    )
503}
504
505/// The "a newer version is available" notice.
506fn update_available_notice(latest: &str, current: &str) -> String {
507    format!(
508        "{PKG_NAME}: a newer version is available: {latest} (you have {current}).\n\
509         {PKG_NAME}: update with `cargo install {PKG_NAME}` — or set CT_UPDATE_CHECK=never to silence.\n"
510    )
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn version_orders_core_and_prerelease() {
519        let v = Version::parse;
520        assert!(v("0.9.0").unwrap() > v("0.8.4").unwrap());
521        assert!(v("1.0.0").unwrap() > v("0.99.99").unwrap());
522        assert!(v("1.2.10").unwrap() > v("1.2.9").unwrap());
523        // a release outranks its own pre-release; pre-releases order lexically
524        assert!(v("1.0.0").unwrap() > v("1.0.0-rc.1").unwrap());
525        assert!(v("1.0.0-rc.2").unwrap() > v("1.0.0-rc.1").unwrap());
526        // build metadata is ignored
527        assert_eq!(v("1.2.3+abc").unwrap(), v("1.2.3").unwrap());
528        // malformed cores don't parse
529        assert!(v("1.2").is_none());
530        assert!(v("1.2.3.4").is_none());
531        assert!(v("x.y.z").is_none());
532    }
533
534    #[test]
535    fn latest_from_index_picks_highest_unyanked() {
536        let body = "\
537{\"name\":\"coding-tools\",\"vers\":\"0.8.3\",\"yanked\":false}\n\
538{\"name\":\"coding-tools\",\"vers\":\"0.8.4\",\"yanked\":false}\n\
539{\"name\":\"coding-tools\",\"vers\":\"0.9.0\",\"yanked\":true}\n\
540not even json\n\
541{\"name\":\"coding-tools\",\"vers\":\"0.8.10\",\"yanked\":false}\n";
542        assert_eq!(latest_from_index(body).as_deref(), Some("0.8.10"));
543        // an all-yanked / empty document yields nothing
544        assert_eq!(latest_from_index("").as_deref(), None);
545        assert_eq!(
546            latest_from_index("{\"vers\":\"1.0.0\",\"yanked\":true}").as_deref(),
547            None
548        );
549    }
550
551    #[test]
552    fn state_round_trips_through_a_file() {
553        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/update");
554        let _ = std::fs::create_dir_all(&dir);
555        let path = dir.join("state.json");
556        let _ = std::fs::remove_file(&path);
557
558        // a missing file reads as default
559        assert_eq!(State::load(&path), State::default());
560
561        let s = State {
562            last_check: 111,
563            last_notified: 222,
564            latest: Some("0.9.0".to_string()),
565            etag: Some("\"abc\"".to_string()),
566            notice_shown: true,
567        };
568        assert!(s.save(&path));
569        assert_eq!(State::load(&path), s);
570
571        // a corrupt file also reads as default
572        std::fs::write(&path, "{ not json").unwrap();
573        assert_eq!(State::load(&path), State::default());
574    }
575
576    #[test]
577    fn notices_name_the_versions_and_the_off_switch() {
578        let avail = update_available_notice("0.9.0", "0.8.4");
579        assert!(
580            avail.contains("0.9.0") && avail.contains("0.8.4"),
581            "{avail}"
582        );
583        assert!(avail.contains("cargo install coding-tools"), "{avail}");
584        assert!(avail.contains("CT_UPDATE_CHECK=never"), "{avail}");
585
586        let first = first_run_notice();
587        assert!(first.contains("once a day"), "{first}");
588        assert!(first.contains("CT_UPDATE_CHECK=never"), "{first}");
589    }
590
591    #[test]
592    fn empty_string_etag_loads_as_none() {
593        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/update");
594        let _ = std::fs::create_dir_all(&dir);
595        let path = dir.join("state-empty.json");
596        std::fs::write(&path, r#"{"etag":"","latest":""}"#).unwrap();
597        let s = State::load(&path);
598        assert_eq!(s.etag, None);
599        assert_eq!(s.latest, None);
600    }
601}