perseus_cli/
install.rs

1use crate::cmd::{cfg_spinner, fail_spinner, run_stage, succeed_spinner};
2use crate::errors::*;
3use crate::parse::Opts;
4use cargo_lock::Lockfile;
5use cargo_metadata::MetadataCommand;
6use console::Emoji;
7use directories::ProjectDirs;
8use flate2::read::GzDecoder;
9use futures::future::try_join;
10use indicatif::ProgressBar;
11use reqwest::Client;
12use std::borrow::BorrowMut;
13use std::fs;
14use std::fs::File;
15use std::{
16    path::{Path, PathBuf},
17    process::Command,
18};
19use tar::Archive;
20use tokio::io::AsyncWriteExt;
21
22static INSTALLING: Emoji<'_, '_> = Emoji("📥", "");
23static GENERATING_LOCKFILE: Emoji<'_, '_> = Emoji("🔏", "");
24
25// For each of the tools installed in this file, we preferentially
26// manually download it. If that can't be achieved due to a platform
27// mismatch, then we'll see if the user already has a version installed.
28//
29// Importantly, if the user has specified an environment variable specifying
30// where a tool can be found, we'll use that no matter what.
31
32/// Gets the directory to store tools in. This will preferentially use the
33/// system-wide cache, falling back to a local version.
34///
35/// If the user specifies that we're running on CI, we'll use the local version
36/// regardless.
37pub fn get_tools_dir(project: &Path, no_system_cache: bool) -> Result<PathBuf, InstallError> {
38    match ProjectDirs::from("", "perseus", "perseus_cli") {
39        Some(dirs) if !no_system_cache => {
40            let target = dirs.cache_dir().join("tools");
41            if target.exists() {
42                Ok(target)
43            } else {
44                // Try to create the system-wide cache
45                if fs::create_dir_all(&target).is_ok() {
46                    Ok(target)
47                } else {
48                    // Failed, so we'll resort to the local cache
49                    let target = project.join("dist/tools");
50                    if !target.exists() {
51                        // If this fails, we have no recourse, so we'll have to fail
52                        fs::create_dir_all(&target)
53                            .map_err(|err| InstallError::CreateToolsDirFailed { source: err })?;
54                    }
55                    // It either already existed or we've just created it
56                    Ok(target)
57                }
58            }
59        }
60        _ => {
61            let target = project.join("dist/tools");
62            if !target.exists() {
63                // If this fails, we have no recourse, so we'll have to fail
64                fs::create_dir_all(&target)
65                    .map_err(|err| InstallError::CreateToolsDirFailed { source: err })?;
66            }
67            // It either already existed or we've just created it
68            Ok(target)
69        }
70    }
71}
72
73/// A representation of the paths to all the external tools we need.
74/// This includes `cargo`, simply for convenience, even though it's not
75/// actually independently installed.
76///
77/// This does not contain metadata for the installation process, but is rather
78/// intended to be passed around through the rest of the CLI.
79#[derive(Clone)]
80pub struct Tools {
81    /// The path to `cargo` on the engine-side.
82    pub cargo_engine: String,
83    /// The path to `cargo` on the browser-side.
84    pub cargo_browser: String,
85    /// The path to `wasm-bindgen`.
86    pub wasm_bindgen: String,
87    /// The path to `wasm-opt`.
88    pub wasm_opt: String,
89}
90impl Tools {
91    /// Gets a new instance of `Tools` by installing the tools if necessary.
92    ///
93    /// If tools are installed, this will create a CLI spinner automatically.
94    pub async fn new(dir: &Path, global_opts: &Opts) -> Result<Self, InstallError> {
95        // First, make sure `Cargo.lock` exists, since we'll need it for determining the
96        // right version of `wasm-bindgen`
97        let metadata = MetadataCommand::new()
98            .no_deps()
99            .cargo_path(&global_opts.cargo_engine_path)
100            .exec()
101            .map_err(|err| InstallError::MetadataFailed { source: err })?;
102        let workspace_root = metadata.workspace_root.into_std_path_buf();
103        let lockfile_path = workspace_root.join("Cargo.lock");
104        if !lockfile_path.exists() {
105            let lf_msg = format!("{} Generating Cargo lockfile", GENERATING_LOCKFILE);
106            let lf_spinner = cfg_spinner(ProgressBar::new_spinner(), &lf_msg);
107            let (_stdout, _stderr, exit_code) = run_stage(
108                vec![&format!(
109                    "{} generate-lockfile",
110                    global_opts.cargo_engine_path
111                )],
112                &workspace_root,
113                &lf_spinner,
114                &lf_msg,
115                vec![
116                    ("RUSTFLAGS", "--cfg=engine"),
117                    ("CARGO_TERM_COLOR", "always"),
118                ],
119                // Sure, the user *might* want logs on this process (but this will only be run the
120                // first time)
121                global_opts.verbose,
122            )
123            .map_err(|err| InstallError::LockfileGenerationFailed { source: err })?;
124            if exit_code != 0 {
125                // The output has already been handled, just terminate
126                return Err(InstallError::LockfileGenerationNonZero { code: exit_code });
127            }
128        }
129        let lockfile = Lockfile::load(lockfile_path)
130            .map_err(|err| InstallError::LockfileLoadFailed { source: err })?;
131
132        let target = get_tools_dir(dir, global_opts.no_system_tools_cache)?;
133
134        // Instantiate the tools
135        let wasm_bindgen = Tool::new(
136            ToolType::WasmBindgen,
137            &global_opts.wasm_bindgen_path,
138            &global_opts.wasm_bindgen_version,
139        );
140        let wasm_opt = Tool::new(
141            ToolType::WasmOpt,
142            &global_opts.wasm_opt_path,
143            &global_opts.wasm_opt_version,
144        );
145
146        // Get the statuses of all the tools
147        let wb_status = wasm_bindgen.get_status(&target, &lockfile)?;
148        let wo_status = wasm_opt.get_status(&target, &lockfile)?;
149        // Figure out if everything is present
150        // This is the only case in which we don't have to start the spinner
151        if let (ToolStatus::Available(wb_path), ToolStatus::Available(wo_path)) =
152            (&wb_status, &wo_status)
153        {
154            #[cfg(unix)]
155            let (wb_path, wo_path) = (wb_path.to_string(), wo_path.to_string());
156            #[cfg(windows)]
157            let (wb_path, wo_path) = (format!("& \'{}\'", wb_path), format!("& \'{}\'", wo_path));
158            Ok(Tools {
159                cargo_engine: global_opts.cargo_engine_path.clone(),
160                cargo_browser: global_opts.cargo_browser_path.clone(),
161                wasm_bindgen: wb_path,
162                wasm_opt: wo_path,
163            })
164        } else {
165            // We need to install some things, which may take some time
166            let spinner_msg = format!("{} Installing external tools", INSTALLING);
167            let spinner = cfg_spinner(ProgressBar::new_spinner(), &spinner_msg);
168
169            // Install all the tools in parallel
170            // These functions sanity-check their statuses, so we don't need to worry about
171            // installing unnecessarily
172            let res = try_join(
173                wasm_bindgen.install(wb_status, &target),
174                wasm_opt.install(wo_status, &target),
175            )
176            .await;
177            if let Err(err) = res {
178                fail_spinner(&spinner, &spinner_msg);
179                return Err(err);
180            }
181            // If we're here, we have the paths
182            succeed_spinner(&spinner, &spinner_msg);
183            let paths = res.unwrap();
184            #[cfg(unix)]
185            let (wb_path, wo_path) = paths;
186            #[cfg(windows)]
187            let (wb_path, wo_path) = (format!("& \'{}\'", paths.0), format!("& \'{}\'", paths.1));
188
189            Ok(Tools {
190                cargo_engine: global_opts.cargo_engine_path.clone(),
191                cargo_browser: global_opts.cargo_browser_path.clone(),
192                wasm_bindgen: wb_path,
193                wasm_opt: wo_path,
194            })
195        }
196    }
197}
198
199/// The data we need about an external tool to be able to install it.
200///
201/// This does not contain data about arguments to these tools, that's passed
202/// through the global options to the CLI directly (with defaults hardcoded).
203pub struct Tool {
204    /// The name of the tool. This will also be used for the name of the
205    /// directory in `dist/tools/` in which the tool is stored (with the
206    /// version number added on).
207    pub name: String,
208    /// A path provided by the user to the tool. If this is present, then the
209    /// tool won't be installed, we'll use what the user has given us
210    /// instead.
211    pub user_given_path: Option<String>,
212    /// A specific version number provided by the user. By default, the latest
213    /// version is used.
214    pub user_given_version: Option<String>,
215    /// The path to the binary within the directory that is extracted from the
216    /// downloaded archive.
217    pub final_path: String,
218    /// The name of the GitHub repo from which the tool can be downloaded.
219    pub gh_repo: String,
220    /// The name of the directory that will be extracted. This should contain
221    /// `%version` to be replaced with the actual version and/or
222    /// `%artifact_name` to be replaced with that.
223    pub extracted_dir_name: String,
224    /// The actual type of the tool. (All tools have the same data.)
225    pub tool_type: ToolType,
226}
227impl Tool {
228    /// Creates a new instance of this `struct`.
229    pub fn new(
230        tool_type: ToolType,
231        user_given_path: &Option<String>,
232        user_given_version: &Option<String>,
233    ) -> Self {
234        // Correct the final path for Windows (on which it'll have a `.exe` extension)
235        #[cfg(unix)]
236        let final_path = tool_type.final_path();
237        #[cfg(windows)]
238        let final_path = tool_type.final_path() + ".exe";
239
240        Self {
241            name: tool_type.name(),
242            user_given_path: user_given_path.to_owned(),
243            user_given_version: user_given_version.to_owned(),
244            final_path,
245            gh_repo: tool_type.gh_repo(),
246            extracted_dir_name: tool_type.extracted_dir_name(),
247            tool_type,
248        }
249    }
250    /// Gets the name of the artifact to download based on the tool data and the
251    /// version to download. Note that the version provided here entirely
252    /// overrides anything the user might have provided.
253    ///
254    /// If no precompiled binary is expected to be available for the current
255    /// platform, this will return `None`.
256    fn get_artifact_name(&self, version: &str) -> Option<String> {
257        match &self.tool_type {
258            // --- `wasm-bindgen` ---
259            // Linux
260            ToolType::WasmBindgen if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") => {
261                Some("wasm-bindgen-%version-x86_64-unknown-linux-musl")
262            }
263            // MacOS (Intel)
264            ToolType::WasmBindgen if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") => {
265                Some("wasm-bindgen-%version-x86_64-apple-darwin")
266            }
267            // MacOS (Apple Silicon)
268            ToolType::WasmBindgen if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") => {
269                Some("wasm-bindgen-%version-aarch64-apple-darwin")
270            }
271            // Windows
272            ToolType::WasmBindgen
273                if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") =>
274            {
275                Some("wasm-bindgen-%version-x86_64-pc-windows-msvc")
276            }
277            ToolType::WasmBindgen => None,
278            // --- `wasm-opt` ---
279            // Linux
280            ToolType::WasmOpt if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") => {
281                Some("binaryen-%version-x86_64-linux")
282            }
283            // MacOS (Intel)
284            ToolType::WasmOpt if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") => {
285                Some("binaryen-%version-x86_64-macos")
286            }
287            // MacOS (Apple Silicon)
288            ToolType::WasmOpt if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") => {
289                Some("binaryen-%version-arm64-macos")
290            }
291            // Windows
292            ToolType::WasmOpt if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") => {
293                Some("binaryen-%version-x86_64-windows")
294            }
295            ToolType::WasmOpt => None,
296        }
297        .map(|s| s.replace("%version", version))
298    }
299    /// Gets the path to the already-installed version of the tool to use. This
300    /// should take the full path to `dist/tools/`. This will automatically
301    /// handle whether or not to install a new version, use a version already
302    /// installed globally on the user's system, etc. If this returns
303    /// `ToolStatus::NeedsInstall`, we can be sure that there are binaries
304    /// available, and the same if it returns `ToolStatus::NeedsLatestInstall`.
305    pub fn get_status(
306        &self,
307        target: &Path,
308        lockfile: &Lockfile,
309    ) -> Result<ToolStatus, InstallError> {
310        // The status information will be incomplete from this first pass
311        let initial_status = {
312            // If there's a directory that matches with a given user version, we'll use it.
313            // If not, we'll use the latest version. Only if there are no
314            // installed versions available will this return `None`, or if the user wants a
315            // specific one that doesn't exist.
316
317            // If the user has given us a path, that overrides everything
318            if let Some(path) = &self.user_given_path {
319                Ok(ToolStatus::Available(path.to_string()))
320            } else {
321                // If they've given us a version, we'll check if that directory exists (we don't
322                // care about any others)
323                if let Some(version) = &self.user_given_version {
324                    // If the user wants the latest version, just force an update
325                    if version == "latest" {
326                        Ok(ToolStatus::NeedsLatestInstall)
327                    } else {
328                        let expected_path = target.join(format!("{}-{}", self.name, version));
329                        Ok(if fs::metadata(&expected_path).is_ok() {
330                            ToolStatus::Available(
331                                expected_path
332                                    .join(&self.final_path)
333                                    .to_string_lossy()
334                                    .to_string(),
335                            )
336                        } else {
337                            ToolStatus::NeedsInstall {
338                                version: version.to_string(),
339                                // This will be filled in on the second pass-through
340                                artifact_name: String::new(),
341                            }
342                        })
343                    }
344                } else {
345                    // We have no further information from the user, so we'll use whatever matches
346                    // the user's `Cargo.lock`, or, if they haven't specified anything, we'll try
347                    // the latest version.
348                    // Either way, we need to know what we've got installed already by walking the
349                    // directory.
350                    let mut versions: Vec<String> = Vec::new();
351                    for entry in fs::read_dir(target)
352                        .map_err(|err| InstallError::ReadToolsDirFailed { source: err })?
353                    {
354                        let entry = entry
355                            .map_err(|err| InstallError::ReadToolsDirFailed { source: err })?;
356                        let dir_name = entry.file_name().to_string_lossy().to_string();
357                        if dir_name.starts_with(&self.name) {
358                            let dir_name_ref = dir_name.to_string();
359                            // Valid directory names are of the form `<tool-name>-<tool-version>`
360                            let version = dir_name
361                                .strip_prefix(&format!("{}-", self.name))
362                                .ok_or(InstallError::InvalidToolsDirName { name: dir_name_ref })?;
363                            versions.push(version.to_string());
364                        }
365                    }
366                    // Now order those from most recent to least recent
367                    versions.sort();
368                    let versions = versions.into_iter().rev().collect::<Vec<String>>();
369
370                    // Now figure out what would match the current setup by checking `Cargo.lock`
371                    // (it's entirely possible that there are multiple versions
372                    // of `wasm-bindgen` in here, but that would be the user's problem).
373                    // It doesn't matter that we do this erroneously for other tools, since they'll
374                    // just return `None`.
375                    match self.get_pkg_version_from_lockfile(lockfile)? {
376                        Some(version) => {
377                            if versions.contains(&version) {
378                                let path_to_version = target
379                                    .join(format!("{}-{}/{}", self.name, version, self.final_path));
380                                Ok(ToolStatus::Available(
381                                    path_to_version.to_string_lossy().to_string(),
382                                ))
383                            } else {
384                                Ok(ToolStatus::NeedsInstall {
385                                    version,
386                                    // This will be filled in on the second pass-through
387                                    artifact_name: String::new(),
388                                })
389                            }
390                        }
391                        // There's nothing in the lockfile, so we'll go with the latest we have
392                        // installed
393                        None => {
394                            // If there are any at all, pick the first one
395                            if !versions.is_empty() {
396                                let latest_available_version = &versions[0];
397                                // We know the directory for this version had a valid name, so we
398                                // can determine exactly where it
399                                // was
400                                let path_to_latest_version = target.join(format!(
401                                    "{}-{}/{}",
402                                    self.name, latest_available_version, self.final_path
403                                ));
404                                Ok(ToolStatus::Available(
405                                    path_to_latest_version.to_string_lossy().to_string(),
406                                ))
407                            } else {
408                                // We don't check the latest version here because we haven't started
409                                // the spinner yet
410                                Ok(ToolStatus::NeedsLatestInstall)
411                            }
412                        }
413                    }
414                }
415            }
416        }?;
417        // If we're considering installing something, we should make sure that there are
418        // actually precompiled binaries available for this platform (if there
419        // aren't, then we'll try to fall back on anything the user has installed
420        // locally, and if they have nothing, an error will be returned)
421        match initial_status {
422            ToolStatus::Available(path) => Ok(ToolStatus::Available(path)),
423            ToolStatus::NeedsInstall { version, .. } => {
424                // This will be `None` if there are no precompiled binaries available
425                let artifact_name = self.get_artifact_name(&version);
426                if let Some(artifact_name) = artifact_name {
427                    // There are precompiled binaries available, which we prefer to preinstalled
428                    // global ones
429                    Ok(ToolStatus::NeedsInstall {
430                        version,
431                        artifact_name,
432                    })
433                } else {
434                    // If the user has something, we're good, but, if not, we have to fail
435                    let preinstalled_path = self.get_path_to_preinstalled()?;
436                    // We've got something, but it might not be the right version, so if the user
437                    // told us to use a specific version, we should warn them
438                    if self.user_given_version.is_some() {
439                        eprintln!("[WARNING]: You requested a specific version, but no precompiled binaries of '{}' are available for your platform, so the version already installed on your system is being used. This may not correspond to the requested version!", self.name);
440                    }
441                    Ok(ToolStatus::Available(preinstalled_path))
442                }
443            }
444            ToolStatus::NeedsLatestInstall => {
445                // To get the proper artifact name for this, we would have to request the latest
446                // version, but we don't want to do that until a CLI spinner has
447                // been started, which is after the execution of this function (so we just use a
448                // dummy version to check if there would be binaries)
449                // This will be `None` if there are no precompiled binaries available
450                let artifact_name = self.get_artifact_name("dummy");
451                if artifact_name.is_some() {
452                    // There are precompiled binaries available, which we prefer to preinstalled
453                    // global ones
454                    Ok(ToolStatus::NeedsLatestInstall)
455                } else {
456                    // If the user has something, we're good, but, if not, we have to fail
457                    let preinstalled_path = self.get_path_to_preinstalled()?;
458                    // The user can't have requested a specific version if we're in the market for
459                    // the latest one, so we can wrap up here
460                    Ok(ToolStatus::Available(preinstalled_path))
461                }
462            }
463        }
464    }
465    /// Gets the latest version for this tool from its GitHub repository. One
466    /// should only bother executing this if we know there are precompiled
467    /// binaries for this platform.
468    pub async fn get_latest_version(&self) -> Result<String, InstallError> {
469        let json = Client::new()
470            .get(&format!(
471                "https://api.github.com/repos/{}/releases/latest",
472                self.gh_repo
473            ))
474            // This needs to display the name of the app for GH
475            .header("User-Agent", "perseus-cli")
476            .send()
477            .await
478            .map_err(|err| InstallError::GetLatestToolVersionFailed {
479                source: err,
480                tool: self.name.to_string(),
481            })?
482            .json::<serde_json::Value>()
483            .await
484            .map_err(|err| InstallError::GetLatestToolVersionFailed {
485                source: err,
486                tool: self.name.to_string(),
487            })?;
488        let latest_version =
489            json.get("name")
490                .ok_or_else(|| InstallError::ParseToolVersionFailed {
491                    tool: self.name.to_string(),
492                })?;
493
494        Ok(latest_version
495            .as_str()
496            .ok_or_else(|| InstallError::ParseToolVersionFailed {
497                tool: self.name.to_string(),
498            })?
499            .to_string())
500    }
501    /// Installs the tool, taking the predetermined status as an argument to
502    /// avoid installing if the tool is actually already available, since
503    /// this method will be called on all tools if even one
504    /// is not available.
505    pub async fn install(&self, status: ToolStatus, target: &Path) -> Result<String, InstallError> {
506        // Do a sanity check to prevent installing something that already exists
507        match status {
508            ToolStatus::Available(path) => Ok(path),
509            ToolStatus::NeedsInstall {
510                version,
511                artifact_name,
512            } => self.install_version(&version, &artifact_name, target).await,
513            ToolStatus::NeedsLatestInstall => {
514                let latest_version = self.get_latest_version().await?;
515                // We *do* know at this point that there do exist precompiled binaries
516                let artifact_name = self.get_artifact_name(&latest_version).unwrap();
517                self.install_version(&latest_version, &artifact_name, target)
518                    .await
519            }
520        }
521    }
522    /// Checks if the user already has this tool installed. This should only be
523    /// called if there are no precompiled binaries available of this tool
524    /// for the user's platform. In that case, this will also warn the user
525    /// if they've asked for a specific version that their own version might not
526    /// be that (we don't bother trying to parse the version of their
527    /// installed program).
528    ///
529    /// If there's nothing the user has installed, then this will return an
530    /// error, and hence it should only be called after all other options
531    /// have been exhausted.
532    fn get_path_to_preinstalled(&self) -> Result<String, InstallError> {
533        #[cfg(unix)]
534        let shell_exec = "sh";
535        #[cfg(windows)]
536        let shell_exec = "powershell";
537        #[cfg(unix)]
538        let shell_param = "-c";
539        #[cfg(windows)]
540        let shell_param = "-command";
541
542        let check_cmd = format!("{} --version", self.name); // Not exactly bulletproof, but it works!
543        let res = Command::new(shell_exec)
544            .args([shell_param, &check_cmd])
545            .output();
546        if let Err(err) = res {
547            // Unlike `wasm-pack`, we don't try to install with `cargo install`, because
548            // that's a little pointless to me (the user will still have to get
549            // `wasm-opt` somehow...)
550            //
551            // TODO Installation script that can build manually on any platform
552            Err(InstallError::ExternalToolUnavailable {
553                tool: self.name.to_string(),
554                source: err,
555            })
556        } else {
557            // It works, so we don't need to install anything
558            Ok(self.name.to_string())
559        }
560    }
561    /// Installs the given version of the tool, returning the path to the final
562    /// binary.
563    async fn install_version(
564        &self,
565        version: &str,
566        artifact_name: &str,
567        target: &Path,
568    ) -> Result<String, InstallError> {
569        let url = format!(
570            "https://github.com/{gh_repo}/releases/download/{version}/{artifact_name}.tar.gz",
571            artifact_name = artifact_name,
572            version = version,
573            gh_repo = self.gh_repo
574        );
575        let dir_name = format!("{}-{}", self.name, version);
576        let tar_name = format!("{}.tar.gz", &dir_name);
577        let dir_path = target.join(&dir_name);
578        let tar_path = target.join(&tar_name);
579
580        // Deal with placeholders in the name to expect from the extracted directory
581        let extracted_dir_name = self
582            .extracted_dir_name
583            .replace("%artifact_name", artifact_name)
584            .replace("%version", version);
585
586        // Download the tarball (source https://github.com/seanmonstar/reqwest/issues/1266#issuecomment-1106187437)
587        // We do this by chunking to minimize memory usage (we're downloading fairly
588        // large files!)
589        let mut res = Client::new().get(url).send().await.map_err(|err| {
590            InstallError::BinaryDownloadRequestFailed {
591                source: err,
592                tool: self.name.to_string(),
593            }
594        })?;
595        let mut file = tokio::fs::File::create(&tar_path)
596            .await
597            .map_err(|err| InstallError::CreateToolDownloadDestFailed { source: err })?;
598        while let Some(mut item) = res
599            .chunk()
600            .await
601            .map_err(|err| InstallError::ChunkBinaryDownloadFailed { source: err })?
602        {
603            file.write_all_buf(item.borrow_mut())
604                .await
605                .map_err(|err| InstallError::WriteBinaryDownloadChunkFailed { source: err })?;
606        }
607        // Now unzip the tarball
608        // TODO Async?
609        let tar_gz = File::open(&tar_path)
610            .map_err(|err| InstallError::CreateToolExtractDestFailed { source: err })?;
611        let mut archive = Archive::new(GzDecoder::new(tar_gz));
612        // We'll extract straight into `dist/tools/` and then rename the resulting
613        // directory
614        archive
615            .unpack(target)
616            .map_err(|err| InstallError::ToolExtractFailed {
617                source: err,
618                tool: self.name.to_string(),
619            })?;
620
621        // Now delete the original archive file
622        fs::remove_file(&tar_path)
623            .map_err(|err| InstallError::ArchiveDeletionFailed { source: err })?;
624        // Finally, rename the extracted directory
625        fs::rename(
626            target.join(extracted_dir_name), // We extracted into the root of the target
627            &dir_path,
628        )
629        .map_err(|err| InstallError::DirRenameFailed { source: err })?;
630
631        // Return the path inside the directory we extracted
632        Ok(dir_path
633            .join(&self.final_path)
634            .to_str()
635            .unwrap()
636            .to_string())
637    }
638    /// Gets the version of a specific package in `Cargo.lock`, assuming it has
639    /// already been generated.
640    fn get_pkg_version_from_lockfile(
641        &self,
642        lockfile: &Lockfile,
643    ) -> Result<Option<String>, InstallError> {
644        let version = lockfile
645            .packages
646            .iter()
647            .find(|p| p.name.as_str() == self.name)
648            .map(|p| p.version.to_string());
649        Ok(version)
650    }
651}
652
653/// A tool's status on-system.
654pub enum ToolStatus {
655    /// The tool needs to be installed.
656    NeedsInstall {
657        version: String,
658        artifact_name: String,
659    },
660    /// The latest version of the tool needs to be determined from its repo and
661    /// then installed.
662    NeedsLatestInstall,
663    /// The tool is already available at the attached path.
664    Available(String),
665}
666
667/// The types of tools we can install.
668pub enum ToolType {
669    /// The `wasm-bindgen` CLI, used for producing final Wasm and JS artifacts.
670    WasmBindgen,
671    /// Binaryen's `wasm-opt` CLI, used for optimizing Wasm in release builds
672    /// to achieve significant savings in bundle sizes.
673    WasmOpt,
674}
675impl ToolType {
676    /// Gets the tool's name.
677    pub fn name(&self) -> String {
678        match &self {
679            Self::WasmBindgen => "wasm-bindgen",
680            Self::WasmOpt => "wasm-opt",
681        }
682        .to_string()
683    }
684    /// Get's the path to the tool's binary inside the extracted directory
685    /// from the downloaded archive.
686    ///
687    /// Note that the return value of this function is uncorrected for Windows.
688    pub fn final_path(&self) -> String {
689        match &self {
690            Self::WasmBindgen => "wasm-bindgen",
691            Self::WasmOpt => "bin/wasm-opt",
692        }
693        .to_string()
694    }
695    /// Gets the GitHub repo to install this tool from.
696    pub fn gh_repo(&self) -> String {
697        match &self {
698            Self::WasmBindgen => "rustwasm/wasm-bindgen",
699            Self::WasmOpt => "WebAssembly/binaryen",
700        }
701        .to_string()
702    }
703    /// Gets the name of the directory that will be extracted from the
704    /// downloaded archive for this tool.
705    ///
706    /// This will return a `String` that might include placeholders for the
707    /// version and downloaded artifact name.
708    pub fn extracted_dir_name(&self) -> String {
709        match &self {
710            Self::WasmBindgen => "%artifact_name",
711            Self::WasmOpt => "binaryen-%version",
712        }
713        .to_string()
714    }
715}