glory_cli/ext/
exe.rs

1use crate::{
2    ext::anyhow::{bail, Context, Result},
3    logger::GRAY,
4};
5use bytes::Bytes;
6use once_cell::sync::Lazy;
7use std::{
8    fs::{self, File},
9    io::{Cursor, Write},
10    path::{Path, PathBuf},
11    sync::Once,
12};
13
14use std::env;
15
16use zip::ZipArchive;
17
18use super::util::{is_linux_musl_env, os_arch};
19
20use reqwest::ClientBuilder;
21#[cfg(target_family = "unix")]
22use std::os::unix::prelude::PermissionsExt;
23use std::time::{Duration, SystemTime};
24
25use semver::Version;
26
27#[derive(Debug)]
28pub struct ExeMeta {
29    name: &'static str,
30    version: String,
31    url: String,
32    exe: String,
33    manual: String,
34}
35
36static ON_STARTUP_DEBUG_ONCE: Lazy<Once> = Lazy::new(|| Once::new());
37
38pub const ENV_VAR_GLORY_CARGO_GENERATE_VERSION: &str = "GLORY_CARGO_GENERATE_VERSION";
39pub const ENV_VAR_GLORY_TAILWIND_VERSION: &str = "GLORY_TAILWIND_VERSION";
40pub const ENV_VAR_GLORY_SASS_VERSION: &str = "GLORY_SASS_VERSION";
41pub const ENV_VAR_GLORY_WASM_OPT_VERSION: &str = "GLORY_WASM_OPT_VERSION";
42
43impl ExeMeta {
44    #[allow(clippy::wrong_self_convention)]
45    fn from_global_path(&self) -> Option<PathBuf> {
46        which::which(self.name).ok()
47    }
48
49    fn get_name(&self) -> String {
50        format!("{}-{}", &self.name, &self.version)
51    }
52
53    async fn cached(&self) -> Result<PathBuf> {
54        let cache_dir = get_cache_dir()?.join(self.get_name());
55        self._with_cache_dir(&cache_dir).await
56    }
57
58    async fn _with_cache_dir(&self, cache_dir: &Path) -> Result<PathBuf> {
59        let exe_dir = cache_dir.join(self.get_name());
60        let c = ExeCache { meta: self, exe_dir };
61        c.get().await
62    }
63
64    #[cfg(test)]
65    pub async fn with_cache_dir(&self, cache_dir: &Path) -> Result<PathBuf> {
66        self._with_cache_dir(cache_dir).await
67    }
68}
69
70pub struct ExeCache<'a> {
71    exe_dir: PathBuf,
72    meta: &'a ExeMeta,
73}
74
75impl<'a> ExeCache<'a> {
76    fn exe_in_cache(&self) -> Result<PathBuf> {
77        let exe_path = self.exe_dir.join(PathBuf::from(&self.meta.exe));
78
79        if !exe_path.exists() {
80            bail!("The path {exe_path:?} doesn't exist");
81        }
82
83        Ok(exe_path)
84    }
85
86    async fn fetch_archive(&self) -> Result<Bytes> {
87        log::debug!("Install downloading {} {}", self.meta.name, GRAY.paint(&self.meta.url));
88
89        let response = reqwest::get(&self.meta.url).await?;
90
91        match response.status().is_success() {
92            true => Ok(response.bytes().await?),
93            false => bail!("Could not download from {}", self.meta.url),
94        }
95    }
96
97    fn extract_downloaded(&self, data: &Bytes) -> Result<()> {
98        if self.meta.url.ends_with(".zip") {
99            extract_zip(data, &self.exe_dir)?;
100        } else if self.meta.url.ends_with(".tar.gz") {
101            extract_tar(data, &self.exe_dir)?;
102        } else {
103            self.write_binary(data)
104                .context(format!("Could not write binary {}", self.meta.get_name()))?;
105        }
106
107        log::debug!("Install decompressing {} {}", self.meta.name, GRAY.paint(self.exe_dir.to_string_lossy()));
108
109        Ok(())
110    }
111
112    fn write_binary(&self, data: &Bytes) -> Result<()> {
113        fs::create_dir_all(&self.exe_dir).unwrap();
114        let path = self.exe_dir.join(Path::new(&self.meta.exe));
115        let mut file = File::create(&path).unwrap();
116        file.write_all(data).context(format!("Error writing binary file: {:?}", path))?;
117
118        #[cfg(target_family = "unix")]
119        {
120            let mut perm = fs::metadata(&path)?.permissions();
121            // https://chmod-calculator.com
122            // read and execute for owner and group
123            perm.set_mode(0o550);
124            fs::set_permissions(&path, perm)?;
125        }
126        Ok(())
127    }
128
129    async fn download(&self) -> Result<PathBuf> {
130        log::info!("Command installing {} ...", self.meta.get_name());
131
132        let data = self
133            .fetch_archive()
134            .await
135            .context(format!("Could not download {}", self.meta.get_name()))?;
136
137        self.extract_downloaded(&data)
138            .context(format!("Could not extract {}", self.meta.get_name()))?;
139
140        let binary_path = self.exe_in_cache().context(format!(
141            "Binary downloaded and extracted but could still not be found at {:?}",
142            self.exe_dir
143        ))?;
144        log::info!("Command {} installed.", self.meta.get_name());
145        Ok(binary_path)
146    }
147
148    async fn get(&self) -> Result<PathBuf> {
149        if let Ok(path) = self.exe_in_cache() {
150            Ok(path)
151        } else {
152            self.download().await
153        }
154    }
155}
156
157// there's a issue in the tar crate: https://github.com/alexcrichton/tar-rs/issues/295
158// It doesn't handle TAR sparse extensions, with data ending up in a GNUSparseFile.0 sub-folder
159fn extract_tar(src: &Bytes, dest: &Path) -> Result<()> {
160    let content = Cursor::new(src);
161    let dec = flate2::read::GzDecoder::new(content);
162    let mut arch = tar::Archive::new(dec);
163    arch.unpack(dest).dot()?;
164    Ok(())
165}
166
167fn extract_zip(src: &Bytes, dest: &Path) -> Result<()> {
168    let content = Cursor::new(src);
169    let mut arch = ZipArchive::new(content).dot()?;
170    arch.extract(dest).dot().dot()?;
171    Ok(())
172}
173
174/// Returns the absolute path to app cache directory.
175///
176/// May return an error when system cache directory does not exist,
177/// or when it can not create app specific directory.
178///
179/// | OS       | Example                            |
180/// | -------- | ---------------------------------- |
181/// | Linux    | /home/alice/.cache/NAME           |
182/// | macOS    | /Users/Alice/Library/Caches/NAME  |
183/// | Windows  | C:\Users\Alice\AppData\Local\NAME |
184fn get_cache_dir() -> Result<PathBuf> {
185    let dir = dirs::cache_dir()
186        .ok_or_else(|| anyhow::anyhow!("Cache directory does not exist"))?
187        .join("glory-cli");
188
189    if !dir.exists() {
190        fs::create_dir_all(&dir).context(format!("Could not create dir {dir:?}"))?;
191    }
192
193    ON_STARTUP_DEBUG_ONCE.call_once(|| {
194        log::debug!("Command cache dir: {}", dir.to_string_lossy());
195    });
196
197    Ok(dir)
198}
199
200#[derive(Debug, Hash, Eq, PartialEq)]
201pub enum Exe {
202    CargoGenerate,
203    Sass,
204    WasmOpt,
205    Tailwind,
206}
207
208impl Exe {
209    pub async fn get(&self) -> Result<PathBuf> {
210        let meta = self.meta().await?;
211
212        let path = if let Some(path) = meta.from_global_path() {
213            path
214        } else if cfg!(feature = "no_downloads") {
215            bail!(
216                "{} is required but was not found. Please install it using your OS's tool of choice",
217                &meta.name
218            );
219        } else {
220            meta.cached().await.context(meta.manual)?
221        };
222
223        log::debug!("Command using {} {} {}", &meta.name, &meta.version, GRAY.paint(path.to_string_lossy()));
224
225        Ok(path)
226    }
227
228    pub async fn meta(&self) -> Result<ExeMeta> {
229        let (target_os, target_arch) = os_arch().unwrap();
230
231        let exe = match self {
232            // There's a problem with upgrading cargo-generate because the tar file cannot be extracted
233            // due to missing support for https://github.com/alexcrichton/tar-rs/pull/298
234            // The tar extracts ok, but contains a folder `GNUSparseFile.0` which contains a file `cargo-generate`
235            // that has not been fully extracted.
236            // let command = &CommandCargoGenerate as &dyn Command;
237            Exe::CargoGenerate => CommandCargoGenerate.exe_meta(target_os, target_arch).await.dot()?,
238            Exe::Sass => CommandSass.exe_meta(target_os, target_arch).await.dot()?,
239            Exe::WasmOpt => CommandWasmOpt.exe_meta(target_os, target_arch).await.dot()?,
240            Exe::Tailwind => CommandTailwind.exe_meta(target_os, target_arch).await.dot()?,
241        };
242
243        Ok(exe)
244    }
245}
246
247/// Tailwind uses the 'vMaj.Min.Pat' format.
248/// WASM opt uses 'version_NNN' format.
249/// Cargo-generate has the 'vX.Y.Z' format
250/// We generally want to keep the suffix intact,
251/// as it carries classifiers, etc, but strip non-ascii
252/// digits from the prefix.
253#[inline]
254fn sanitize_version_prefix(ver_string: &str) -> String {
255    ver_string.chars().skip_while(|c| !c.is_ascii_digit() || *c == '_').collect::<String>()
256}
257
258/// Attempts to convert a non-semver version string to a semver one.
259/// E.g. WASM Opt uses `version_112`, which is not semver even if
260/// we strip the prefix, treat it as `112.0.0`
261fn normalize_version(ver_string: &str) -> Option<Version> {
262    let ver_string = sanitize_version_prefix(ver_string);
263    match Version::parse(&ver_string) {
264        Ok(v) => Some(v),
265        Err(_) => match &ver_string.parse::<u64>() {
266            Ok(num) => Some(Version::new(*num, 0, 0)),
267            Err(_) => match Version::parse(format!("{ver_string}.0").as_str()) {
268                Ok(v) => Some(v),
269                Err(e) => {
270                    log::error!("Command failed to normalize version {ver_string}: {e}");
271                    None
272                }
273            },
274        },
275    }
276}
277
278// fallback to this crate until rust stable includes async traits
279// https://github.com/dtolnay/async-trait
280use async_trait::async_trait;
281
282struct CommandTailwind;
283struct CommandWasmOpt;
284struct CommandSass;
285struct CommandCargoGenerate;
286
287#[async_trait]
288impl Command for CommandTailwind {
289    fn name(&self) -> &'static str {
290        "tailwindcss"
291    }
292    fn default_version(&self) -> &'static str {
293        "v3.3.5"
294    }
295    fn env_var_version_name(&self) -> &'static str {
296        ENV_VAR_GLORY_TAILWIND_VERSION
297    }
298    fn github_owner(&self) -> &'static str {
299        "tailwindlabs"
300    }
301    fn github_repo(&self) -> &'static str {
302        "tailwindcss"
303    }
304
305    /// Tool binary download url for the given OS and platform arch
306    fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
307        match (target_os, target_arch) {
308            ("windows", "x86_64") => Ok(format!(
309                "https://github.com/{}/{}/releases/download/{}/{}-windows-x64.exe",
310                self.github_owner(),
311                self.github_repo(),
312                version,
313                self.name()
314            )),
315            ("macos", "x86_64") => Ok(format!(
316                "https://github.com/{}/{}/releases/download/{}/{}-macos-x64",
317                self.github_owner(),
318                self.github_repo(),
319                version,
320                self.name()
321            )),
322            ("macos", "aarch64") => Ok(format!(
323                "https://github.com/{}/{}/releases/download/{}/{}-macos-arm64",
324                self.github_owner(),
325                self.github_repo(),
326                version,
327                self.name()
328            )),
329            ("linux", "x86_64") => Ok(format!(
330                "https://github.com/{}/{}/releases/download/{}/{}-linux-x64",
331                self.github_owner(),
332                self.github_repo(),
333                version,
334                self.name()
335            )),
336            ("linux", "aarch64") => Ok(format!(
337                "https://github.com/{}/{}/releases/download/{}/{}-linux-arm64",
338                self.github_owner(),
339                self.github_repo(),
340                version,
341                self.name()
342            )),
343            _ => bail!("Command [{}] failed to find a match for {}-{} ", self.name(), target_os, target_arch),
344        }
345    }
346
347    fn executable_name(&self, target_os: &str, target_arch: &str, _version: Option<&str>) -> Result<String> {
348        Ok(match (target_os, target_arch) {
349            ("windows", _) => format!("{}-windows-x64.exe", self.name()),
350            ("macos", "x86_64") => format!("{}-macos-x64", self.name()),
351            ("macos", "aarch64") => format!("{}-macos-arm64", self.name()),
352            ("linux", "x86_64") => format!("{}-linux-x64", self.name()),
353            (_, _) => format!("{}-linux-arm64", self.name()),
354        })
355    }
356
357    fn manual_install_instructions(&self) -> String {
358        "Try manually installing tailwindcss: https://tailwindcss.com/docs/installation".to_string()
359    }
360}
361
362#[async_trait]
363impl Command for CommandWasmOpt {
364    fn name(&self) -> &'static str {
365        "wasm-opt"
366    }
367    fn default_version(&self) -> &'static str {
368        "version_112"
369    }
370    fn env_var_version_name(&self) -> &'static str {
371        ENV_VAR_GLORY_WASM_OPT_VERSION
372    }
373    fn github_owner(&self) -> &'static str {
374        "WebAssembly"
375    }
376    fn github_repo(&self) -> &'static str {
377        "binaryen"
378    }
379
380    fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
381        let target = match (target_os, target_arch) {
382            ("linux", _) => "x86_64-linux",
383            ("windows", _) => "x86_64-windows",
384            ("macos", "aarch64") => "arm64-macos",
385            ("macos", "x86_64") => "x86_64-macos",
386            _ => {
387                bail!("No wasm-opt tar binary found for {target_os} {target_arch}")
388            }
389        };
390
391        Ok(format!(
392            "https://github.com/{}/{}/releases/download/{}/binaryen-{}-{}.tar.gz",
393            self.github_owner(),
394            self.github_repo(),
395            version,
396            version,
397            target
398        ))
399    }
400
401    fn executable_name(&self, target_os: &str, _target_arch: &str, version: Option<&str>) -> Result<String> {
402        if version.is_none() {
403            bail!("Version is required for WASM Opt, none provided")
404        };
405
406        Ok(match target_os {
407            "windows" => format!("binaryen-{}/bin/{}.exe", version.unwrap_or_default(), self.name()),
408            _ => format!("binaryen-{}/bin/{}", version.unwrap_or_default(), self.name()),
409        })
410    }
411
412    fn manual_install_instructions(&self) -> String {
413        "Try manually installing binaryen: https://github.com/WebAssembly/binaryen".to_string()
414    }
415}
416
417#[async_trait]
418impl Command for CommandSass {
419    fn name(&self) -> &'static str {
420        "sass"
421    }
422    fn default_version(&self) -> &'static str {
423        "1.58.3"
424    }
425    fn env_var_version_name(&self) -> &'static str {
426        ENV_VAR_GLORY_SASS_VERSION
427    }
428    fn github_owner(&self) -> &'static str {
429        "dart-musl"
430    }
431    fn github_repo(&self) -> &'static str {
432        "dart-sass"
433    }
434
435    fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
436        let is_musl_env = is_linux_musl_env();
437        Ok(if is_musl_env {
438            match target_arch {
439                "x86_64" => format!(
440                    "https://github.com/{}/{}/releases/download/{}/dart-sass-{}-linux-x64.tar.gz",
441                    self.github_owner(),
442                    self.github_repo(),
443                    version,
444                    version
445                ),
446                "aarch64" => format!(
447                    "https://github.com/{}/{}/releases/download/{}/dart-sass-{}-linux-arm64.tar.gz",
448                    self.github_owner(),
449                    self.github_repo(),
450                    version,
451                    version
452                ),
453                _ => bail!("No sass tar binary found for linux-musl {target_arch}"),
454            }
455        } else {
456            match (target_os, target_arch) {
457                // note the different github_owner
458                ("windows", "x86_64") => format!(
459                    "https://github.com/sass/{}/releases/download/{}/dart-sass-{}-windows-x64.zip",
460                    self.github_repo(),
461                    version,
462                    version
463                ),
464                ("macos" | "linux", "x86_64") => format!(
465                    "https://github.com/sass/{}/releases/download/{}/dart-sass-{}-{}-x64.tar.gz",
466                    self.github_repo(),
467                    version,
468                    version,
469                    target_os
470                ),
471                ("macos" | "linux", "aarch64") => format!(
472                    "https://github.com/sass/{}/releases/download/{}/dart-sass-{}-{}-arm64.tar.gz",
473                    self.github_repo(),
474                    version,
475                    version,
476                    target_os
477                ),
478                _ => bail!("No sass tar binary found for {target_os} {target_arch}"),
479            }
480        })
481    }
482
483    fn executable_name(&self, target_os: &str, _target_arch: &str, _version: Option<&str>) -> Result<String> {
484        Ok(match target_os {
485            "windows" => "dart-sass/sass.bat".to_string(),
486            _ => "dart-sass/sass".to_string(),
487        })
488    }
489
490    fn manual_install_instructions(&self) -> String {
491        "Try manually installing sass: https://sass-lang.com/install".to_string()
492    }
493}
494
495#[async_trait]
496impl Command for CommandCargoGenerate {
497    fn name(&self) -> &'static str {
498        "cargo-generate"
499    }
500    fn default_version(&self) -> &'static str {
501        "v0.17.3"
502    }
503    fn env_var_version_name(&self) -> &'static str {
504        ENV_VAR_GLORY_CARGO_GENERATE_VERSION
505    }
506    fn github_owner(&self) -> &'static str {
507        "cargo-generate"
508    }
509    fn github_repo(&self) -> &'static str {
510        "cargo-generate"
511    }
512
513    fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String> {
514        let is_musl_env = is_linux_musl_env();
515
516        let target = if is_musl_env {
517            match (target_os, target_arch) {
518                ("linux", "aarch64") => "aarch64-unknown-linux-musl",
519                ("linux", "x86_64") => "x86_64-unknown-linux-musl",
520                _ => bail!("No cargo-generate tar binary found for linux-musl {target_arch}"),
521            }
522        } else {
523            match (target_os, target_arch) {
524                ("macos", "aarch64") => "aarch64-apple-darwin",
525                ("linux", "aarch64") => "aarch64-unknown-linux-gnu",
526                ("macos", "x86_64") => "x86_64-apple-darwin",
527                ("windows", "x86_64") => "x86_64-pc-windows-msvc",
528                ("linux", "x86_64") => "x86_64-unknown-linux-gnu",
529                _ => bail!("No cargo-generate tar binary found for {target_os} {target_arch}"),
530            }
531        };
532
533        Ok(format!(
534            "https://github.com/{}/{}/releases/download/{}/cargo-generate-{}-{}.tar.gz",
535            self.github_owner(),
536            self.github_repo(),
537            version,
538            version,
539            target
540        ))
541    }
542
543    fn executable_name(&self, target_os: &str, _target_arch: &str, _version: Option<&str>) -> Result<String> {
544        Ok(match target_os {
545            "windows" => "cargo-generate.exe".to_string(),
546            _ => "cargo-generate".to_string(),
547        })
548    }
549
550    fn manual_install_instructions(&self) -> String {
551        "Try manually installing cargo-generate: https://github.com/cargo-generate/cargo-generate#installation".to_string()
552    }
553}
554
555#[async_trait]
556/// Template trait, implementors should only fill in
557/// the command-specific logic. Handles caching, latest
558/// version checking against the GitHub API and env var
559/// version override for a given command.
560trait Command {
561    fn name(&self) -> &'static str;
562    fn default_version(&self) -> &str;
563    fn env_var_version_name(&self) -> &str;
564    fn github_owner(&self) -> &str;
565    fn github_repo(&self) -> &str;
566    fn download_url(&self, target_os: &str, target_arch: &str, version: &str) -> Result<String>;
567    fn executable_name(&self, target_os: &str, target_arch: &str, version: Option<&str>) -> Result<String>;
568    #[allow(unused)]
569    fn manual_install_instructions(&self) -> String {
570        // default placeholder text, individual commands can override and customize
571        "Try manually installing the command".to_string()
572    }
573
574    /// Resolves and creates command metadata.
575    /// Checks if a newer version of the binary is available (once a day).
576    /// A marker file is created in the cache directory. Add `-v` flag to
577    /// the `cargo glory` command to see the OS-specific location.
578    ///
579    /// # Arguments
580    ///
581    /// * `target_os` - The target operating system.
582    /// * `target_arch` - The target architecture.
583    ///
584    /// # Returns
585    ///
586    /// Returns a `Result` containing the `ExeMeta` struct on success, or an error on failure.
587    ///
588    async fn exe_meta(&self, target_os: &str, target_arch: &str) -> Result<ExeMeta> {
589        let version = self.resolve_version().await;
590        let url = self.download_url(target_os, target_arch, version.as_str())?;
591        let exe = self.executable_name(target_os, target_arch, Some(version.as_str()))?;
592        Ok(ExeMeta {
593            name: self.name(),
594            version,
595            url: url.to_owned(),
596            exe: exe.to_string(),
597            manual: self.manual_install_instructions(),
598        })
599    }
600
601    /// Returns true if the command should check for a new version
602    /// Returns false in case of any errors (no check)
603    async fn should_check_for_new_version(&self) -> bool {
604        match get_cache_dir() {
605            Ok(dir) => {
606                let marker = dir.join(format!(".{}_last_checked", self.name()));
607                return match (marker.exists(), marker.is_dir()) {
608                    (_, true) => {
609                        // conflicting dir instead of a marker file, bail
610                        log::warn!(
611                            "Command [{}] encountered a conflicting dir in the cache, please delete {}",
612                            self.name(),
613                            marker.display()
614                        );
615
616                        false
617                    }
618                    (true, _) => {
619                        // existing marker file, read and check if last checked > 1 DAY
620                        let contents = tokio::fs::read_to_string(&marker).await;
621                        let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH);
622                        if let Some(timestamp) = contents.ok().map(|s| s.parse::<u64>().ok().unwrap_or_default()) {
623                            let last_checked = Duration::from_millis(timestamp);
624                            let one_day = Duration::from_secs(24 * 60 * 60);
625                            if let Ok(now) = now {
626                                match (now - last_checked) > one_day {
627                                    true => tokio::fs::write(&marker, now.as_millis().to_string()).await.is_ok(),
628                                    false => false,
629                                }
630                            } else {
631                                false
632                            }
633                        } else {
634                            false
635                        }
636                    }
637                    (false, _) => {
638                        // no marker file yet, record and hint to check
639                        let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH);
640                        return if let Ok(unix_timestamp) = now {
641                            tokio::fs::write(marker, unix_timestamp.as_millis().to_string()).await.is_ok()
642                        } else {
643                            false
644                        };
645                    }
646                };
647            }
648            Err(e) => {
649                log::warn!("Command {} failed to get cache dir: {}", self.name(), e);
650                false
651            }
652        }
653    }
654
655    async fn check_for_latest_version(&self) -> Option<String> {
656        log::debug!("Command [{}] checking for the latest available version", self.name());
657
658        let client = ClientBuilder::default()
659            // this github api allows anonymous, but requires a user-agent header be set
660            .user_agent("glory-cli")
661            .build()
662            .unwrap_or_default();
663
664        if let Ok(response) = client
665            .get(format!(
666                "https://api.github.com/repos/{}/{}/releases/latest",
667                self.github_owner(),
668                self.github_repo()
669            ))
670            .send()
671            .await
672        {
673            if !response.status().is_success() {
674                log::error!("Command [{}] GitHub API request failed: {}", self.name(), response.status());
675                return None;
676            }
677
678            #[derive(serde::Deserialize)]
679            struct Github {
680                tag_name: String, // this is the version number, not the git tag
681            }
682
683            let github: Github = match response.json().await {
684                Ok(json) => json,
685                Err(e) => {
686                    log::debug!("Command [{}] failed to parse the response JSON from the GitHub API: {}", self.name(), e);
687                    return None;
688                }
689            };
690
691            Some(github.tag_name)
692        } else {
693            log::debug!("Command [{}] failed to check for the latest version", self.name());
694            None
695        }
696    }
697
698    /// get the latest version from github api
699    /// cache the last check timestamp
700    /// compare with the currently requested version
701    /// inform a user if a more recent compatible version is available
702    async fn resolve_version(&self) -> String {
703        // TODO revisit this logic when implementing the SemVer compatible ranges matching
704        // if env var is set, use the requested version and bypass caching logic
705        let is_force_pin_version = env::var(self.env_var_version_name()).is_ok();
706        log::trace!(
707            "Command [{}] is_force_pin_version: {} - {:?}",
708            self.name(),
709            is_force_pin_version,
710            env::var(self.env_var_version_name())
711        );
712
713        if !is_force_pin_version && !self.should_check_for_new_version().await {
714            log::trace!("Command [{}] NOT checking for the latest available version", &self.name());
715            return self.default_version().into();
716        }
717
718        let version = env::var(self.env_var_version_name())
719            .unwrap_or_else(|_| self.default_version().into())
720            .to_owned();
721
722        let latest = self.check_for_latest_version().await;
723
724        match latest {
725            Some(latest) => {
726                let norm_latest = normalize_version(latest.as_str());
727                let norm_version = normalize_version(&version);
728                if norm_latest.is_some() && norm_version.is_some() {
729                    // TODO use the VersionReq for semantic matching
730                    match norm_version.cmp(&norm_latest) {
731                        core::cmp::Ordering::Greater | core::cmp::Ordering::Equal => {
732                            log::debug!(
733                                "Command [{}] requested version {} is already same or newer than available version {}",
734                                self.name(),
735                                version,
736                                &latest
737                            )
738                        }
739                        core::cmp::Ordering::Less => {
740                            log::info!(
741                                "Command [{}] requested version {}, but a newer version {} is available, you can try it out by \
742                                            setting the {}={} env var and re-running the command",
743                                self.name(),
744                                version,
745                                &latest,
746                                self.env_var_version_name(),
747                                &latest
748                            )
749                        }
750                    }
751                }
752            }
753            None => log::warn!("Command [{}] failed to check for the latest version", self.name()),
754        }
755
756        version
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use cargo_metadata::semver::Version;
764
765    #[test]
766    fn test_sanitize_version_prefix() {
767        let version = sanitize_version_prefix("v1.2.3");
768        assert_eq!(version, "1.2.3");
769        assert!(Version::parse(&version).is_ok());
770        let version = sanitize_version_prefix("version_1.2.3");
771        assert_eq!(version, "1.2.3");
772        assert!(Version::parse(&version).is_ok());
773    }
774
775    #[test]
776    fn test_normalize_version() {
777        let version = normalize_version("version_112");
778        assert!(version.is_some_and(|v| { v.major == 112 && v.minor == 0 && v.patch == 0 }));
779
780        let version = normalize_version("v3.3.3");
781        assert!(version.is_some_and(|v| { v.major == 3 && v.minor == 3 && v.patch == 3 }));
782
783        let version = normalize_version("10.0.0");
784        assert!(version.is_some_and(|v| { v.major == 10 && v.minor == 0 && v.patch == 0 }));
785    }
786
787    #[test]
788    fn test_incomplete_version_strings() {
789        let version = normalize_version("5");
790        assert!(version.is_some_and(|v| { v.major == 5 && v.minor == 0 && v.patch == 0 }));
791
792        let version = normalize_version("0.2");
793        assert!(version.is_some_and(|v| { v.major == 0 && v.minor == 2 && v.patch == 0 }));
794    }
795
796    #[test]
797    fn test_invalid_versions() {
798        let version = normalize_version("1a-test");
799        assert_eq!(version, None);
800    }
801}