Repository: FuelLabs/fuelup (rust)

Issue:
Upon installation of a toolchain or component, warn the user if any executables already existing on their `PATH`
It is totally valid for a user to have two installations of a particular tool. E.g. a user might setup their path like so:
```
PATH="~/.cargo/bin:~/.fuelup/bin"
```
This allows them to use `cargo` to temporarily install tools during development (e.g. `cargo install --path forc`) and then remove them again later `cargo uninstall forc` in a manner that falls back to their `fuelup` installation.

However, a lot of the time (especially for less-experienced devs) having multiple installations of a tool on a `PATH` can just be confusing.

During installation, we should make it clear if `fuelup` is installing a tool that is already installed on the user's system. We can check whether each of the executables already exist on the user's system by checking and searching the user's `PATH` (see `forc`'s plugin support for an example of searching the `PATH`).

If a duplicate is found we should clarify the following when installing:

- That a duplicate exists.
- Where the duplicate is located (its path).
- Which version (i.e. the existing version or the version being installed by `fuelup`) will take priority based on the search order of their `PATH`.

The final point might be trickier depending on the complexity of the user's `PATH`, but would likely be very valuable information for many users.

We could re-warn on `fuelup update` too.

---

Later on, we can consider adding heuristics for suggestions on how to resolve conflicts based on where the duplicates are and the `PATH` order, e.g. "`fuelup` detected a version of `forc` installed via `cargo` that will take precedece - consider uninstalling it with `cargo uninstall forc`".

Pull request description:
feat: warn existing executables
closes #290 

This check is invoked upon `install` or `update` commands.

Example output:

![image](https://user-images.githubusercontent.com/25565268/204702072-dcadbc7d-c69d-45f5-a7f0-d518df45caf2.png)

This check will warn when:

1) your fuel related executable exists but is not in `.fuelup/bin`,
2) your fuel related executable exists, is not in `.fuelup/bin` and instead is in `.cargo/bin`, urging the user to do `cargo uninstall` when `fuelup` is about to do an install or update.
3) your fuel related executable exists, is both in `.fuelup/bin` and in `.cargo/bin`, warning that the version in `.fuelup/bin` is overshadowed and urging the user to do `cargo uninstall`.

A fourth variation exists but should be rare, and happens when your fuel related executable exists, is in some other path and not in `.cargo/bin` (eg. `.local/bin`). In this case the warning only shows up and does not suggest any action to be taken.

Example:

