1use colored::Colorize;
17use self_update::backends::github::ReleaseList;
18use self_update::update::Release;
19use semver::Version;
20use std::io::IsTerminal;
21use std::path::PathBuf;
22use std::sync::mpsc;
23use std::thread;
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
28
29const CHECK_WAIT: Duration = Duration::from_millis(500);
31
32#[derive(Debug, Clone, Copy)]
34pub struct Tool {
35 pub repo_owner: &'static str,
37 pub repo_name: &'static str,
39 pub bin_name: &'static str,
41 pub crate_name: &'static str,
43 pub current_version: &'static str,
45}
46
47impl Tool {
48 pub fn opt_out_variable(&self) -> String {
51 format!(
52 "{}_NO_UPDATE_CHECK",
53 self.bin_name.to_ascii_uppercase().replace('-', "_")
54 )
55 }
56
57 fn command(&self) -> String {
58 format!("{} update", self.bin_name)
59 }
60}
61
62pub const FAMILY_OPT_OUT_VARIABLE: &str = "UNPARSER_NO_UPDATE_CHECK";
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Skip {
68 Flag,
70 Environment,
72 Ci,
74 NotInteractive,
76}
77
78pub fn skip_reason(
81 tool: &Tool,
82 disabled: bool,
83 env: impl Fn(&str) -> Option<String>,
84 stderr_is_terminal: bool,
85) -> Option<Skip> {
86 let set = |name: &str| env(name).is_some_and(|v| !v.is_empty() && v != "0");
87 if disabled {
88 Some(Skip::Flag)
89 } else if set(&tool.opt_out_variable()) || set(FAMILY_OPT_OUT_VARIABLE) {
90 Some(Skip::Environment)
91 } else if env("CI").is_some_and(|v| !v.is_empty()) {
92 Some(Skip::Ci)
93 } else if !stderr_is_terminal {
94 Some(Skip::NotInteractive)
95 } else {
96 None
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102struct Cached {
103 checked_at: u64,
104 latest: String,
105}
106
107impl Cached {
108 fn parse(text: &str) -> Option<Self> {
109 let (at, latest) = text.trim().split_once(' ')?;
110 Some(Self {
111 checked_at: at.parse().ok()?,
112 latest: latest.to_string(),
113 })
114 }
115
116 fn render(&self) -> String {
117 format!("{} {}\n", self.checked_at, self.latest)
118 }
119
120 fn is_fresh(&self, now: u64) -> bool {
121 now.saturating_sub(self.checked_at) < CHECK_INTERVAL.as_secs()
122 }
123}
124
125fn now() -> u64 {
126 SystemTime::now()
127 .duration_since(UNIX_EPOCH)
128 .map(|d| d.as_secs())
129 .unwrap_or(0)
130}
131
132fn cache_file(tool: &Tool) -> Option<PathBuf> {
134 let var = |name: &str| {
135 std::env::var_os(name)
136 .filter(|v| !v.is_empty())
137 .map(PathBuf::from)
138 };
139 let base = if cfg!(windows) {
140 var("LOCALAPPDATA")
141 } else if cfg!(target_os = "macos") {
142 var("HOME").map(|home| home.join("Library").join("Caches"))
143 } else {
144 var("XDG_CACHE_HOME").or_else(|| var("HOME").map(|home| home.join(".cache")))
145 }?;
146 Some(base.join(tool.bin_name).join("update-check"))
147}
148
149fn read_cache(tool: &Tool) -> Option<Cached> {
150 Cached::parse(&std::fs::read_to_string(cache_file(tool)?).ok()?)
151}
152
153fn write_cache(tool: &Tool, cached: &Cached) {
154 if let Some(path) = cache_file(tool) {
156 if let Some(dir) = path.parent() {
157 let _ = std::fs::create_dir_all(dir);
158 }
159 let _ = std::fs::write(path, cached.render());
160 }
161}
162
163fn latest_release(tool: &Tool) -> Result<Release, Box<dyn std::error::Error>> {
164 let releases = ReleaseList::configure()
165 .repo_owner(tool.repo_owner)
166 .repo_name(tool.repo_name)
167 .build()?
168 .fetch()?;
169 let latest = releases.latest().ok_or("No releases found on GitHub.")?;
170 Ok(latest.clone())
171}
172
173fn newer(current: &str, latest: &str) -> bool {
174 match (Version::parse(current), Version::parse(latest)) {
175 (Ok(current), Ok(latest)) => latest > current,
176 _ => false,
177 }
178}
179
180enum Pending {
181 Known(String),
183 Asking(mpsc::Receiver<Option<String>>),
185}
186
187pub struct UpdateCheck {
189 tool: Tool,
190 pending: Pending,
191}
192
193impl UpdateCheck {
194 pub fn start(tool: Tool, disabled: bool) -> Option<Self> {
199 skip_reason(
200 &tool,
201 disabled,
202 |name| std::env::var(name).ok(),
203 std::io::stderr().is_terminal(),
204 )
205 .is_none()
206 .then(|| Self::begin(tool))
207 }
208
209 fn begin(tool: Tool) -> Self {
210 if let Some(cached) = read_cache(&tool).filter(|c| c.is_fresh(now())) {
211 return Self {
212 tool,
213 pending: Pending::Known(cached.latest),
214 };
215 }
216 let (tx, rx) = mpsc::channel();
217 thread::spawn(move || {
218 let latest = latest_release(&tool)
219 .ok()
220 .map(|release| release.version().trim_start_matches('v').to_string());
221 if let Some(latest) = &latest {
222 write_cache(
223 &tool,
224 &Cached {
225 checked_at: now(),
226 latest: latest.clone(),
227 },
228 );
229 }
230 let _ = tx.send(latest);
231 });
232 Self {
233 tool,
234 pending: Pending::Asking(rx),
235 }
236 }
237
238 pub fn finish(self) {
244 let latest = match self.pending {
245 Pending::Known(latest) => Some(latest),
246 Pending::Asking(rx) => rx.recv_timeout(CHECK_WAIT).ok().flatten(),
247 };
248 if let Some(latest) = latest.filter(|l| newer(self.tool.current_version, l)) {
249 eprintln!();
250 eprintln!(
251 "{} {} → {} available! Run '{}' to update.",
252 "Update:".yellow().bold(),
253 self.tool.current_version,
254 latest.green(),
255 self.tool.command().cyan()
256 );
257 eprintln!(
258 "{}",
259 format!(
260 "(--no-update-check or {}=1 turns this check off)",
261 self.tool.opt_out_variable()
262 )
263 .dimmed()
264 );
265 }
266 }
267}
268
269struct PlatformInfo {
271 os_name: &'static str,
273 arch_name: &'static str,
275 target_triple: &'static str,
277 archive_ext: &'static str,
279}
280
281fn current_platform() -> PlatformInfo {
282 platform_for(
283 std::env::consts::OS,
284 std::env::consts::ARCH,
285 cfg!(target_env = "musl"),
286 )
287}
288
289fn platform_for(os: &'static str, arch: &'static str, musl: bool) -> PlatformInfo {
296 let (os_name, arch_name, target_triple, archive_ext) = match (os, arch, musl) {
297 ("windows", "x86_64", _) => ("windows", "x86_64", "x86_64-pc-windows-msvc", "zip"),
298 ("linux", "x86_64", false) => ("linux", "x86_64", "x86_64-unknown-linux-gnu", "tar.gz"),
299 ("linux", "x86_64", true) => (
300 "linux",
301 "x86_64-musl",
302 "x86_64-unknown-linux-musl",
303 "tar.gz",
304 ),
305 ("macos", "x86_64", _) => ("macos", "x86_64", "x86_64-apple-darwin", "tar.gz"),
306 ("macos", "aarch64", _) => ("macos", "aarch64", "aarch64-apple-darwin", "tar.gz"),
307 (os, arch, _) => (os, arch, "unknown", "tar.gz"),
309 };
310 PlatformInfo {
311 os_name,
312 arch_name,
313 target_triple,
314 archive_ext,
315 }
316}
317
318fn asset_patterns(bin: &str, platform: &PlatformInfo, version: &str) -> Vec<String> {
320 let v = version.trim_start_matches('v');
321 let (os, arch, triple, ext) = (
322 platform.os_name,
323 platform.arch_name,
324 platform.target_triple,
325 platform.archive_ext,
326 );
327 vec![
328 format!("{bin}-{os}-{arch}-v{v}.{ext}"),
330 format!("{bin}-{os}-{arch}-{v}.{ext}"),
331 format!("{bin}-{triple}-v{v}.{ext}"),
333 format!("{bin}-{triple}-{v}.{ext}"),
334 ]
335}
336
337fn find_matching_asset(asset_names: &[String], patterns: &[String]) -> Option<String> {
339 patterns
340 .iter()
341 .find(|pattern| asset_names.iter().any(|name| name == *pattern))
342 .cloned()
343}
344
345fn is_cargo_install() -> bool {
347 std::env::current_exe().is_ok_and(|exe| {
348 let path = exe.to_string_lossy();
349 path.contains(".cargo") && path.contains("bin")
350 })
351}
352
353pub fn run_update(
357 tool: &Tool,
358 check_only: bool,
359 force: bool,
360) -> Result<(), Box<dyn std::error::Error>> {
361 let current_version = tool.current_version;
362 println!("{} {}", "Current version:".cyan().bold(), current_version);
363 println!("{}", "Checking for updates...".cyan());
364
365 let latest = latest_release(tool)?;
366 let latest_version = latest.version().trim_start_matches('v').to_string();
367 println!("{} {}", "Latest version:".cyan().bold(), latest_version);
368 write_cache(
369 tool,
370 &Cached {
371 checked_at: now(),
372 latest: latest_version.clone(),
373 },
374 );
375
376 let current = Version::parse(current_version)?;
377 let latest_ver = Version::parse(&latest_version)?;
378 if current >= latest_ver && !force {
379 println!();
380 println!("{} You are running the latest version!", "✓".green().bold());
381 return Ok(());
382 }
383 if current < latest_ver {
384 println!();
385 println!(
386 "{} New version available: {} → {}",
387 "↑".yellow().bold(),
388 current_version.yellow(),
389 latest_version.green().bold()
390 );
391 }
392
393 let cargo_command = format!("cargo install {}", tool.crate_name);
394 if check_only {
395 println!();
396 let command = if is_cargo_install() {
397 cargo_command
398 } else {
399 tool.command()
400 };
401 println!("Run '{}' to update.", command.cyan());
402 return Ok(());
403 }
404 if is_cargo_install() {
405 println!();
406 println!(
407 "{} Installed via cargo. Please run:",
408 "Note:".yellow().bold()
409 );
410 println!(" {}", cargo_command.cyan().bold());
411 println!();
412 println!(
413 "{}",
414 "This ensures proper integration with your Rust toolchain.".dimmed()
415 );
416 return Ok(());
417 }
418
419 println!();
420 println!("{}", "Downloading update...".cyan());
421 let patterns = asset_patterns(tool.bin_name, ¤t_platform(), &latest_version);
422 let asset_names: Vec<String> = latest
423 .assets()
424 .iter()
425 .map(|a| a.name().to_string())
426 .collect();
427 let Some(asset_name) = find_matching_asset(&asset_names, &patterns) else {
428 println!("{}", "No matching asset found.".red());
429 println!("{}", "Searched for:".dimmed());
430 for p in &patterns {
431 println!(" - {}", p.dimmed());
432 }
433 println!();
434 println!(
435 "{} {}",
436 "Available assets:".dimmed(),
437 asset_names.join(", ")
438 );
439 return Err("No compatible binary found for this platform".into());
440 };
441 println!("{} {}", "Found asset:".dimmed(), asset_name.dimmed());
442
443 let download_url = format!(
446 "https://github.com/{}/{}/releases/download/v{}/{}",
447 tool.repo_owner, tool.repo_name, latest_version, asset_name
448 );
449 let tmp_dir = tempfile::TempDir::new()?;
450 let tmp_archive_path = tmp_dir.path().join(&asset_name);
451 let mut tmp_archive = std::fs::File::create(&tmp_archive_path)?;
452 let mut download = self_update::Download::from_url(&download_url);
453 download.show_download_progress(true);
454 download.download_to(&mut tmp_archive)?;
455
456 println!("{}", "Extracting archive...".dimmed());
457 let bin_name = format!("{}{}", tool.bin_name, std::env::consts::EXE_SUFFIX);
458 self_update::Extract::from_source(&tmp_archive_path).extract_file(tmp_dir.path(), &bin_name)?;
459 self_replace::self_replace(tmp_dir.path().join(&bin_name))?;
460
461 println!();
462 println!(
463 "{} Successfully updated to v{}!",
464 "✓".green().bold(),
465 latest_version
466 );
467 println!();
468 println!("Restart {} to use the new version.", tool.bin_name);
469 Ok(())
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use std::collections::HashMap;
476
477 const TOOL: Tool = Tool {
478 repo_owner: "iyulab",
479 repo_name: "unpdf",
480 bin_name: "unpdf",
481 crate_name: "unpdf-cli",
482 current_version: "0.9.0",
483 };
484
485 fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
486 let vars: HashMap<String, String> = vars
487 .iter()
488 .map(|(k, v)| (k.to_string(), v.to_string()))
489 .collect();
490 move |name| vars.get(name).cloned()
491 }
492
493 #[test]
494 fn an_interactive_run_with_nothing_set_checks() {
495 assert_eq!(skip_reason(&TOOL, false, env(&[]), true), None);
496 }
497
498 #[test]
499 fn every_way_of_turning_the_check_off_is_honoured() {
500 assert_eq!(skip_reason(&TOOL, true, env(&[]), true), Some(Skip::Flag));
501 for var in ["UNPDF_NO_UPDATE_CHECK", FAMILY_OPT_OUT_VARIABLE] {
502 assert_eq!(
503 skip_reason(&TOOL, false, env(&[(var, "1")]), true),
504 Some(Skip::Environment),
505 "{var}"
506 );
507 }
508 assert_eq!(
509 skip_reason(&TOOL, false, env(&[("CI", "true")]), true),
510 Some(Skip::Ci)
511 );
512 assert_eq!(
513 skip_reason(&TOOL, false, env(&[]), false),
514 Some(Skip::NotInteractive)
515 );
516 }
517
518 #[test]
520 fn an_opt_out_set_to_zero_or_empty_does_not_turn_the_check_off() {
521 for value in ["0", ""] {
522 assert_eq!(
523 skip_reason(&TOOL, false, env(&[("UNPDF_NO_UPDATE_CHECK", value)]), true),
524 None
525 );
526 }
527 }
528
529 #[test]
530 fn the_opt_out_variable_is_named_after_the_executable() {
531 assert_eq!(TOOL.opt_out_variable(), "UNPDF_NO_UPDATE_CHECK");
532 let dashed = Tool {
533 bin_name: "my-tool",
534 ..TOOL
535 };
536 assert_eq!(dashed.opt_out_variable(), "MY_TOOL_NO_UPDATE_CHECK");
537 }
538
539 #[test]
540 fn a_cached_answer_stands_for_a_day() {
541 let cached = Cached {
542 checked_at: 1_000_000,
543 latest: "0.9.1".into(),
544 };
545 assert_eq!(Cached::parse(&cached.render()), Some(cached.clone()));
546 assert!(cached.is_fresh(1_000_000 + 23 * 3600));
547 assert!(!cached.is_fresh(1_000_000 + 24 * 3600));
548 assert_eq!(Cached::parse("garbage"), None);
549 }
550
551 #[test]
552 fn only_a_newer_release_is_an_update() {
553 assert!(newer("0.9.0", "0.10.0"));
554 assert!(!newer("0.9.0", "0.9.0"));
555 assert!(!newer("0.10.0", "0.9.0"));
556 assert!(!newer("0.9.0", "not-a-version"));
557 }
558
559 fn windows_x86_64() -> PlatformInfo {
560 platform_for("windows", "x86_64", false)
561 }
562
563 #[test]
568 fn cli_archive_is_chosen_over_the_library_archive() {
569 let patterns = asset_patterns("unpdf", &windows_x86_64(), "0.9.0");
570 let release = vec![
572 "libunpdf-windows-x86_64-v0.9.0.zip".to_string(),
573 "unpdf-windows-x86_64-v0.9.0.zip".to_string(),
574 ];
575 assert_eq!(
576 find_matching_asset(&release, &patterns).as_deref(),
577 Some("unpdf-windows-x86_64-v0.9.0.zip")
578 );
579 }
580
581 #[test]
584 fn a_musl_build_updates_to_the_musl_archive() {
585 let release = vec![
586 "unpdf-linux-x86_64-v0.9.0.tar.gz".to_string(),
587 "unpdf-linux-x86_64-musl-v0.9.0.tar.gz".to_string(),
588 "libunpdf-linux-x86_64-musl-v0.9.0.tar.gz".to_string(),
589 ];
590 let musl = asset_patterns("unpdf", &platform_for("linux", "x86_64", true), "0.9.0");
591 assert_eq!(
592 find_matching_asset(&release, &musl).as_deref(),
593 Some("unpdf-linux-x86_64-musl-v0.9.0.tar.gz")
594 );
595 let gnu = asset_patterns("unpdf", &platform_for("linux", "x86_64", false), "0.9.0");
596 assert_eq!(
597 find_matching_asset(&release, &gnu).as_deref(),
598 Some("unpdf-linux-x86_64-v0.9.0.tar.gz")
599 );
600 }
601
602 #[test]
603 fn a_musl_build_never_falls_back_to_the_glibc_archive() {
604 let release = vec!["unpdf-linux-x86_64-v0.9.0.tar.gz".to_string()];
605 let musl = asset_patterns("unpdf", &platform_for("linux", "x86_64", true), "0.9.0");
606 assert_eq!(find_matching_asset(&release, &musl), None);
607 }
608
609 #[test]
610 fn each_released_platform_names_its_own_archive() {
611 for (os, arch, musl, expected) in [
612 (
613 "windows",
614 "x86_64",
615 false,
616 "undoc-windows-x86_64-v0.9.0.zip",
617 ),
618 ("linux", "x86_64", false, "undoc-linux-x86_64-v0.9.0.tar.gz"),
619 (
620 "linux",
621 "x86_64",
622 true,
623 "undoc-linux-x86_64-musl-v0.9.0.tar.gz",
624 ),
625 ("macos", "x86_64", false, "undoc-macos-x86_64-v0.9.0.tar.gz"),
626 (
627 "macos",
628 "aarch64",
629 false,
630 "undoc-macos-aarch64-v0.9.0.tar.gz",
631 ),
632 ] {
633 let patterns = asset_patterns("undoc", &platform_for(os, arch, musl), "0.9.0");
634 assert_eq!(patterns[0], expected, "{os} {arch} musl={musl}");
635 }
636 }
637
638 #[test]
639 fn a_library_only_release_matches_nothing() {
640 let patterns = asset_patterns("unpdf", &windows_x86_64(), "0.9.0");
641 let release = vec!["libunpdf-windows-x86_64-v0.9.0.zip".to_string()];
642 assert_eq!(find_matching_asset(&release, &patterns), None);
643 }
644
645 #[test]
646 fn naming_variants_resolve_in_preference_order() {
647 let patterns = asset_patterns("unpdf", &windows_x86_64(), "0.9.0");
648 let triple_only = vec!["unpdf-x86_64-pc-windows-msvc-0.9.0.zip".to_string()];
649 assert_eq!(
650 find_matching_asset(&triple_only, &patterns).as_deref(),
651 Some("unpdf-x86_64-pc-windows-msvc-0.9.0.zip")
652 );
653 let several = vec![
654 "unpdf-x86_64-pc-windows-msvc-v0.9.0.zip".to_string(),
655 "unpdf-windows-x86_64-v0.9.0.zip".to_string(),
656 ];
657 assert_eq!(
658 find_matching_asset(&several, &patterns).as_deref(),
659 Some("unpdf-windows-x86_64-v0.9.0.zip")
660 );
661 }
662
663 #[test]
664 fn a_v_prefixed_tag_produces_the_same_patterns_as_a_bare_version() {
665 let platform = windows_x86_64();
666 assert_eq!(
667 asset_patterns("unpdf", &platform, "v0.9.0"),
668 asset_patterns("unpdf", &platform, "0.9.0")
669 );
670 }
671
672 #[test]
673 fn another_platforms_or_versions_archive_is_not_matched() {
674 let patterns = asset_patterns("unpdf", &windows_x86_64(), "0.9.0");
675 let release = vec![
676 "unpdf-linux-x86_64-v0.9.0.tar.gz".to_string(),
677 "unpdf-macos-aarch64-v0.9.0.tar.gz".to_string(),
678 "unpdf-windows-x86_64-v0.8.0.zip".to_string(),
679 ];
680 assert_eq!(find_matching_asset(&release, &patterns), None);
681 }
682}