Repository: FuelLabs/fuelup (rust)

Issue:
feat: support specifying dates for `latest` and `nightly`
Currently `fuel-toolchain.toml` does not support specifying dates as its `channel`.

Pull request description:
feat(fuel-toolchain.toml): support dates
closes #331

~Marked this as ready prematurely but converting back to a draft for now while I work on possibly refactoring parts of the `Toolchain` and `Channel` abstractions. There are various overlapping concepts here as a result of mostly forking the functionality of `rustup`, going to leave my thoughts below:~

~We currently have `DistToolchainDescription` to reason about toolchains - it usually comes in the form of `<name>-<date>-<target>`, with `date` and `target` being optional. It is used as a way to parse user input. It is valid for users to do `fuelup toolchain install latest`, and also `fuelup toolchain install nightly-2023-01-10`.~

~Currently `latest` has no knowledge or concept of dates - so this has to be updated. This includes various places of the codebase.~

EDIT:

This should be ready for review.

## Overview

We're using `DistToolchainDescription` whereever possible to display and parse toolchain inputs; because of this abstraction thankfully we're easily able to enforce dated `latest`s with this PR. The concepts surrounding `DistToolchainDescription` do seem a bit messy atm and I think this should call for some minor refactoring in future, but I'll leave that for a later PR.

## How to test?

First put this convenience script `build.sh` in fuelup dir:

```sh
cargo build
rm -f ~/.fuelup/bin/forc
rm -f ~/.fuelup/bin/fuelup
ln target/debug/fuelup ~/.fuelup/bin/forc
ln target/debug/fuelup ~/.fuelup/bin/fuelup
```

Use this to run cargo build and it should automatically link dev fuelup that handles the new logic from this PR. Note that this messes up your actual `forc` and `fuelup` so you would have to reinstall fuelup after you're done reviewing.

Create a `fuel-toolchain.toml`, declaring `latest-2023-01-06`:

```toml
[toolchain]
channel = "latest-2023-01-06"
```

