Repository: FuelLabs/fuelup (rust)

Issue:
Provide a way to view SDK version compatibility for official toolchains
While `fuelup` cannot *ship* the SDK per se, it would be nice if we could at least provide a way of determining a version that is definitely compatible with a toolchain's version of `forc` and `fuel-core`.

We might do this by adding a special step that inspects the version of `fuels` used within the sway repo E2E tests when checking for a new `forc` version?

We can likely only achieve this nicely for the official toolchains, as there is nothing stopping a user from constructing a custom toolchain using versions of `forc` and `forc-client` with differing SDK versions for example.

Pull request:
feat: support fetching and showing fuels_version in fuelup show
closes #289

This doesn't really address the issue directly because it shows the compatible `fuels` version on a component level rather than the toolchain level, which I think is probably better.

I was contemplating between dealing with this on the toolchain-level or on the component-level, and it came down to where we wanted the logic for getting the fuels version to live.

### The problem

Ultimately, trying to get the fuels version means we have to unavoidably fetch it from the `Cargo.toml` directly, whether we solve it on the toolchain-level or component-level.

On the toolchain-level, we could publish the info in our channels, but that means that on a component-level we do not know what `fuels` version is compatible.

On the component-level, we could fetch the info directly on the first installation, but that means in some areas of fuelup we have to hardcode the logic to fetch the info directly, which may be ugly (see). However this method is a lot more flexible and we can leverage the store by storing a `fuels_version` textfile that is cached in the component directory, rather than store it in the toolchain directory.

### Solution 
Ultimately I decided to go with shipping this feature using the latter method.

My justification:

- In both circumstances we __have__ to maintain some ugly logic that hard codes the URI of the `Cargo.toml` file to fetch and read the `fuels` version, so either way we would have to maintain such code. If we had chosen to ship this feature on the toolchain-level, we would have this logic in the `build-channel` script anyway. I'd say the upside of shipping this on the backend side means that we don't need new `fuelup` releases in the case that the URI changes, and that could be a strong case for NOT taking this approach.

- The latter method also allows us to show the tested `fuels` version per component instead of per toolchain, which I think is better DevEx for users.

At this point of my thought process I'm not sure if there's any cataclysmic downside to doing it as such instead of the original proposed method. I'm still open minded as to the best approach, so let me know if there's some flaw in my thinking here!

Patch:
diff --git a/component/src/lib.rs b/component/src/lib.rs
index c8e3ec67..3c678bc0 100644
--- a/component/src/lib.rs
+++ b/component/src/lib.rs
@@ -17,7 +17,7 @@ pub struct Components {
     pub component: HashMap<String, Component>,
 }
 
-#[derive(Debug, Deserialize, Clone)]
+#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Hash)]
 pub struct Component {
     pub name: String,
     pub is_plugin: Option<bool>,
@@ -26,6 +26,7 @@ pub struct Component {
     pub repository_name: String,
     pub targets: Vec<String>,
     pub publish: Option<bool>,
+    pub show_fuels_version: Option<bool>,
 }
 
 impl Component {
@@ -39,6 +40,7 @@ impl Component {
                 targets: vec![FUELUP.to_string()],
                 is_plugin: Some(false),
                 publish: Some(true),
+                show_fuels_version: Some(false),
             });
         }
 
@@ -133,6 +135,25 @@ impl Components {
         Ok(main_components)
     }
 
