Repository: taiki-e/cargo-hack (language: rust)

Issue:
Flag to run cargo commands with the appropriate MSRV
I'm imagining a `--rust-version` flag that would add a `+<package.rust-version>` to each underlying cargo invocation.


Discussion / hints:
Sometimes there is a different MSRV for development and release.  I think a good next step past this is a flag to run `cargo package` on each workspace member, extract, patch, and then build those

Mentioning this now for the smaller issue in case it affects the CLI design for this one
Thanks for the suggestion! 

I think `--version-range .. --version-step <large number>` can do something similar, but it makes sense to have a dedicated flag.

https://github.com/taiki-e/cargo-hack#--version-range


> If the lower bound of the range is omitted, the value of the rust-version field in Cargo.toml is used as the lower bound.
> 
> You can specify the version interval by using --version-step.


Oh, interesting.  I agree that a short-hand for this would also be useful.

I can go add this if you are ok (which it sounds like)


> I can go add this if you are ok (which it sounds like)

Thanks. I would be happy to accept a PR to add this.

Fix patch:
diff --git a/README.md b/README.md
index 97aa46c0..98058178 100644
--- a/README.md
+++ b/README.md
@@ -163,6 +163,11 @@ OPTIONS:
 
             This flag can only be used together with either --features or --include-features.
 
+        --rust-version
+            Perform commands on `package.rust-version`.
+
+            This cannot be used with --version-range.
+
         --version-range [START]..[=END]
             Perform commands on a specified (inclusive) range of Rust versions.
 