This channel was manually deployed by me (we don't have any new versions yet, but I used the date of the last release which was forc v0.33)

Assuming you've ran the above `build.sh`, now try running `forc`. If you don't have the above toolchain installed, `fuelup` will now fetch and install it for you.

Now try it again with a random dated nightly:

```toml
[toolchain]
channel = "nightly-2022-09-23"
```

Now try using `latest` and `nightly` (without dates), this should warn you that the toml is invalid, and default to using your default toolchain's `forc`.




Patch:
diff --git a/Cargo.lock b/Cargo.lock
index d99fbec4..1983beac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -971,6 +971,7 @@ dependencies = [
  "itoa",
  "libc",
  "num_threads",
+ "serde",
  "time-macros",
 ]
 
diff --git a/Cargo.toml b/Cargo.toml
index 629c6b32..157ac03e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -31,7 +31,7 @@ sha2 = "0.10"
 tar = "0.4"
 tempfile = "3"
 termcolor = "1"
-time = { version = "0.3", features = ["macros", "parsing", "formatting"] }
+time = { version = "0.3", features = ["macros", "parsing", "formatting", "serde-well-known"] }
 toml_edit = { version = "0.13", features = ["serde", "easy"] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["ansi", "env-filter", "json"] }
diff --git a/src/channel.rs b/src/channel.rs
index ce9e3d83..ed790c47 100644
--- a/src/channel.rs
+++ b/src/channel.rs
@@ -12,6 +12,7 @@ use semver::Version;
 use serde::{Deserialize, Serialize};
 use sha2::{Digest, Sha256};
 use std::{collections::BTreeMap, fmt::Debug};
+use time::Date;
 use toml_edit::de;
 use tracing::warn;
 
@@ -42,24 +43,46 @@ pub fn is_beta_toolchain(name: &str) -> bool {
     name == BETA_1 || name == BETA_2
 }
 
+fn format_nightly_url(date: &Date) -> Result<String> {
+    Ok(format!(
+        "channels/nightly/{}",
+        &date.format(DATE_FORMAT_URL_FRIENDLY)?,
+    ))
+}
+
+fn construct_channel_url(desc: &DistToolchainDescription) -> Result<String> {
+    let mut url = FUELUP_GH_PAGES.to_owned();
+    match desc.name {
+        DistToolchainName::Latest => {
+            if let Some(date) = desc.date {
+                url.push_str(&format!(
+                    "channels/latest/channel-fuel-latest-{}.toml",
+                    date
+                ))
+            } else {
+                url.push_str(CHANNEL_LATEST_FILE_NAME)
+            }
+        }
+
+        DistToolchainName::Nightly => {
+            if let Some(date) = desc.date {
+                url.push_str(&format_nightly_url(&date)?);
+                url.push('/');
+            }
+            url.push_str(CHANNEL_NIGHTLY_FILE_NAME)
+        }
+        DistToolchainName::Beta1 => url.push_str(CHANNEL_BETA_1_FILE_NAME),
+        DistToolchainName::Beta2 => url.push_str(CHANNEL_BETA_2_FILE_NAME),
+    };
+
+    Ok(url)
+}
+
 impl Channel {
     /// The returned `String` is a sha256 hash of the downloaded toolchain TOML bytes.
     pub fn from_dist_channel(desc: &DistToolchainDescription) -> Result<(Self, String)> {
-        let channel_file_name = match desc.name {
-            DistToolchainName::Latest => CHANNEL_LATEST_FILE_NAME,
-            DistToolchainName::Nightly => CHANNEL_NIGHTLY_FILE_NAME,
-            DistToolchainName::Beta1 => CHANNEL_BETA_1_FILE_NAME,
-            DistToolchainName::Beta2 => CHANNEL_BETA_2_FILE_NAME,
-        };
-
-        let mut channel_url = FUELUP_GH_PAGES.to_owned();
-        if desc.name == DistToolchainName::Nightly && desc.date.is_some() {
-            channel_url.push_str("channels/nightly/");
-            channel_url.push_str(&desc.date.unwrap().format(DATE_FORMAT_URL_FRIENDLY)?);
-            channel_url.push('/');
-        }
+        let channel_url = construct_channel_url(desc)?;
 
-        channel_url.push_str(channel_file_name);
         let mut hasher = Sha256::new();
         let toml = match download(&channel_url, &mut hasher) {
             Ok(t) => String::from_utf8(t)?,
diff --git a/src/ops/fuelup_default.rs b/src/ops/fuelup_default.rs
index 6834698a..028930ba 100644
--- a/src/ops/fuelup_default.rs
+++ b/src/ops/fuelup_default.rs
@@ -21,7 +21,7 @@ pub fn default(toolchain: Option<String>) -> Result<()> {
                     match DistToolchainDescription::from_str(&to.cfg.toolchain.channel.to_string())
                     {
                         Ok(desc) => desc.to_string(),
-                        Err(_) => to.cfg.toolchain.channel,
+                        Err(_) => to.cfg.toolchain.channel.to_string(),
                     };
                 result.push_str(&format!("{} (override)", name));
 
diff --git a/src/ops/fuelup_show.rs b/src/ops/fuelup_show.rs
index 1d6722bb..9e51a7fa 100644
--- a/src/ops/fuelup_show.rs
+++ b/src/ops/fuelup_show.rs
@@ -58,9 +58,11 @@ pub fn show() -> Result<()> {
     let toolchain_override = ToolchainOverride::from_project_root();
 
     let override_name = if let Some(toolchain_override) = toolchain_override.as_ref() {
-        match DistToolchainDescription::from_str(&toolchain_override.cfg.toolchain.channel) {
+        match DistToolchainDescription::from_str(
+            &toolchain_override.cfg.toolchain.channel.to_string(),
+        ) {
             Ok(desc) => Some(desc.to_string()),
-            Err(_) => Some(toolchain_override.cfg.toolchain.channel.clone()),
+            Err(_) => Some(toolchain_override.cfg.toolchain.channel.to_string()),
         }
     } else {
         None
diff --git a/src/proxy_cli.rs b/src/proxy_cli.rs
index 7e9bee98..1e893ce3 100644
--- a/src/proxy_cli.rs
+++ b/src/proxy_cli.rs
@@ -35,7 +35,7 @@ fn direct_proxy(proc_name: &str, args: &[OsString], toolchain: &Toolchain) -> Re
             // unwrap() is safe here since we try DistToolchainDescription::from_str()
             // when deserializing from the toml.
             let description =
-                DistToolchainDescription::from_str(&to.cfg.toolchain.channel).unwrap();
+                DistToolchainDescription::from_str(&to.cfg.toolchain.channel.to_string()).unwrap();
             let toolchain = Toolchain::from_path(&description.to_string());
 
             // Install the entire toolchain declared in [toolchain] if it does not exist.
diff --git a/src/toolchain.rs b/src/toolchain.rs
index ccbbbb81..80663c22 100644
--- a/src/toolchain.rs
+++ b/src/toolchain.rs
@@ -279,6 +279,7 @@ impl Toolchain {
 
     pub fn install_if_nonexistent(&self, description: &DistToolchainDescription) -> Result<()> {
         if !self.exists() {
+            info!("toolchain '{}' does not exist; installing", description);
             if let Ok((channel, hash)) = Channel::from_dist_channel(description) {
                 let config = Config::from_env()?;
                 if let Ok(true) = config.hash_matches(description, &hash) {
diff --git a/src/toolchain_override.rs b/src/toolchain_override.rs
index f46157b5..6734fc1d 100644
--- a/src/toolchain_override.rs
+++ b/src/toolchain_override.rs
@@ -1,18 +1,20 @@
 use anyhow::{bail, Result};
 use semver::Version;
-use serde::{Deserialize, Serialize};
+use serde::de::Error;
+use serde::ser::SerializeStruct;
+use serde::{Deserialize, Deserializer, Serialize};
+use std::fmt;
 use std::str::FromStr;
 use std::{collections::HashMap, path::PathBuf};
+use time::Date;
 use toml_edit::{de, ser, value, Document};
 use tracing::{info, warn};
 
+use crate::channel::{is_beta_toolchain, LATEST, NIGHTLY};
+use crate::constants::{DATE_FORMAT, FUEL_TOOLCHAIN_TOML_FILE};
+use crate::toolchain::{DistToolchainDescription, Toolchain};
 use crate::{
-    constants::FUEL_TOOLCHAIN_TOML_FILE,
-    download::DownloadCfg,
-    file,
-    path::get_fuel_toolchain_toml,
-    target_triple::TargetTriple,
-    toolchain::{DistToolchainName, Toolchain},
+    download::DownloadCfg, file, path::get_fuel_toolchain_toml, target_triple::TargetTriple,
 };
 
 // For composability with other functionality of fuelup, we want to add
@@ -33,9 +35,78 @@ pub struct OverrideCfg {
 }
 
 // Represents the [toolchain] table in 'fuel-toolchain.toml'.
-#[derive(Debug, Deserialize, Serialize)]
+#[derive(Debug, Deserialize)]
 pub struct ToolchainCfg {
-    pub channel: String,
+    #[serde(deserialize_with = "deserialize_channel")]
+    pub channel: Channel,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct Channel {
+    pub name: String,
+    pub date: Option<Date>,
+}
+
+impl Serialize for ToolchainCfg {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut c = serializer.serialize_struct("ToolchainCfg", 2)?;
+        c.serialize_field("channel", &self.channel.to_string())?;
+
+        c.end()
+    }
+}
+
+pub fn deserialize_channel<'de, D>(deserializer: D) -> Result<Channel, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let channel_str = String::deserialize(deserializer)?;
+
+    channel_str.parse().map_or_else(
+        |_| {
+            Err(Error::invalid_value(
+                serde::de::Unexpected::Str(&channel_str),
+                &"a channel with date <latest|nightly>-YYYY-MM-DD",
+            ))
+        },
+        Result::Ok,
+    )
+}
+
+impl fmt::Display for Channel {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self.date {
+            Some(d) => write!(f, "{}-{}", self.name, d),
+            None => write!(f, "{}", self.name),
+        }
+    }
+}
+
+impl FromStr for Channel {
+    type Err = anyhow::Error;
+    fn from_str(s: &str) -> Result<Self> {
+        if is_beta_toolchain(s) {
+            return Ok(Self {
+                name: s.to_string(),
+                date: None,
+            });
+        };
+
+        if let Some((name, d)) = s.split_once('-') {
+            Ok(Self {
+                name: name.to_string(),
+                date: Date::parse(d, DATE_FORMAT).ok(),
+            })
+        } else {
+            if s == LATEST || s == NIGHTLY {
+                bail!("'{s}' without date specifier is forbidden");
+            }
+            bail!("Invalid str for channel: '{}'", s);
+        }
+    }
 }
 
 impl ToolchainOverride {
@@ -120,7 +191,7 @@ impl OverrideCfg {
     // an OverrideCfg with its file path.
     pub(crate) fn from_toml(toml: &str) -> Result<Self> {
         let cfg: OverrideCfg = de::from_str(toml)?;
-        if DistToolchainName::from_str(&cfg.toolchain.channel).is_err() {
+        if DistToolchainDescription::from_str(&cfg.toolchain.channel.to_string()).is_err() {
             bail!("Invalid channel '{}'", &cfg.toolchain.channel)
         }
 
@@ -140,41 +211,66 @@ impl OverrideCfg {
 
 #[cfg(test)]
 mod tests {
+    use crate::channel::{BETA_1, BETA_2, NIGHTLY};
+
     use super::*;
 
     #[test]
-    fn parse_toolchain_override_channel_only() {
+    fn parse_toolchain_override_latest_with_date() {
         const TOML: &str = r#"[toolchain]
-channel = "latest"
+channel = "latest-2023-01-09"
 "#;
-
         let cfg = OverrideCfg::from_toml(TOML).unwrap();
 
-        assert_eq!(cfg.toolchain.channel, "latest");
+        assert_eq!(cfg.toolchain.channel.to_string(), "latest-2023-01-09");
+
         assert!(cfg.components.is_none());
         assert_eq!(TOML, cfg.to_string_pretty().unwrap());
     }
 
     #[test]
-    fn parse_toolchain_override_components() {
+    fn parse_toolchain_override_nightly_with_date() {
         const TOML: &str = r#"[toolchain]
-channel = "latest"
+channel = "nightly-2023-01-09"
 
 [components]
-fuel-core = "0.15.1"
+forc = "0.33.0"
 "#;
-
         let cfg = OverrideCfg::from_toml(TOML).unwrap();
 
-        assert_eq!(cfg.toolchain.channel, "latest");
-        assert_eq!(cfg.components.as_ref().unwrap().keys().len(), 1);
+        assert_eq!(cfg.toolchain.channel.to_string(), "nightly-2023-01-09");
         assert_eq!(
-            cfg.components.as_ref().unwrap().get("fuel-core").unwrap(),
-            &Version::new(0, 15, 1)
+            cfg.components.as_ref().unwrap().get("forc").unwrap(),
+            &Version::new(0, 33, 0)
         );
         assert_eq!(TOML, cfg.to_string_pretty().unwrap());
     }
 
+    #[test]
+    fn parse_toolchain_override_channel_without_date_error() {
+        const LATEST: &str = r#"[toolchain]
+channel = "latest"
+"#;
+        const NIGHTLY: &str = r#"[toolchain]
+channel = "nightly"
+"#;
+
+        let result = OverrideCfg::from_toml(LATEST);
+        assert!(result.is_err());
+        let e = result.unwrap_err();
+        assert_eq!(e
+            .to_string(),
+            "invalid value: string \"latest\", expected a channel with date <latest|nightly>-YYYY-MM-DD for key `toolchain.channel`".to_string());
+
+        let result = OverrideCfg::from_toml(NIGHTLY);
+        assert!(result.is_err());
+        let e = result.unwrap_err();
+
+        assert_eq!(e
+            .to_string(),
+            "invalid value: string \"nightly\", expected a channel with date <latest|nightly>-YYYY-MM-DD for key `toolchain.channel`".to_string());
+    }
+
     #[test]
     fn parse_toolchain_override_invalid_tomls() {
         const EMPTY_STR: &str = "";
@@ -183,7 +279,6 @@ fuel-core = "0.15.1"
         const INVALID_CHANNEL: &str = r#"[toolchain]
 channel = "invalid-channel"
 "#;
-
         const EMPTY_COMPONENTS: &str = r#"[toolchain]
 channel = "beta-2"
 
@@ -199,4 +294,12 @@ channel = "beta-2"
             assert!(OverrideCfg::from_toml(toml).is_err());
         }
     }
+
+    #[test]
+    fn channel_from_str() {
+        assert!(Channel::from_str(BETA_1).is_ok());
+        assert!(Channel::from_str(BETA_2).is_ok());
+        assert!(Channel::from_str(NIGHTLY).is_err());
+        assert!(Channel::from_str(LATEST).is_err());
+    }
 }


Test patch:
diff --git a/tests/testcfg.rs b/tests/testcfg.rs
index 959d228c..47592a3a 100644
--- a/tests/testcfg.rs
+++ b/tests/testcfg.rs
@@ -2,8 +2,9 @@ use anyhow::Result;
 use fuelup::constants::FUEL_TOOLCHAIN_TOML_FILE;
 use fuelup::settings::SettingsFile;
 use fuelup::target_triple::TargetTriple;
-use fuelup::toolchain_override::{OverrideCfg, ToolchainCfg, ToolchainOverride};
+use fuelup::toolchain_override::{self, OverrideCfg, ToolchainCfg, ToolchainOverride};
 use std::os::unix::fs::OpenOptionsExt;
+use std::str::FromStr;
 use std::{
     env, fs,
     path::{Path, PathBuf},
@@ -263,7 +264,7 @@ pub fn setup(state: FuelupState, f: &dyn Fn(&mut TestCfg)) -> Result<()> {
             setup_override_file(ToolchainOverride {
                 cfg: OverrideCfg::new(
                     ToolchainCfg {
-                        channel: "beta-1".to_string(),
+                        channel: toolchain_override::Channel::from_str("beta-1").unwrap(),
                     },
                     None,
                 ),