Repository: FuelLabs/fuelup (rust)

Issue:
Add a `Plugin` abstraction that allows us to reason about plugins with multiple commands
In #152 and #154 especially we add some hacks to check for `forc-client` in order to handle its unique behaviour of providing multiple commands.

In the future, we will likely add more plugins that support multiple commands (e.g. [`forc-edit`](https://github.com/FuelLabs/sway/issues/2369)) and we should use a proper `Plugin` type (rather than just strings) that allows us to reason about this more generally.

Pull request description:
components toml
~_WIP_~

closes #163 

- Adds `Component` and a `Plugin` abstractions to be able to read supported components and plugins from `components.toml` (which will be shared between `fuelup` usage and the CI).
- For now, this PR will only address the `fuelup` side of things - another PR will address the CI side
- In future, any components that have to be added and distributed by fuelup will have to be configured within `components.toml`. By relying on reading this TOML, fuelup will automatically know which component/plugin is supported and be able to download it.

This PR should also partially address #162 - the hope is to be able to specify a new component or plugin in this TOML and have `fuelup` and the CI automatically detect them.

Patch:
diff --git a/components.toml b/components.toml
new file mode 100644
index 00000000..370b5d7b
--- /dev/null
+++ b/components.toml
@@ -0,0 +1,45 @@
+[component.forc]
+name = "forc"
+tarball_prefix = "forc-binaries"
+executables = ["forc"]
+repository_url = "https://github.com/FuelLabs/sway"
+targets = ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
+
+[component.forc-fmt]
+name = "forc-fmt"
+tarball_prefix = "forc-binaries"
+is_plugin = true
+executables = ["forc-fmt"]
+repository_url = "https://github.com/FuelLabs/sway"
+targets = ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
+
+[component.forc-lsp]
+name = "forc-lsp"
+tarball_prefix = "forc-binaries"
+is_plugin = true
+executables = ["forc-lsp"]
+repository_url = "https://github.com/FuelLabs/sway"
+targets = ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
+
+[component.forc-explore]
+name = "forc-explore"
+tarball_prefix = "forc-binaries"
+is_plugin = true
+executables = ["forc-explore"]
+repository_url = "https://github.com/FuelLabs/sway"
+targets = ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
+
+[component.forc-client]
+name = "forc-client"
+tarball_prefix = "forc-binaries"
+is_plugin = true
+executables = ["forc-deploy", "forc-run"]
+repository_url = "https://github.com/FuelLabs/sway"
+targets = ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
+
+[component.fuel-core]
+name = "fuel-core"
+tarball_prefix = "fuel-core"
+executables = ["fuel-core"]
+repository_url = "https://github.com/FuelLabs/fuel-core"
+targets = [ "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-apple-darwin" ]
diff --git a/src/component.rs b/src/component.rs
index 5d5acaf0..d34c16c2 100644
--- a/src/component.rs
+++ b/src/component.rs
@@ -1,10 +1,161 @@
+use std::collections::HashMap;
+
+use anyhow::Result;
+use serde::Deserialize;
+use toml_edit::de;
+
 pub const FORC: &str = "forc";
 pub const FUEL_CORE: &str = "fuel-core";
 pub const FUELUP: &str = "fuelup";
-pub const FORC_EXPLORE: &str = "forc-explore";
-pub const FORC_FMT: &str = "forc-fmt";
-pub const FORC_LSP: &str = "forc-lsp";
-pub const FORC_DEPLOY: &str = "forc-run";
-pub const FORC_RUN: &str = "forc-deploy";
 
-pub const SUPPORTED_PLUGINS: &[&str] = &[FORC_FMT, FORC_LSP, FORC_EXPLORE, FORC_DEPLOY, FORC_RUN];
+const COMPONENTS_TOML: &str = include_str!("../components.toml");
+
+#[derive(Debug, Deserialize)]
+pub struct Components {
+    pub component: HashMap<String, Component>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct Component {
+    pub name: String,
+    pub is_plugin: Option<bool>,
+    pub tarball_prefix: String,
+    pub executables: Vec<String>,
+    pub repository_url: String,
+    pub targets: Vec<String>,
+}
+
+#[derive(Debug)]
+pub struct Plugin {
+    pub name: String,
+    pub executables: Vec<String>,
+}
+
+impl Plugin {
+    pub fn is_main_executable(&self) -> bool {
+        self.executables.len() == 1 && self.name == self.executables[0]
+    }
+}
+
+impl Components {
+    pub fn from_toml(toml: &str) -> Result<Self> {
+        let components: Components = de::from_str(toml)?;
+        Ok(components)
+    }
+
+    pub fn collect_exclude_plugins() -> Result<Vec<Component>> {
+        let components = Self::from_toml(COMPONENTS_TOML)?;
+
+        let mut main_components: Vec<Component> = components
+            .component
+            .keys()
+            .map(|c| {
+                components
+                    .component
+                    .get(c)
+                    .expect("Failed to parse components.toml")
+            })
+            .filter_map(|c| c.is_plugin.is_none().then(|| c.clone()))
+            .collect();
+
+        main_components.sort_by_key(|c| c.name.clone());
+
+        Ok(main_components)
+    }
+
+    pub fn collect_plugins() -> Result<Vec<Plugin>> {
+        let components = Self::from_toml(COMPONENTS_TOML)?;
+
+        let mut plugins: Vec<Plugin> = components
+            .component
+            .keys()
+            .map(|c| {
+                components
+                    .component
+                    .get(c)
+                    .expect("Failed to parse components.toml")
+            })
+            .filter(|&c| c.is_plugin.unwrap_or_default())
+            .map(|p| Plugin {
+                name: p.name.clone(),
+                executables: p.executables.clone(),
+            })
+            .collect();
+        plugins.sort_by_key(|p| p.name.clone());
+
+        Ok(plugins)
+    }
+
+    pub fn collect_plugin_executables() -> Result<Vec<String>> {
+        let plugins = Self::collect_plugins()?;
+        let mut executables = vec![];
+
+        for plugin in plugins.iter() {
+            executables.extend(plugin.executables.clone().into_iter())
+        }
+
+        Ok(executables)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::component;
+    #[test]
+    fn test_toml() -> Result<()> {
+        const TOML: &str = r#"
+[component.forc-fmt]
+name = "forc-fmt"
+is_plugin = true
+tarball_prefix = "forc-binaries"
+executables = ["forc-fmt"]
+repository_url = "https://github.com/FuelLabs/sway"
+targets = ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
+"#;
+
+        let components = Components::from_toml(TOML)?;
+
+        assert_eq!(components.component["forc-fmt"].name, "forc-fmt");
+        assert_eq!(components.component["forc-fmt"].is_plugin, Some(true));
+        assert_eq!(
+            components.component["forc-fmt"].tarball_prefix,
+            "forc-binaries"
+        );
+        assert_eq!(components.component["forc-fmt"].executables, ["forc-fmt"]);
+        assert_eq!(
+            components.component["forc-fmt"].repository_url,
+            "https://github.com/FuelLabs/sway"
+        );
+        assert_eq!(
+            components.component["forc-fmt"].targets,
+            ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_collect_exclude_plugins() -> Result<()> {
+        let components = Components::collect_exclude_plugins().unwrap();
+        let actual = components
+            .iter()
+            .map(|c| c.name.clone())
+            .collect::<Vec<String>>();
+        let mut expected = [component::FORC, component::FUEL_CORE];
+        expected.sort();
+        assert_eq!(components.len(), 2);
+        assert_eq!(actual, expected);
+        Ok(())
+    }
+
+    #[test]
+    fn test_collect_plugins() {
+        assert!(Components::collect_plugins().is_ok());
+    }
+
+    #[test]
+    fn test_collect_plugin_executables() {
+        assert!(Components::collect_plugin_executables().is_ok());
+    }
+}
diff --git a/src/ops/fuelup_check.rs b/src/ops/fuelup_check.rs
index 62bc7e25..752e0bbb 100644
--- a/src/ops/fuelup_check.rs
+++ b/src/ops/fuelup_check.rs
@@ -1,7 +1,7 @@
 use crate::{
     channel::{Channel, PackageVersion},
     commands::check::CheckCommand,
-    component::SUPPORTED_PLUGINS,
+    component::Components,
     config::Config,
     constants::{CHANNEL_LATEST_FILE_NAME, CHANNEL_NIGHTLY_FILE_NAME},
     file::read_file,
@@ -120,32 +120,37 @@ fn check_toolchain(toolchain: &str, verbose: bool) -> Result<()> {
 
     bold(|s| writeln!(s, "{}", &toolchain.name));
 
-    for component in [component::FORC, component::FUEL_CORE] {
-        let version = &channel.pkg[component].version;
-        let latest_version = &latest_package_versions[component];
+    for component in Components::collect_exclude_plugins()? {
+        let version = &channel.pkg[&component.name].version;
+        let latest_version = &latest_package_versions[&component.name];
 
-        let component_executable = toolchain.bin_path.join(component);
+        let component_executable = toolchain.bin_path.join(&component.name);
 
         if component_executable.is_file() {
-            bold(|s| write!(s, "  {} - ", &component));
+            bold(|s| write!(s, "  {} - ", &component.name));
             compare_and_print_versions(version, latest_version)?;
         } else {
             print!("  ");
-            bold(|s| write!(s, "{}", &component));
+            bold(|s| write!(s, "{}", &component.name));
             println!(" - not installed");
         }
 
-        if verbose && component == component::FORC {
-            for plugin in SUPPORTED_PLUGINS {
-                if plugin == &component::FORC_DEPLOY {
-                    bold(|s| writeln!(s, "    - forc-client"));
-                }
-                if plugin == &component::FORC_RUN || plugin == &component::FORC_DEPLOY {
-                    print!("  ");
+        if verbose && component.name == component::FORC {
+            for plugin in component::Components::collect_plugins()? {
+                if !plugin.is_main_executable() {
+                    bold(|s| writeln!(s, "    - {}", plugin.name));
                 }
 
-                let plugin_executable = toolchain.bin_path.join(&plugin);
-                check_plugin(&plugin_executable, plugin, version, latest_version)?;
+                for (index, executable) in plugin.executables.iter().enumerate() {
+                    let plugin_executable = toolchain.bin_path.join(executable);
+
+                    let mut plugin_name = &plugin.name;
+                    if !plugin.is_main_executable() {
+                        print!("  ");
+                        plugin_name = &plugin.executables[index];
+                    }
+                    check_plugin(&plugin_executable, plugin_name, version, latest_version)?;
+                }
             }
         }
     }
diff --git a/src/ops/fuelup_show.rs b/src/ops/fuelup_show.rs
index f86cf59d..f8198a65 100644
--- a/src/ops/fuelup_show.rs
+++ b/src/ops/fuelup_show.rs
@@ -1,11 +1,12 @@
 use anyhow::Result;
 use semver::Version;
-use std::io::Write;
 use std::str::FromStr;
+use std::{io::Write, path::Path};
 
+use crate::component::Components;
 use crate::{
     channel::Channel,
-    component::{self, SUPPORTED_PLUGINS},
+    component,
     config::Config,
     constants::{CHANNEL_LATEST_FILE_NAME, CHANNEL_NIGHTLY_FILE_NAME},
     file::read_file,
@@ -15,6 +16,36 @@ use crate::{
     toolchain::{DistToolchainName, OfficialToolchainDescription, Toolchain},
 };
 
+fn exec_show_version(component: &str, component_executable: &Path) -> Result<()> {
+    match std::process::Command::new(component_executable)
+        .arg("--version")
+        .output()
+    {
+        Ok(o) => {
+            let output = String::from_utf8_lossy(&o.stdout).into_owned();
+            match output.split_whitespace().nth(1) {
+                Some(v) => {
+                    let version = Version::parse(v)?;
+                    println!(" : {}", version);
+                }
+                None => {
+                    eprintln!("  {} - Error getting version string", component);
+                }
+            };
+        }
+        Err(e) => {
+            print!(" - ");
+            if component_executable.exists() {
+                println!("execution error - {}", e);
+            } else {
+                println!("not found");
+            }
+        }
+    }
+
+    Ok(())
+}
+
 pub fn show() -> Result<()> {
     bold(|s| write!(s, "Default host: "));
     println!("{}", TargetTriple::from_host()?);
@@ -55,93 +86,41 @@ pub fn show() -> Result<()> {
         None
     };
 
-    for component in [component::FORC, component::FUEL_CORE] {
+    for component in Components::collect_exclude_plugins()? {
+        bold(|s| write!(s, "  {}", &component.name));
         if let Some(c) = channel.as_ref() {
-            let version = &c.pkg[component].version;
-            bold(|s| write!(s, "  {}", &component));
+            let version = &c.pkg[&component.name].version;
             println!(" : {}", version);
         } else {
-            let component_executable = active_toolchain.bin_path.join(component);
-            match std::process::Command::new(&component_executable)
-                .arg("--version")
-                .output()
-            {
-                Ok(o) => {
-                    let output = String::from_utf8_lossy(&o.stdout).into_owned();
-                    match output.split_whitespace().nth(1) {
-                        Some(v) => {
-                            let version = Version::parse(v)?;
-                            bold(|s| write!(s, "  {}", &component));
-                            println!(" : {}", version);
-                        }
-                        None => {
-                            eprintln!("  {} - Error getting version string", component);
-                        }
-                    };
-                }
-                Err(e) => {
-                    print!("  ");
-                    bold(|s| write!(s, "{}", &component));
-                    print!(" - ");
-                    if component_executable.exists() {
-                        println!("execution error - {}", e);
-                    } else {
-                        println!("not found");
-                    }
-                }
-            }
+            let component_executable = active_toolchain.bin_path.join(&component.name);
+            exec_show_version(&component.name, component_executable.as_path())?;
         };
 
-        if component == component::FORC {
-            for plugin in SUPPORTED_PLUGINS {
+        if component.name == component::FORC {
+            for plugin in Components::collect_plugins()? {
+                bold(|s| write!(s, "    - {}", &plugin.name));
                 if let Some(c) = channel.as_ref() {
-                    let version = &c.pkg[component].version;
+                    let version = &c.pkg[&component.name].version;
 
-                    if plugin == &component::FORC_DEPLOY {
-                        bold(|s| writeln!(s, "    - forc-client"));
+                    if !plugin.is_main_executable() {
+                        println!();
+                        for executable in plugin.executables.iter() {
+                            bold(|s| write!(s, "      - {}", &executable));
+                            println!(" : {}", version);
+                        }
+                    } else {
+                        println!(" : {}", version);
                     }
-                    if plugin == &component::FORC_RUN || plugin == &component::FORC_DEPLOY {
-                        print!("  ");
+                } else if !plugin.is_main_executable() {
+                    println!();
+                    for executable in plugin.executables.iter() {
+                        bold(|s| write!(s, "      - {}", &executable));
+                        let plugin_executable = active_toolchain.bin_path.join(&executable);
+                        exec_show_version(executable, plugin_executable.as_path())?;
                     }
-                    bold(|s| write!(s, "    - {}", &plugin));
-                    println!(" : {}", version);
                 } else {
-                    let plugin_executable = active_toolchain.bin_path.join(&plugin);
-                    if plugin == &component::FORC_DEPLOY {
-                        bold(|s| writeln!(s, "    - forc-client"));
-                    }
-                    if plugin == &component::FORC_RUN || plugin == &component::FORC_DEPLOY {
-                        print!("  ");
-                    }
-                    match std::process::Command::new(&plugin_executable)
-                        .arg("--version")
-                        .output()
-                    {
-                        Ok(o) => {
-                            let output = String::from_utf8_lossy(&o.stdout).into_owned();
-                            match output.split_whitespace().nth(1) {
-                                Some(v) => {
-                                    let version = Version::parse(v)?;
-                                    print!("    - ");
-                                    bold(|s| write!(s, "{}", plugin));
-                                    println!(" : {}", version);
-                                }
-                                None => {
-                                    eprintln!("    - {} - Error getting version string", plugin);
-                                }
-                            };
-                        }
-                        Err(e) => {
-                            print!("    - ");
-                            bold(|s| write!(s, "{}", plugin));
-                            print!(" - ");
-                            if plugin_executable.exists() {
-                                println!("execution error - {}", e);
-                            } else {
-                                println!("not found");
-                            }
-                        }
-                    }
+                    let plugin_executable = active_toolchain.bin_path.join(&plugin.name);
+                    exec_show_version(&plugin.name, plugin_executable.as_path())?;
                 }
             }
         }
diff --git a/src/proxy_cli.rs b/src/proxy_cli.rs
index db726213..08177705 100644
--- a/src/proxy_cli.rs
+++ b/src/proxy_cli.rs
@@ -4,7 +4,7 @@ use std::os::unix::prelude::CommandExt;
 use std::process::{Command, ExitCode, Stdio};
 use std::{env, io};
 
-use crate::component::SUPPORTED_PLUGINS;
+use crate::component::Components;
 use crate::toolchain::Toolchain;
 
 /// Runs forc or fuel-core in proxy mode
@@ -14,7 +14,7 @@ pub fn proxy_run(arg0: &str) -> Result<ExitCode> {
 
     if !cmd_args.is_empty() {
         let plugin = format!("{}-{}", arg0, &cmd_args[0].to_string_lossy());
-        if SUPPORTED_PLUGINS.contains(&plugin.as_str()) {
+        if Components::collect_plugin_executables()?.contains(&plugin) {
             direct_proxy(&plugin, &cmd_args[1..], &toolchain)?;
         }
     }
diff --git a/src/toolchain.rs b/src/toolchain.rs
index a399d732..aa693b4b 100644
--- a/src/toolchain.rs
+++ b/src/toolchain.rs
@@ -7,7 +7,7 @@ use std::str::FromStr;
 use time::Date;
 use tracing::{error, info};
 
-use crate::component::SUPPORTED_PLUGINS;
+use crate::component::Components;
 use crate::constants::DATE_FORMAT;
 use crate::download::{download_file_and_unpack, link_to_fuelup, unpack_bins, DownloadCfg};
 use crate::ops::fuelup_self::self_update;
@@ -245,9 +245,9 @@ impl Toolchain {
                 .with_context(|| format!("failed to remove component '{}'", component))?;
             // If component to remove is 'forc', silently remove forc plugins
             if component == component::FORC {
-                for component in SUPPORTED_PLUGINS {
-                    let component_path = self.bin_path.join(component);
-                    remove_file(component_path)
+                for plugin in Components::collect_plugin_executables()? {
+                    let plugin_path = self.bin_path.join(plugin);
+                    remove_file(plugin_path)
                         .with_context(|| format!("failed to remove component '{}'", component))?;
                 }
             }


Test patch:
diff --git a/tests/commands.rs b/tests/commands.rs
index e28ea1df..ee6566f7 100644
--- a/tests/commands.rs
+++ b/tests/commands.rs
@@ -111,12 +111,12 @@ active toolchain
 ----------------
 my_toolchain (default)
   forc - not found
-    - forc-fmt - not found
-    - forc-lsp - not found
-    - forc-explore - not found
     - forc-client
-      - forc-run - not found
       - forc-deploy - not found
+      - forc-run - not found
+    - forc-explore - not found
+    - forc-fmt - not found
+    - forc-lsp - not found
   fuel-core - not found
 "#;
         assert!(stdout.contains(expected_stdout));