diff --git a/src/cli.rs b/src/cli.rs
index 6e0d08b1..825b116b 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -149,6 +149,7 @@ impl Args {
         let mut keep_going = false;
         let mut print_command_list = false;
         let mut no_manifest_path = false;
+        let mut rust_version = false;
         let mut version_range = None;
         let mut version_step = None;
 
@@ -236,6 +237,7 @@ impl Args {
 
                 Long("manifest-path") => parse_opt!(manifest_path, false),
                 Long("depth") => parse_opt!(depth, false),
+                Long("rust-version") => parse_flag!(rust_version),
                 Long("version-range") => parse_opt!(version_range, false),
                 Long("version-step") => parse_opt!(version_step, false),
 
@@ -507,11 +509,28 @@ impl Args {
             }
         }
 
-        if version_range.is_some() {
-            let rustup = Rustup::new();
-            if rustup.version < 23 {
-                bail!("--version-range requires rustup 1.23 or later");
+        let version_range = match (version_range, rust_version) {
+            (Some(_), true) => {
+                conflicts("--version-range", "--rust-version")?;
+                unreachable!()
+            }
+            (Some(version_range), false) => {
+                let rustup = Rustup::new();
+                if rustup.version < 23 {
+                    bail!("--version-range requires rustup 1.23 or later");
+                }
+                Some(version_range.parse()?)
             }
+            (None, true) => {
+                let rustup = Rustup::new();
+                if rustup.version < 23 {
+                    bail!("--rust-version requires rustup 1.23 or later");
+                }
+                Some(VersionRange::msrv())
+            }
+            (None, false) => None,
+        };
+        if version_range.is_some() {
         } else {
             if version_step.is_some() {
                 requires("--version-step", &["--version-range"])?;
@@ -520,7 +539,6 @@ impl Args {
                 requires("--clean-per-version", &["--version-range"])?;
             }
         }
-        let version_range = version_range.map(|v| v.parse()).transpose()?;
 
         if no_dev_deps {
             info!(
@@ -728,6 +746,15 @@ const HELP: &[HelpText<'_>] = &[
         "Skip passing --features flag to `cargo` if that feature does not exist in the package",
         &["This flag can only be used together with either --features or --include-features."],
     ),
+    (
+        "",
+        "--rust-version",
+        "",
+        "Perform commands on `package.rust-version`",
+        &[
+            "This cannot be used with --version-range.",
+        ],
+    ),
     (
         "",
         "--version-range",
diff --git a/src/rustup.rs b/src/rustup.rs
index e56e8c00..058a2f98 100644
--- a/src/rustup.rs
+++ b/src/rustup.rs
@@ -5,7 +5,7 @@ use anyhow::{bail, format_err, Result};
 use crate::{
     cargo,
     context::Context,
-    version::{Version, VersionRange},
+    version::{MaybeVersion, Version, VersionRange},
 };
 
 pub(crate) struct Rustup {
@@ -41,39 +41,65 @@ pub(crate) fn version_range(
         Ok(())
     };
 
-    let VersionRange { start_inclusive, end_inclusive } = range;
+    let mut stable_version = None;
+    let mut get_stable_version = || -> Result<Version> {
+        if let Some(stable_version) = stable_version {
+            Ok(stable_version)
+        } else {
+            install_toolchain("stable", &[], false)?;
+            let version = cargo::version(cmd!("cargo", "+stable"))?;
+            stable_version = Some(version);
+            Ok(version)
+        }
+    };
 
-    let start_inclusive = match start_inclusive {
-        Some(start) => start,
-        None => {
-            let mut rust_version = None;
+    let mut rust_version = None;
+    let mut get_rust_version = || -> Result<Version> {
+        if let Some(rust_version) = rust_version {
+            Ok(rust_version)
+        } else {
+            let mut version = None;
             for id in cx.workspace_members() {
                 let v = cx.rust_version(id);
-                if v.is_none() || v == rust_version {
+                if v.is_none() || v == version {
                     // no-op
-                } else if rust_version.is_none() {
-                    rust_version = v;
+                } else if version.is_none() {
+                    version = v;
                 } else {
                     bail!("automatic detection of the lower bound of the version range is not yet supported when the minimum supported Rust version of the crates in the workspace do not match")
                 }
             }
-            match rust_version {
+            let version = match version {
                 Some(v) => v.parse()?,
                 None => bail!("no rust-version field in Cargo.toml is specified"),
-            }
+            };
+            rust_version = Some(version);
+            Ok(version)
         }
     };
-    check(&start_inclusive)?;
+
+    let VersionRange { start_inclusive, end_inclusive } = range;
+
+    let start_inclusive = match start_inclusive {
+        MaybeVersion::Version(start) => {
+            check(&start)?;
+            start
+        }
+        MaybeVersion::Msrv => {
+            let start = get_rust_version()?;
+            check(&start)?;
+            start
+        }
+        MaybeVersion::Stable => get_stable_version()?,
+    };
 
     let end_inclusive = match end_inclusive {
-        Some(end) => {
+        MaybeVersion::Version(end) => {
             check(&end)?;
             end
         }
-        None => {
-            install_toolchain("stable", &[], false)?;
-            cargo::version(cmd!("cargo", "+stable"))?
-        }
+        MaybeVersion::Msrv => get_rust_version()?,
+        MaybeVersion::Stable => get_stable_version()?,
     };
 
     let step = step.map(str::parse::<u8>).transpose()?.unwrap_or(1);
diff --git a/src/version.rs b/src/version.rs
index 27e48a84..6593febf 100644
--- a/src/version.rs
+++ b/src/version.rs
@@ -33,19 +33,32 @@ impl FromStr for Version {
     }
 }
 
+#[derive(Copy, Clone)]
+pub(crate) enum MaybeVersion {
+    Version(Version),
+    Msrv,
+    Stable,
+}
+
 #[derive(Copy, Clone)]
 pub(crate) struct VersionRange {
-    pub(crate) start_inclusive: Option<Version>,
-    pub(crate) end_inclusive: Option<Version>,
+    pub(crate) start_inclusive: MaybeVersion,
+    pub(crate) end_inclusive: MaybeVersion,
+}
+
+impl VersionRange {
+    pub(crate) fn msrv() -> Self {
+        Self { start_inclusive: MaybeVersion::Msrv, end_inclusive: MaybeVersion::Msrv }
+    }
 }
 
 impl fmt::Display for VersionRange {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        if let Some(start) = self.start_inclusive {
+        if let MaybeVersion::Version(start) = self.start_inclusive {
             write!(f, "{start}")?;
         }
         write!(f, "..=")?;
-        if let Some(end) = self.end_inclusive {
+        if let MaybeVersion::Version(end) = self.end_inclusive {
             write!(f, "{end}")?;
         }
         Ok(())
@@ -56,7 +69,7 @@ impl FromStr for VersionRange {
     type Err = Error;
 
     fn from_str(s: &str) -> Result<Self, Self::Err> {
-        let (start, end_inclusive) = if let Some((start, end)) = s.split_once("..") {
+        let (start, end) = if let Some((start, end)) = s.split_once("..") {
             let end = match end.strip_prefix('=') {
                 Some(end) => end,
                 None => {
@@ -71,15 +84,16 @@ impl FromStr for VersionRange {
         } else {
             (s, None)
         };
-        let start_inclusive = maybe_version(start)?;
+        let start_inclusive = maybe_version(start)?.unwrap_or(MaybeVersion::Msrv);
+        let end_inclusive = end.unwrap_or(MaybeVersion::Stable);
         Ok(Self { start_inclusive, end_inclusive })
     }
 }
 
-fn maybe_version(s: &str) -> Result<Option<Version>, Error> {
+fn maybe_version(s: &str) -> Result<Option<MaybeVersion>, Error> {
     if s.is_empty() {
         Ok(None)
     } else {
-        s.parse().map(Some)
+        s.parse().map(MaybeVersion::Version).map(Some)
     }
 }


Test patch:
diff --git a/tests/auxiliary/mod.rs b/tests/auxiliary/mod.rs
index 14ef8373..5e222051 100644
--- a/tests/auxiliary/mod.rs
+++ b/tests/auxiliary/mod.rs
@@ -61,7 +61,10 @@ pub fn cargo_hack<O: AsRef<OsStr>>(args: impl AsRef<[O]>) -> Command {
     let mut cmd = cargo_bin_exe();
     cmd.arg("hack");
     if let Some(toolchain) = test_version() {
-        if !args.iter().any(|a| a.as_ref().to_str().unwrap().starts_with("--version-range")) {
+        if !args.iter().any(|a| {
+            let s = a.as_ref().to_str().unwrap();
+            s.starts_with("--version-range") || s.starts_with("--rust-version")
+        }) {
             cmd.arg(format!("--version-range=1.{toolchain}..=1.{toolchain}"));
         }
     }
diff --git a/tests/long-help.txt b/tests/long-help.txt
index 1367690b..f1ef3fdf 100644
--- a/tests/long-help.txt
+++ b/tests/long-help.txt
@@ -134,6 +134,11 @@ OPTIONS:
 
             This flag can only be used together with either --features or --include-features.
 
+        --rust-version
+            Perform commands on `package.rust-version`.
+
+            This cannot be used with --version-range.
+
         --version-range [START]..[=END]
             Perform commands on a specified (inclusive) range of Rust versions.
 
diff --git a/tests/short-help.txt b/tests/short-help.txt
index d8279321..2296dd11 100644
--- a/tests/short-help.txt
+++ b/tests/short-help.txt
@@ -34,6 +34,7 @@ OPTIONS:
         --ignore-private                 Skip to perform on `publish = false` packages
         --ignore-unknown-features        Skip passing --features flag to `cargo` if that feature
                                          does not exist in the package
+        --rust-version                   Perform commands on `package.rust-version`
         --version-range [START]..[=END]  Perform commands on a specified (inclusive) range of Rust
                                          versions
         --version-step <NUM>             Specify the version interval of --version-range (default
diff --git a/tests/test.rs b/tests/test.rs
index 25bc9616..77674845 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -1353,6 +1353,16 @@ fn version_range() {
         );
 }
 
+#[cfg_attr(windows, ignore)] // rustup bug: https://github.com/rust-lang/rustup/issues/3036
+#[test]
+fn rust_version() {
+    cargo_hack(["check", "--rust-version"]).assert_success("rust-version").stderr_contains(
+        "
+            running `cargo +1.64 check` on real (1/1)
+            ",
+    );
+}
+
 #[cfg_attr(windows, ignore)] // rustup bug: https://github.com/rust-lang/rustup/issues/3036
 #[test]
 fn multi_target() {