+    pub fn collect_show_fuels_versions() -> Result<Vec<Component>> {
+        let components = Self::from_toml(COMPONENTS_TOML)?;
+
+        let mut components_to_show = components
+            .component
+            .values()
+            .filter_map(|c| {
+                if let Some(true) = c.show_fuels_version {
+                    Some(c.clone())
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<Component>>();
+
+        components_to_show.sort_by_key(|c| c.name.clone());
+
+        Ok(components_to_show)
+    }
     pub fn collect_plugins() -> Result<Vec<Plugin>> {
         let components = Self::from_toml(COMPONENTS_TOML)?;
 
diff --git a/components.toml b/components.toml
index b66f86f4..640d0f08 100644
--- a/components.toml
+++ b/components.toml
@@ -5,6 +5,7 @@ executables = ["forc", "forc-fmt", "forc-lsp", "forc-doc", "forc-deploy", "forc-
 repository_name = "sway"
 targets = ["linux_amd64", "linux_arm64", "darwin_amd64", "darwin_arm64"]
 publish = true
+show_fuels_version = true
 
 [component.forc-doc]
 name = "forc-doc"
@@ -71,6 +72,7 @@ executables = ["forc-wallet"]
 repository_name = "forc-wallet"
 targets = [ "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-apple-darwin" ]
 publish = true
+show_fuels_version = true
 
 [component.fuel-indexer]
 name = "fuel-indexer"
diff --git a/src/channel.rs b/src/channel.rs
index 936bc5b0..45451f4f 100644
--- a/src/channel.rs
+++ b/src/channel.rs
@@ -39,6 +39,7 @@ pub struct Channel {
 pub struct Package {
     pub target: BTreeMap<String, HashedBinary>,
     pub version: Version,
+    pub fuels_version: Option<String>,
 }
 
 pub fn is_beta_toolchain(name: &str) -> bool {
@@ -98,13 +99,13 @@ impl Channel {
         Ok(channel)
     }
 
-    pub fn build_download_configs(self) -> Vec<DownloadCfg> {
+    pub fn build_download_configs(&self) -> Vec<DownloadCfg> {
         let mut cfgs = self
             .pkg
-            .into_iter()
+            .iter()
             .filter(|(component_name, _)| Components::contains_published(component_name))
             .map(|(name, package)| {
-                DownloadCfg::from_package(&name, package).map_err(|_| {
+                DownloadCfg::from_package(name, package).map_err(|_| {
                     warn!(
                         "Failed to recognize component: '{}'.
 If this component should be downloadable, try running `fuelup self update` and re-run the installation.",
@@ -138,11 +139,13 @@ mod tests {
             channel.pkg["forc"].version,
             Version::parse("0.17.0").unwrap()
         );
+        assert_eq!(channel.pkg["forc"].fuels_version, Some("0.36".to_string()));
         assert!(channel.pkg.contains_key("fuel-core"));
         assert_eq!(
             channel.pkg["fuel-core"].version,
             Version::parse("0.9.4").unwrap()
         );
+        assert_eq!(channel.pkg["fuel-core"].fuels_version, None);
 
         let targets = &channel.pkg["forc"].target;
         assert_eq!(targets.len(), 4);
diff --git a/src/constants.rs b/src/constants.rs
index 4de0ed35..bf76b91a 100644
--- a/src/constants.rs
+++ b/src/constants.rs
@@ -2,6 +2,7 @@ use time::{format_description::FormatItem, macros::format_description};
 
 pub const FUELUP_GH_PAGES: &str = "https://raw.githubusercontent.com/FuelLabs/fuelup/gh-pages/";
 pub const FUEL_TOOLCHAIN_TOML_FILE: &str = "fuel-toolchain.toml";
+pub const FUELS_VERSION_FILE: &str = "fuels_version";
 
 pub const CHANNEL_LATEST_URL: &str =
     "https://raw.githubusercontent.com/FuelLabs/fuelup/gh-pages/channel-fuel-latest.toml";
diff --git a/src/download.rs b/src/download.rs
index 5cdb9daf..8721a2d3 100644
--- a/src/download.rs
+++ b/src/download.rs
@@ -74,7 +74,7 @@ impl DownloadCfg {
         })
     }
 
-    pub fn from_package(name: &str, package: Package) -> Result<Self> {
+    pub fn from_package(name: &str, package: &Package) -> Result<Self> {
         let target = TargetTriple::from_component(name)?;
         let tarball_name = tarball_name(name, &package.version, &target);
         let tarball_url = package.target[&target.to_string()].url.clone();
@@ -82,7 +82,7 @@ impl DownloadCfg {
         Ok(Self {
             name: name.to_string(),
             target,
-            version: package.version,
+            version: package.version.clone(),
             tarball_name,
             tarball_url,
             hash,
@@ -307,6 +307,63 @@ pub fn unpack_bins(dir: &Path, dst_dir: &Path) -> Result<Vec<PathBuf>> {
     Ok(downloaded)
 }
 
+/// Read the version (as a plain String) used by the `fuels` dependency, if it exists.
+fn fuels_version_from_toml(toml: toml_edit::Document) -> Result<String> {
+    if let Some(deps) = toml.get("dependencies") {
+        if let Some(fuels) = deps.get("fuels") {
+            let version = match fuels.as_value() {
+                Some(toml_edit::Value::String(s)) => s.value().to_string(),
+                Some(toml_edit::Value::InlineTable(t)) => t.get("version").map_or_else(
+                    || "".to_string(),
+                    |v| v.as_str().unwrap_or_default().to_string(),
+                ),
+                _ => String::default(),
+            };
+            println!("vs: {version}");
+
+            return Ok(version);
+        } else {
+            bail!("'fuels' dependency does not exist");
+        };
+    };
+
+    bail!("the table 'dependencies' does not exist");
+}
+
+/// Fetches the Cargo.toml of a component in its repository and tries to read the version of
+/// the `fuels` dependency.
+pub fn fetch_fuels_version(cfg: &DownloadCfg) -> Result<String> {
+    let url = match cfg.name.as_str() {
+        "forc" => format!(
+            "https://raw.githubusercontent.com/FuelLabs/sway/v{}/test/src/sdk-harness/Cargo.toml",
+            cfg.version
+        ),
+        "forc-wallet" => {
+            format!(
+                "https://raw.githubusercontent.com/FuelLabs/forc-wallet/v{}/Cargo.toml",
+                cfg.version
+            )
+        }
+        _ => bail!("invalid component to fetch fuels version for"),
+    };
+
+    let handle = if let Ok(proxy) = env::var("http_proxy") {
+        ureq::builder()
+            .user_agent("fuelup")
+            .proxy(ureq::Proxy::new(proxy)?)
+            .build()
+    } else {
+        ureq::builder().user_agent("fuelup").build()
+    };
+
+    if let Ok(resp) = handle.get(&url).call() {
+        let cargo_toml = toml_edit::Document::from_str(&resp.into_string()?)?;
+        return fuels_version_from_toml(cargo_toml);
+    }
+
+    bail!("Failed to get fuels version");
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -321,6 +378,34 @@ mod tests {
         f(toolchain_bin_dir)
     }
 
+    #[test]
+    fn test_fuels_version_from_toml() {
+        let toml = r#"
+[package]        
+name = "forc"
+
+[dependencies]
+fuels = "0.1"
+"#;
+        assert_eq!(
+            "0.1",
+            fuels_version_from_toml(toml_edit::Document::from_str(toml).unwrap()).unwrap()
+        );
+
+        let toml = r#"
+[package]        
+name = "forc"
+
+[dependencies]
+fuels = { version = "0.1", features = ["some-feature"] }
+"#;
+
+        assert_eq!(
+            "0.1",
+            fuels_version_from_toml(toml_edit::Document::from_str(toml).unwrap()).unwrap()
+        );
+    }
+
     #[test]
     fn test_unpack_and_link_bins() -> Result<()> {
         with_toolchain_dir(|dir| {
diff --git a/src/ops/fuelup_show.rs b/src/ops/fuelup_show.rs
index a20bfefa..10a88930 100644
--- a/src/ops/fuelup_show.rs
+++ b/src/ops/fuelup_show.rs
@@ -1,10 +1,12 @@
-use anyhow::Result;
+use anyhow::{bail, Result};
 use component::{self, Components};
 use semver::Version;
+use std::collections::HashMap;
 use std::str::FromStr;
 use std::{io::Write, path::Path};
 use tracing::{error, info};
 
+use crate::store::Store;
 use crate::{
     config::Config,
     fmt::{bold, print_header},
@@ -14,7 +16,7 @@ use crate::{
     toolchain_override::ToolchainOverride,
 };
 
-fn exec_show_version(component_executable: &Path) -> Result<()> {
+fn exec_show_version(component_executable: &Path) -> Result<Version> {
     match std::process::Command::new(component_executable)
         .arg("--version")
         .output()
@@ -25,6 +27,7 @@ fn exec_show_version(component_executable: &Path) -> Result<()> {
                 Some(v) => {
                     let version = Version::parse(v)?;
                     info!(" : {}", version);
+                    return Ok(version);
                 }
                 None => {
                     error!(" : Error getting version string");
@@ -41,7 +44,7 @@ fn exec_show_version(component_executable: &Path) -> Result<()> {
         }
     }
 
-    Ok(())
+    bail!("could not show version: {}", component_executable.display());
 }
 
 pub fn show() -> Result<()> {
@@ -102,10 +105,13 @@ pub fn show() -> Result<()> {
     print_header("\nactive toolchain");
     info!("{}", active_toolchain_message);
 
+    let mut version_map: HashMap<String, Version> = HashMap::new();
     for component in Components::collect_exclude_plugins()? {
         bold(|s| write!(s, "  {}", &component.name));
         let component_executable = active_toolchain.bin_path.join(&component.name);
-        exec_show_version(component_executable.as_path())?;
+        if let Ok(version) = exec_show_version(component_executable.as_path()) {
+            version_map.insert(component.name.clone(), version);
+        };
 
         if component.name == component::FORC {
             for plugin in Components::collect_plugins()? {
@@ -115,15 +121,37 @@ pub fn show() -> Result<()> {
                     for executable in plugin.executables.iter() {
                         bold(|s| write!(s, "      - {}", &executable));
                         let plugin_executable = active_toolchain.bin_path.join(executable);
-                        exec_show_version(plugin_executable.as_path())?;
+                        if let Ok(version) = exec_show_version(plugin_executable.as_path()) {
+                            version_map.insert(executable.clone(), version);
+                        };
                     }
                 } else {
                     let plugin_executable = active_toolchain.bin_path.join(&plugin.name);
-                    exec_show_version(plugin_executable.as_path())?;
+                    if let Ok(version) = exec_show_version(plugin_executable.as_path()) {
+                        version_map.insert(plugin.name.clone(), version);
+                    };
                 }
             }
         }
     }
 
+    let store = Store::from_env()?;
+
+    let mut fuels_version_header_shown = false;
+    for component in Components::collect_show_fuels_versions()? {
+        if let Some(version) = version_map.get(&component.name) {
+            if let Ok(fuels_version) = store.get_cached_fuels_version(&component.name, version) {
+                // Only print the header if we find an Ok fuels_version to show.
+                if !fuels_version_header_shown {
+                    print_header("\nfuels versions");
+                    fuels_version_header_shown = true;
+                }
+
+                bold(|s| write!(s, "{}", &component.name));
+                info!(" : {}", fuels_version);
+            };
+        }
+    }
+
     Ok(())
 }
diff --git a/src/store.rs b/src/store.rs
index cc947bd9..c96a9d82 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -1,10 +1,15 @@
+use std::fs;
+use std::io::Write;
 use std::path::{Path, PathBuf};
 
 use anyhow::Result;
+use component::Component;
 use semver::Version;
+use tracing::{info, warn};
 
 use crate::{
-    download::{download_file_and_unpack, unpack_bins, DownloadCfg},
+    constants::FUELS_VERSION_FILE,
+    download::{download_file_and_unpack, fetch_fuels_version, unpack_bins, DownloadCfg},
     path::{ensure_dir_exists, store_dir},
 };
 
@@ -41,9 +46,51 @@ impl Store {
     pub(crate) fn install_component(&self, cfg: &DownloadCfg) -> Result<Vec<PathBuf>> {
         let component_dir = self.component_dir_path(&cfg.name, &cfg.version);
 
+        // Cache fuels_version for this component if show_fuels_version exists and is true.
+        // We don't want this failure to block installation, so errors are ignored here.
+        if let Ok(c) = Component::from_name(&cfg.name) {
+            if let Some(true) = c.show_fuels_version {
+                if let Err(e) = self.cache_fuels_version(cfg) {
+                    warn!(
+                        "Failed to cache fuels version for component '{}': {}",
+                        cfg.name, e
+                    );
+                };
+            }
+        };
+
         ensure_dir_exists(&component_dir)?;
         download_file_and_unpack(cfg, &component_dir)?;
         // We ensure that component_dir exists above, so its parent must exist here.
         unpack_bins(&component_dir, &component_dir)
     }
+
+    pub(crate) fn cache_fuels_version(&self, cfg: &DownloadCfg) -> Result<()> {
+        let dirname = component_dirname(&cfg.name, &cfg.version);
+        let component_dir = self.path().join(dirname);
+
+        if let Ok(fuels_version) = fetch_fuels_version(cfg) {
+            info!(
+                "Caching fuels version at {}",
+                component_dir.join(FUELS_VERSION_FILE).display()
+            );
+            ensure_dir_exists(&component_dir)?;
+            let mut fuels_version_file =
+                std::fs::File::create(component_dir.join(FUELS_VERSION_FILE))?;
+
+            write!(fuels_version_file, "{fuels_version}")?;
+        };
+
+        Ok(())
+    }
+
+    pub(crate) fn get_cached_fuels_version(
+        &self,
+        name: &str,
+        version: &Version,
+    ) -> std::io::Result<String> {
+        let dirname = component_dirname(name, version);
+
+        fs::read_to_string(self.path().join(dirname).join(FUELS_VERSION_FILE))
+    }
 }


Test patch:
diff --git a/tests/channel-fuel-latest-example.toml b/tests/channel-fuel-latest-example.toml
index 4716167b..97a65ae9 100644
--- a/tests/channel-fuel-latest-example.toml
+++ b/tests/channel-fuel-latest-example.toml
@@ -2,6 +2,7 @@ published_by = "https://github.com/FuelLabs/fuelup/actions/runs/12345"
 
 [pkg.forc]
 version = "0.17.0"
+fuels_version = "0.36"
 
 [pkg.forc.target.darwin_amd64]
 url = "https://github.com/FuelLabs/sway/releases/download/v0.17.0/forc-binaries-darwin_amd64.tar.gz"
diff --git a/tests/show.rs b/tests/show.rs
index 692f2e0b..485834cd 100644
--- a/tests/show.rs
+++ b/tests/show.rs
@@ -47,6 +47,7 @@ latest-{target} (default)
 "#
         );
         assert!(stdout.contains(expected_stdout));
+        assert!(!stdout.contains("fuels versions"));
     })?;
     Ok(())
 }
@@ -89,6 +90,7 @@ latest-{target} (default)
 "#
         );
         assert!(stdout.contains(expected_stdout));
+        assert!(!stdout.contains("fuels versions"));
 
         cfg.fuelup(&["default", "nightly"]);
         stdout = cfg.fuelup(&["show"]).stdout;
@@ -121,6 +123,7 @@ nightly-{target} (default)
 "#
         );
         assert!(stdout.contains(expected_stdout));
+        assert!(!stdout.contains("fuels versions"));
     })?;
 
     Ok(())
@@ -162,6 +165,7 @@ my_toolchain (default)
   fuel-indexer - not found
 "#;
         assert!(stdout.contains(expected_stdout));
+        assert!(!stdout.contains("fuels versions"));
     })?;
     Ok(())
 }
@@ -205,6 +209,7 @@ beta-1-{target} (override), path: {}
             cfg.home.join(FUEL_TOOLCHAIN_TOML_FILE).display()
         );
         assert!(stdout.contains(expected_stdout));
+        assert!(!stdout.contains("fuels versions"));
     })?;
     Ok(())
 }
@@ -248,6 +253,7 @@ latest-{target} (default)
 "#,
         );
         assert!(stdout.contains(expected_stdout));
+        assert!(!stdout.contains("fuels versions"));
 
         let toolchain_override = ToolchainOverride {
             cfg: OverrideCfg::new(
@@ -293,6 +299,7 @@ latest-{target} (default)
   fuel-indexer : 0.2.0
 "#;
         assert!(stdout.contains(expected_stdout));
+        assert!(!stdout.contains("fuels versions"));
     })?;
     Ok(())
 }