1use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32
33use serde_json::{Value, json};
34
35const PKG_NAME: &str = "coding-tools";
37const REPO: &str = "https://github.com/jshook/coding-tools";
39const INDEX_HOST: &str = "https://index.crates.io";
41const STATE_FILE: &str = "update-check.json";
43pub const BG_FLAG: &str = "--update-check-run";
45const DAILY: u64 = 86_400;
47
48pub 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
80fn interval_from_env() -> Option<u64> {
82 parse_interval(std::env::var("CT_UPDATE_CHECK").ok().as_deref())
83}
84
85pub 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
110pub fn index_url(name: &str) -> String {
117 format!("{INDEX_HOST}/{}", index_path(name))
118}
119
120pub 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
157pub 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#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct Version {
180 core: (u64, u64, u64),
181 pre: Option<String>,
183}
184
185impl Version {
186 pub fn parse(s: &str) -> Option<Version> {
189 let s = s.trim();
190 let s = s.split('+').next().unwrap_or(s); 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; }
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 (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#[derive(Debug, Default, Clone, PartialEq, Eq)]
236struct State {
237 last_check: u64,
239 last_notified: u64,
241 latest: Option<String>,
243 etag: Option<String>,
245 notice_shown: bool,
247}
248
249impl State {
250 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 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
295fn 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
320fn unix_now() -> u64 {
322 SystemTime::now()
323 .duration_since(UNIX_EPOCH)
324 .map(|d| d.as_secs())
325 .unwrap_or(0)
326}
327
328pub fn on_invocation() {
336 let _ = try_on_invocation();
337}
338
339fn try_on_invocation() -> Option<()> {
340 let interval = interval_from_env()?; {
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 if tty && !state.notice_shown {
365 eprint!("{}", first_run_notice());
366 state.notice_shown = true;
367 }
368
369 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 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
392fn 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
412pub fn run_background_poll() {
417 let _ = try_poll();
418}
419
420fn try_poll() -> Option<()> {
421 interval_from_env()?; 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
440enum Fetch {
442 Updated {
444 latest: String,
445 etag: Option<String>,
446 },
447 NotModified,
449 Failed,
451}
452
453fn fetch(current: &str, etag: Option<&str>) -> Fetch {
455 let url = index_url(PKG_NAME);
456 let ua = format!("{PKG_NAME}/{current} ({REPO})");
457 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
495fn 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
505fn 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 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 assert_eq!(v("1.2.3+abc").unwrap(), v("1.2.3").unwrap());
528 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 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 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 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}