Skip to main content

unparser_shared/
cli_update.rs

1//! Update notice and self-update for the family's command-line tools, from GitHub releases.
2//!
3//! Each tool's CLI used to carry its own copy of this, and the copies had drifted apart.
4//! What they share:
5//!
6//! - **The notice is cheap, and it can be turned off.** A command may ask GitHub for the
7//!   latest release at most once a day. The answer is cached, so the other runs of the day
8//!   make no network request and wait for nothing. No check is made when the user turns it off
9//!   ([`UpdateCheck::start`]'s `disabled`, wired to a `--no-update-check` flag, or the
10//!   `<TOOL>_NO_UPDATE_CHECK` / `UNPARSER_NO_UPDATE_CHECK` environment variables), in CI (`CI` is
11//!   set), or when stderr, where the notice is printed, is not a terminal.
12//! - **The update installs the tool's own archive.** A release carries the CLI archive
13//!   `<bin>-<os>-<arch>-v<version>.<ext>` and the C library archive `lib<bin>-…`, whose name
14//!   contains the CLI's. Asset names are therefore compared for equality, never by substring.
15
16use 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
26/// How long a cached answer stands before a command asks GitHub again.
27const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
28
29/// How long a command that did ask waits for the answer at exit. Once a day at most.
30const CHECK_WAIT: Duration = Duration::from_millis(500);
31
32/// The tool an update is for.
33#[derive(Debug, Clone, Copy)]
34pub struct Tool {
35    /// GitHub owner of the repository the releases live in.
36    pub repo_owner: &'static str,
37    /// GitHub repository name.
38    pub repo_name: &'static str,
39    /// The executable's name, without an extension; the archives are named after it.
40    pub bin_name: &'static str,
41    /// The crate `cargo install` installs it from.
42    pub crate_name: &'static str,
43    /// The running version (`env!("CARGO_PKG_VERSION")` of the CLI crate).
44    pub current_version: &'static str,
45}
46
47impl Tool {
48    /// The environment variable that turns the notice off for this tool alone:
49    /// `UNPDF_NO_UPDATE_CHECK` for `unpdf`.
50    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
62/// The variable that turns the notice off for every tool of the family.
63pub const FAMILY_OPT_OUT_VARIABLE: &str = "UNPARSER_NO_UPDATE_CHECK";
64
65/// Why no check is made, if one is not.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Skip {
68    /// The caller passed `disabled` — the tool's `--no-update-check` flag.
69    Flag,
70    /// An opt-out variable is set to something other than empty or `0`.
71    Environment,
72    /// `CI` is set.
73    Ci,
74    /// stderr is not a terminal, so nobody would see the notice.
75    NotInteractive,
76}
77
78/// Whether a run should look for an update, given its environment. `env` reads a variable,
79/// `stderr_is_terminal` says where the notice would go.
80pub 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/// A cached answer: when GitHub was last asked, and the latest release it named.
101#[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
132/// Where the answer is cached: the platform's per-user cache directory, `<tool>/update-check`.
133fn 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    // A cache that cannot be written only means asking again next time.
155    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    /// A fresh cached answer — nothing to wait for.
182    Known(String),
183    /// Asking GitHub on a background thread.
184    Asking(mpsc::Receiver<Option<String>>),
185}
186
187/// A run's update check: started when the command starts, finished when it ends.
188pub struct UpdateCheck {
189    tool: Tool,
190    pending: Pending,
191}
192
193impl UpdateCheck {
194    /// Start the check for `tool`, or `None` when no check should be made (see
195    /// [`skip_reason`]; `disabled` is the tool's `--no-update-check`). A fresh cached answer
196    /// is used as it is; otherwise GitHub is asked on a background thread while the command
197    /// runs.
198    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    /// Print the notice on stderr if a newer release exists. Waits at most half a second
239    /// for an answer still on its way — on the one run a day that asks.
240    ///
241    /// stderr, not stdout: commands write document data to stdout, and a notice there would
242    /// corrupt piped output.
243    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
269/// Platform info for asset matching.
270struct PlatformInfo {
271    /// Human-friendly OS name (windows, linux, macos)
272    os_name: &'static str,
273    /// Human-friendly arch name (x86_64, aarch64, x86_64-musl)
274    arch_name: &'static str,
275    /// Rust target triple (x86_64-pc-windows-msvc, etc.)
276    target_triple: &'static str,
277    /// Archive extension (zip for Windows, tar.gz for Unix)
278    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
289/// The release archive naming for a platform.
290///
291/// A musl build is its own platform: it is what runs where the glibc build cannot (Alpine,
292/// distroless images), so it must update to the musl archive and never to the glibc one,
293/// which would install a binary that does not start. Kept apart from the `cfg!` probe so that
294/// every platform's answer is testable on any host.
295fn 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        // No release archive is built for anything else; "unknown" matches no asset.
308        (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
318/// The CLI archive names to look for, in preference order.
319fn 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        // Human-friendly (preferred): <bin>-windows-x86_64-v0.2.0.zip
329        format!("{bin}-{os}-{arch}-v{v}.{ext}"),
330        format!("{bin}-{os}-{arch}-{v}.{ext}"),
331        // Target triple: <bin>-x86_64-pc-windows-msvc-v0.2.0.zip
332        format!("{bin}-{triple}-v{v}.{ext}"),
333        format!("{bin}-{triple}-{v}.{ext}"),
334    ]
335}
336
337/// The first of `patterns` that is exactly one of `asset_names`.
338fn 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
345/// Whether the running executable was installed by `cargo install` (it lives in `.cargo/bin`).
346fn 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
353/// The `update` command: report the latest release and, unless `check_only`, replace the
354/// running executable with it. `force` reinstalls even when up to date. A `cargo install`
355/// is pointed at `cargo install` instead.
356pub 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, &current_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    // The direct download URL: the API asset URL serves the binary only to a request that
444    // carries `Accept: application/octet-stream`, which self_update 1.x can no longer set.
445    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    /// `VAR=0` and `VAR=` read as «not set», as shells and CI systems write them.
519    #[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    /// A release carries two archives whose names differ only by a `lib` prefix: the CLI
564    /// executable and the shared library for the language bindings. Substring matching would
565    /// let `libunpdf-…` satisfy a search for `unpdf-…`, and the updater would replace the
566    /// executable with a library — silently, since both are valid archives.
567    #[test]
568    fn cli_archive_is_chosen_over_the_library_archive() {
569        let patterns = asset_patterns("unpdf", &windows_x86_64(), "0.9.0");
570        // Listed library-first, so a match that scans the release in order fails here too.
571        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    /// Each Linux x86_64 build picks its own archive: a musl build that installed the glibc
582    /// one would replace itself with a binary that does not start where it runs.
583    #[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}