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) {
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
293fn 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
318fn unix_now() -> u64 {
320 SystemTime::now()
321 .duration_since(UNIX_EPOCH)
322 .map(|d| d.as_secs())
323 .unwrap_or(0)
324}
325
326pub fn on_invocation() {
334 let _ = try_on_invocation();
335}
336
337fn try_on_invocation() -> Option<()> {
338 let interval = interval_from_env()?; 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 if tty && !state.notice_shown {
353 eprint!("{}", first_run_notice());
354 state.notice_shown = true;
355 }
356
357 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 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
380fn 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
400pub fn run_background_poll() {
405 let _ = try_poll();
406}
407
408fn try_poll() -> Option<()> {
409 interval_from_env()?; 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
428enum Fetch {
430 Updated {
432 latest: String,
433 etag: Option<String>,
434 },
435 NotModified,
437 Failed,
439}
440
441fn fetch(current: &str, etag: Option<&str>) -> Fetch {
443 let url = index_url(PKG_NAME);
444 let ua = format!("{PKG_NAME}/{current} ({REPO})");
445 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
483fn 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
493fn 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 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 assert_eq!(v("1.2.3+abc").unwrap(), v("1.2.3").unwrap());
516 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 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 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 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}