![image](https://user-images.githubusercontent.com/25565268/204703031-dd521acc-4fc1-4eaa-8cd1-10f782bd5c72.png)


Patch:
diff --git a/src/ops/fuelup_toolchain/install.rs b/src/ops/fuelup_toolchain/install.rs
index f890d398..d7d03773 100644
--- a/src/ops/fuelup_toolchain/install.rs
+++ b/src/ops/fuelup_toolchain/install.rs
@@ -1,5 +1,5 @@
 use crate::config::Config;
-use crate::path::settings_file;
+use crate::path::{settings_file, warn_existing_fuel_executables};
 use crate::settings::SettingsFile;
 use crate::toolchain::{DistToolchainDescription, Toolchain};
 use crate::{channel::Channel, commands::toolchain::InstallCommand};
@@ -26,6 +26,7 @@ pub fn install(command: InstallCommand) -> Result<()> {
     let mut installed_bins = String::new();
 
     let config = Config::from_env()?;
+    warn_existing_fuel_executables()?;
 
     let toolchain = Toolchain::from_path(&description.to_string())?;
     let (cfgs, hash) = if let Ok((channel, hash)) = Channel::from_dist_channel(&description) {
diff --git a/src/ops/fuelup_update.rs b/src/ops/fuelup_update.rs
index 28a1839c..7566e6cc 100644
--- a/src/ops/fuelup_update.rs
+++ b/src/ops/fuelup_update.rs
@@ -2,6 +2,7 @@ use crate::{
     channel::Channel,
     config::Config,
     fmt::{bold, colored_bold},
+    path::warn_existing_fuel_executables,
     toolchain::{DistToolchainDescription, Toolchain},
 };
 use anyhow::{bail, Result};
@@ -19,6 +20,8 @@ pub fn update() -> Result<()> {
     let toolchains = config.list_dist_toolchains()?;
     let mut summary: Vec<(String, String)> = Vec::with_capacity(toolchains.len());
 
+    warn_existing_fuel_executables()?;
+
     for toolchain in toolchains {
         let mut installed_bins = String::new();
         let mut errored_bins = String::new();
diff --git a/src/path.rs b/src/path.rs
index 7ff1c837..ea617e25 100644
--- a/src/path.rs
+++ b/src/path.rs
@@ -1,8 +1,11 @@
 use anyhow::{bail, Result};
+use component::Components;
+use std::env;
 use std::{
     fs,
     path::{Path, PathBuf},
 };
+use tracing::warn;
 
 use dirs;
 
@@ -51,3 +54,81 @@ pub fn ensure_dir_exists(path: &Path) -> Result<()> {
     }
     Ok(())
 }
+
+pub fn warn_existing_fuel_executables() -> Result<()> {
+    let components = Components::collect_publishables()?.into_iter();
+
+    fn search_directories() -> Vec<PathBuf> {
+        if let Some(val) = env::var_os("PATH") {
+            return env::split_paths(&val).collect();
+        }
+        vec![]
+    }
+
+    let mut summary = String::new();
+
+    for c in components {
+        for e in c.executables {
+            if let Some(path) = search_directories()
+                .into_iter()
+                .map(|d| d.join(e.clone()))
+                .find(|f| is_executable(f))
+            {
+                let path = path.to_str().unwrap_or_default();
+                let mut message = String::new();
+                let fuelup_bin_dir = fuelup_bin_dir();
+                if !path.contains(fuelup_bin_dir.to_str().unwrap()) {
+                    let maybe_fuelup_executable = fuelup_bin_dir.join(&e);
+
+                    message.push_str(&format!("warning: '{e}' found in PATH at {path}."));
+
+                    if is_executable(&maybe_fuelup_executable) {
+                        message.push_str(&format!(
+                            " This will take precedence over '{}', already installed at {}. Consider uninstalling {}, or re-arranging your PATH to give fuelup priority.",
+                            c.name,
+                            &maybe_fuelup_executable.display(),
+                            path
+                        ));
+                    } else {
+                        message.push_str(&format!(
+                            " This will take precedence over '{}' to be installed at {}.",
+                            c.name,
+                            &maybe_fuelup_executable.display()
+                        ));
+                    }
+                }
+
+                if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
+                    if path.contains(&cargo_home) {
+                        message.push_str(&format!(
+                            " You may want to execute 'cargo uninstall {}'.",
+                            c.name
+                        ));
+                    }
+                }
+
+                if !message.is_empty() {
+                    summary.push_str(&message);
+                    summary.push('\n');
+                }
+            }
+        }
+    }
+
+    warn!("{}", summary);
+
+    Ok(())
+}
+
+#[cfg(unix)]
+pub fn is_executable(path: &Path) -> bool {
+    use std::os::unix::prelude::*;
+    std::fs::metadata(path)
+        .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
+        .unwrap_or(false)
+}
+
+#[cfg(windows)]
+pub fn is_executable(path: &Path) -> bool {
+    path.is_file()
+}


Test patch:
diff --git a/tests/commands.rs b/tests/commands.rs
index 86dc0514..11ca686c 100644
--- a/tests/commands.rs
+++ b/tests/commands.rs
@@ -131,6 +131,7 @@ fn fuelup_update() -> Result<()> {
     testcfg::setup(FuelupState::LatestToolchainInstalled, &|cfg| {
         let output = cfg.fuelup(&["update"]);
         assert!(output.status.success());
+        assert!(!output.stdout.contains("warning:"));
 
         for entry in cfg.toolchains_dir().read_dir().expect("Could not read dir") {
             let toolchain_dir = entry.unwrap();
@@ -147,6 +148,56 @@ fn fuelup_update() -> Result<()> {
     Ok(())
 }
 
+#[cfg(unix)]
+#[test]
+fn fuelup_update_conflict() -> Result<()> {
+    testcfg::setup(FuelupState::FuelupUpdateConflict, &|cfg| {
+        let output = cfg.fuelup(&["update"]);
+
+        // There are 4 scenarios of warning messages:
+        // 1) duplicate fuel executable found in PATH but not in CARGO_HOME and not in FUELUP_HOME
+        let has_duplicate_message = &format!(
+            "warning: 'forc' found in PATH at {}. This will take precedence over 'forc' to be installed at {}.",
+            cfg.home.join(".local/bin/forc").display(),
+            cfg.home.join(".fuelup/bin/forc").display()
+        );
+        // 2) duplicate fuel executable found in PATH and in CARGO_HOME but not in FUELUP_HOME.
+        //    `cargo uninstall` is a possible action and therefore a suggestion by fuelup.
+        let has_duplicate_cargo_uninstall_message =
+            &format!("warning: 'fuel-core' found in PATH at {}. This will take precedence over 'fuel-core' to be installed at {}. You may want to execute 'cargo uninstall fuel-core'.\n",
+                cfg.home.join(".cargo/bin/fuel-core").display(),
+                cfg.home.join(".fuelup/bin/fuel-core").display()
+            );
+        // 3) duplicate fuel executable found in PATH and in FUELUP_HOME but not in CARGO_HOME.
+        //    fuelup's version is overshadowed by the duplicate.
+        let has_duplicate_overshadow_message = &format!("warning: 'forc-wallet' found in PATH at {}. This will take precedence over 'forc-wallet', already installed at {}. Consider uninstalling {}, or re-arranging your PATH to give fuelup priority.",
+                cfg.home.join(".local/bin/forc-wallet").display(),
+                cfg.home.join(".fuelup/bin/forc-wallet").display(),
+                cfg.home.join(".local/bin/forc-wallet").display(),
+            );
+        // 4) duplicate fuel executable found in PATH and in FUELUP_HOME and CARGO_HOME.
+        //    fuelup's version is overshadowed by the duplicate. `cargo uninstall` is a possible
+        //    action and therefore a suggestion by fuelup.
+        let has_duplicate_overshadow_cargo_uninstall_message = &format!("warning: 'forc-explore' found in PATH at {}. This will take precedence over 'forc-explore', already installed at {}. Consider uninstalling {}, or re-arranging your PATH to give fuelup priority. You may want to execute 'cargo uninstall forc-explore'.",
+                cfg.home.join(".cargo/bin/forc-explore").display(),
+                cfg.home.join(".fuelup/bin/forc-explore").display(),
+                cfg.home.join(".cargo/bin/forc-explore").display(),
+            );
+
+        assert!(output.status.success());
+        assert!(output.stdout.contains(has_duplicate_message));
+        assert!(output
+            .stdout
+            .contains(has_duplicate_cargo_uninstall_message));
+        assert!(output.stdout.contains(has_duplicate_overshadow_message));
+        assert!(output
+            .stdout
+            .contains(has_duplicate_overshadow_cargo_uninstall_message));
+    })?;
+
+    Ok(())
+}
+
 #[test]
 fn fuelup_toolchain_uninstall() -> Result<()> {
     testcfg::setup(FuelupState::Empty, &|cfg| {
diff --git a/tests/testcfg.rs b/tests/testcfg.rs
index 9ad7f49b..6aa06c80 100644
--- a/tests/testcfg.rs
+++ b/tests/testcfg.rs
@@ -1,6 +1,7 @@
 use anyhow::Result;
 use fuelup::settings::SettingsFile;
 use fuelup::target_triple::TargetTriple;
+use std::os::unix::fs::OpenOptionsExt;
 use std::{
     env, fs,
     path::{Path, PathBuf},
@@ -12,6 +13,7 @@ pub enum FuelupState {
     AllInstalled,
     Empty,
     LatestToolchainInstalled,
+    FuelupUpdateConflict,
     NightlyInstalled,
     NightlyDateInstalled,
     LatestAndCustomInstalled,
@@ -81,6 +83,15 @@ impl TestCfg {
         let output = Command::new(&self.fuelup_path)
             .args(args)
             .env("HOME", &self.home)
+            .env("CARGO_HOME", self.home.join(".cargo").to_str().unwrap())
+            .env(
+                "PATH",
+                format!(
+                    "{}:{}",
+                    &self.home.join(".local/bin").display(),
+                    &self.home.join(".cargo/bin").display()
+                ),
+            )
             .env("TERM", "dumb")
             .output()
             .expect("Failed to execute command");
@@ -94,6 +105,22 @@ impl TestCfg {
     }
 }
 
+#[cfg(unix)]
+fn create_fuel_executable(path: &Path) -> std::io::Result<()> {
+    fs::OpenOptions::new()
+        .create(true)
+        .write(true)
+        .mode(0o770)
+        .open(path)?;
+    Ok(())
+}
+
+#[cfg(windows)]
+fn create_fuel_executable(path: &Path) -> std::io::Result<()> {
+    fs::File::create(path)?;
+    Ok(())
+}
+
 fn setup_toolchain(fuelup_home_path: &Path, toolchain: &str) -> Result<()> {
     let bin_dir = fuelup_home_path
         .join("toolchains")
@@ -102,7 +129,7 @@ fn setup_toolchain(fuelup_home_path: &Path, toolchain: &str) -> Result<()> {
     fs::create_dir_all(&bin_dir).expect("Failed to create temporary latest toolchain bin dir");
 
     for bin in ALL_BINS {
-        fs::File::create(&bin_dir.join(bin))?;
+        create_fuel_executable(&bin_dir.join(bin))?;
     }
 
     Ok(())
@@ -158,6 +185,23 @@ pub fn setup(state: FuelupState, f: &dyn Fn(&mut TestCfg)) -> Result<()> {
             setup_toolchain(&tmp_fuelup_root_path, &latest)?;
             setup_settings_file(&tmp_fuelup_root_path, &latest)?;
         }
+        FuelupState::FuelupUpdateConflict => {
+            setup_toolchain(&tmp_fuelup_root_path, &latest)?;
+            setup_settings_file(&tmp_fuelup_root_path, &latest)?;
+
+            fs::create_dir_all(&tmp_home.join(".local/bin"))?;
+            create_fuel_executable(&tmp_home.join(".local/bin/forc"))?;
+
+            create_fuel_executable(&tmp_home.join(".local/bin/forc-wallet"))?;
+            create_fuel_executable(&tmp_home.join(".fuelup/bin/forc-wallet"))?;
+
+            fs::create_dir_all(&tmp_home.join(".cargo/bin"))?;
+
+            create_fuel_executable(&tmp_home.join(".cargo/bin/forc-explore"))?;
+            create_fuel_executable(&tmp_home.join(".fuelup/bin/forc-explore"))?;
+
+            create_fuel_executable(&tmp_home.join(".cargo/bin/fuel-core"))?;
+        }
         FuelupState::NightlyInstalled => {
             setup_toolchain(&tmp_fuelup_root_path, &nightly)?;
             setup_settings_file(&tmp_fuelup_root_path, &nightly)?;