Repository: railwayapp/nixpacks (rust)

Issue:
Support Rust workspace Cargo.toml
Tried this under Railway, but when your `Cargo.toml` is a workspace one, it doesn't work. It however should be allowed. Building a workspace will build all members, and while that may be not ideal, starting a binary will still work with it since it'll get generated. A person can also provide a custom build command targeting only one package when that's undesired behavior,  
`cargo build --release -p package-name`

```
Error: Generating start phase
 
Caused by:
 
0: Reading Cargo.toml
1: missing field `package` at line 1 column 1
```

Pull request description:
Support cargo workspaces
## What does this PR address?
<!-- Thanks for sending a pull request! -->

Adds support for Cargo [Workspaces](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html)

<!-- Remove if not applicable -->
Fixes #256 

## Before submitting:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask -->
<!--- If you plan to update documentation or tests in follow-up, please note -->

- [x] Make sure to follow [GitHub's guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) on creating PR.
- [x] Did you read through [contribution guidelines](https://github.com/railwayapp/nixpacks/blob/main/CONTRIBUTING.md)?
- [x] Did your changes require updates to the documentation? Have you updated those accordingly?
- [x] Did you write tests to cover your changes? Did it pass locally when running `cargo test`?
- [x] Have you run `cargo fmt`, `cargo check` and `cargo clippy` locally to ensure that CI passes?


Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 038be79..245e953 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,16 +1,8 @@
-## What does this PR address?
 <!-- Thanks for sending a pull request! -->
+<!-- Please add a useful description explaining what this PR does -->
 
-<!-- Remove if not applicable -->
-Fixes # (issue)
+This PR...
 
-## Before submitting:
-<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
-<!--- If you're unsure about any of these, don't hesitate to ask -->
-<!--- If you plan to update documentation or tests in follow-up, please note -->
-
-- [ ] Make sure to follow [GitHub's guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) on creating PR.
-- [ ] Did you read through [contribution guidelines](https://github.com/railwayapp/nixpacks/blob/main/CONTRIBUTING.md)?
-- [ ] Did your changes require updates to the documentation? Have you updated those accordingly?
-- [ ] Did you write tests to cover your changes? Did it pass locally when running `cargo test`?
-- [ ] Have you run `cargo fmt`, `cargo check` and `cargo clippy` locally to ensure that CI passes?
+<!-- PR Checklist  -->
+<!-- - [ ] Tests are added/updated if needed  -->
+<!-- - [ ] Docs are updated if needed  -->
diff --git a/docs/pages/docs/providers/rust.md b/docs/pages/docs/providers/rust.md
index 53a2d2a..464e717 100644
--- a/docs/pages/docs/providers/rust.md
+++ b/docs/pages/docs/providers/rust.md
@@ -46,3 +46,12 @@ These directories are cached between builds
 - Build: `~/.cargo/git`
 - Build: `~/.cargo/registry`
 - Build: `target`
+
+## [Workspaces](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html)
+
+Nixpacks will auto-detect if you are using Cargo Workspaces.
+This checks `workspace.default_members` first and then `workspace.members`.
+It also respects the `workspace.exclude` field.
+
+To set which workspace Nixpacks will build, just set the `NIXPACKS_CARGO_WORKSPACE`
+environment variable and Nixpacks will use it as the `--package` argument.
diff --git a/examples/rust-cargo-workspaces-glob/.gitignore b/examples/rust-cargo-workspaces-glob/.gitignore
new file mode 100644
index 0000000..8c88100
--- /dev/null
+++ b/examples/rust-cargo-workspaces-glob/.gitignore
@@ -0,0 +1,13 @@
+# Created by https://www.toptal.com/developers/gitignore/api/rust
+# Edit at https://www.toptal.com/developers/gitignore?templates=rust
+
+### Rust ###
+# Generated by Cargo
+# will have compiled files and executables
+/target/
+
+# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
+# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+Cargo.lock
+
+# End of https://www.toptal.com/developers/gitignore/api/rust
diff --git a/examples/rust-cargo-workspaces-glob/Cargo.toml b/examples/rust-cargo-workspaces-glob/Cargo.toml
new file mode 100644
index 0000000..1b57bcd
--- /dev/null
+++ b/examples/rust-cargo-workspaces-glob/Cargo.toml
@@ -0,0 +1,4 @@
+[workspace]
+members = [
+    "example/*"
+]
diff --git a/examples/rust-cargo-workspaces-glob/example/binary/Cargo.toml b/examples/rust-cargo-workspaces-glob/example/binary/Cargo.toml
new file mode 100644
index 0000000..e02ca7e
--- /dev/null
+++ b/examples/rust-cargo-workspaces-glob/example/binary/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "binary"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies.library]
+path = "../library"
diff --git a/examples/rust-cargo-workspaces-glob/example/binary/src/main.rs b/examples/rust-cargo-workspaces-glob/example/binary/src/main.rs
new file mode 100644
index 0000000..13d5eb3
--- /dev/null
+++ b/examples/rust-cargo-workspaces-glob/example/binary/src/main.rs
@@ -0,0 +1,3 @@
+fn main() {
+    println!("{}", library::MESSAGE);
+}
diff --git a/examples/rust-cargo-workspaces-glob/example/library/Cargo.toml b/examples/rust-cargo-workspaces-glob/example/library/Cargo.toml
new file mode 100644
index 0000000..0d4367c
--- /dev/null
+++ b/examples/rust-cargo-workspaces-glob/example/library/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "library"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
diff --git a/examples/rust-cargo-workspaces-glob/example/library/src/lib.rs b/examples/rust-cargo-workspaces-glob/example/library/src/lib.rs
new file mode 100644
index 0000000..f571142
--- /dev/null
+++ b/examples/rust-cargo-workspaces-glob/example/library/src/lib.rs
@@ -0,0 +1,1 @@
+pub const MESSAGE: &str = "Hello from rust";
diff --git a/examples/rust-cargo-workspaces/.gitignore b/examples/rust-cargo-workspaces/.gitignore
new file mode 100644
index 0000000..8c88100
--- /dev/null
+++ b/examples/rust-cargo-workspaces/.gitignore
@@ -0,0 +1,13 @@
+# Created by https://www.toptal.com/developers/gitignore/api/rust
+# Edit at https://www.toptal.com/developers/gitignore?templates=rust
+
+### Rust ###
+# Generated by Cargo
+# will have compiled files and executables
+/target/
+
+# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
+# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+Cargo.lock
+
+# End of https://www.toptal.com/developers/gitignore/api/rust
diff --git a/examples/rust-cargo-workspaces/Cargo.toml b/examples/rust-cargo-workspaces/Cargo.toml
new file mode 100644
index 0000000..3d93627
--- /dev/null
+++ b/examples/rust-cargo-workspaces/Cargo.toml
@@ -0,0 +1,5 @@
+[workspace]
+members = [
+    "binary",
+    "library"
+]
diff --git a/examples/rust-cargo-workspaces/binary/Cargo.toml b/examples/rust-cargo-workspaces/binary/Cargo.toml
new file mode 100644
index 0000000..e02ca7e
--- /dev/null
+++ b/examples/rust-cargo-workspaces/binary/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "binary"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies.library]
+path = "../library"
diff --git a/examples/rust-cargo-workspaces/binary/src/main.rs b/examples/rust-cargo-workspaces/binary/src/main.rs
new file mode 100644
index 0000000..13d5eb3
--- /dev/null
+++ b/examples/rust-cargo-workspaces/binary/src/main.rs
@@ -0,0 +1,3 @@
+fn main() {
+    println!("{}", library::MESSAGE);
+}
diff --git a/examples/rust-cargo-workspaces/library/Cargo.toml b/examples/rust-cargo-workspaces/library/Cargo.toml
new file mode 100644
index 0000000..0d4367c
--- /dev/null
+++ b/examples/rust-cargo-workspaces/library/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "library"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
diff --git a/examples/rust-cargo-workspaces/library/src/lib.rs b/examples/rust-cargo-workspaces/library/src/lib.rs
new file mode 100644
index 0000000..f571142
--- /dev/null
+++ b/examples/rust-cargo-workspaces/library/src/lib.rs
@@ -0,0 +1,1 @@
+pub const MESSAGE: &str = "Hello from rust";
diff --git a/src/providers/rust.rs b/src/providers/rust.rs
index 4255af8..ee11347 100644
--- a/src/providers/rust.rs
+++ b/src/providers/rust.rs
@@ -1,4 +1,5 @@
 use std::env::consts::ARCH;
+use std::fmt::Write as _;
 
 use super::Provider;
 use crate::nixpacks::{
@@ -8,7 +9,7 @@ use crate::nixpacks::{
     phase::{BuildPhase, SetupPhase, StartPhase},
 };
 use anyhow::{Context, Result};
-use cargo_toml::Manifest;
+use cargo_toml::{Manifest, Workspace};
 
 static RUST_OVERLAY: &str = "https://github.com/oxalica/rust-overlay/archive/master.tar.gz";
 static DEFAULT_RUST_PACKAGE: &str = "rust-bin.stable.latest.default";
@@ -58,33 +59,7 @@ impl Provider for RustProvider {
     }
 
     fn build(&self, app: &App, env: &Environment) -> Result<Option<BuildPhase>> {
-        let mut build_phase = match RustProvider::get_target(app, env)? {
-            Some(target) => {
-                let mut build_phase =
-                    BuildPhase::new(format!("cargo build --release --target {}", target));
-
-                if let Some(name) = RustProvider::get_app_name(app)? {
-                    // Copy the binary out of the target directory
-                    build_phase.add_cmd(format!(
-                        "cp target/{}/release/{name} {name}",
-                        target,
-                        name = name
-                    ));
-                }
-
-                build_phase
-            }
-            None => {
-                let mut build_phase = BuildPhase::new("cargo build --release".to_string());
-
-                if let Some(name) = RustProvider::get_app_name(app)? {
-                    // Copy the binary out of the target directory
-                    build_phase.add_cmd(format!("cp target/release/{name} {name}", name = name));
-                }
-
-                build_phase
-            }
-        };
+        let mut build_phase = RustProvider::get_build_phase(app, env)?;
 
         build_phase.add_cache_directory((*CARGO_GIT_CACHE_DIR).to_string());
         build_phase.add_cache_directory((*CARGO_REGISTRY_CACHE_DIR).to_string());
@@ -98,23 +73,28 @@ impl Provider for RustProvider {
     }
 
     fn start(&self, app: &App, env: &Environment) -> Result<Option<StartPhase>> {
-        let name = RustProvider::get_app_name(app)?;
+        if (RustProvider::get_target(app, env)?).is_some() {
+            if let Some(workspace) = RustProvider::resolve_cargo_workspace(app, env)? {
+                let mut start_phase = StartPhase::new(format!("./{}", workspace));
 
-        if let Some(name) = name {
-            let start_phase = match RustProvider::get_target(app, env)? {
-                Some(_) => {
-                    let binary_file = format!("./{}", name);
-                    let mut start_phase = StartPhase::new(format!("./{}", name));
+                start_phase.run_in_slim_image();
+                start_phase.add_file_dependency(format!("./{}", workspace));
 
-                    start_phase.run_in_slim_image();
-                    start_phase.add_file_dependency(binary_file);
+                Ok(Some(start_phase))
+            } else if let Some(name) = RustProvider::get_app_name(app)? {
+                let mut start_phase = StartPhase::new(format!("./{}", name));
 
-                    start_phase
-                }
-                None => StartPhase::new(format!("./{}", name)),
-            };
+                start_phase.run_in_slim_image();
+                start_phase.add_file_dependency(format!("./{}", name));
 
-            Ok(Some(start_phase))
+                Ok(Some(start_phase))
+            } else {
+                Ok(None)
+            }
+        } else if let Some(workspace) = RustProvider::resolve_cargo_workspace(app, env)? {
+            Ok(Some(StartPhase::new(format!("./{}", workspace))))
+        } else if let Some(name) = RustProvider::get_app_name(app)? {
+            Ok(Some(StartPhase::new(format!("./{}", name))))
         } else {
             Ok(None)
         }
@@ -154,6 +134,7 @@ impl RustProvider {
     fn parse_cargo_toml(app: &App) -> Result<Option<Manifest>> {
         if app.includes_file("Cargo.toml") {
             let cargo_toml: Manifest = app.read_toml("Cargo.toml").context("Reading Cargo.toml")?;
+
             return Ok(Some(cargo_toml));
         }
 
@@ -239,6 +220,124 @@ impl RustProvider {
 
         Ok(false)
     }
+
+    fn resolve_cargo_workspace(app: &App, env: &Environment) -> Result<Option<String>> {
+        if let Some(name) = env.get_config_variable("CARGO_WORKSPACE") {
+            return Ok(Some(name));
+        }
+
+        let manifest = RustProvider::parse_cargo_toml(app)?.context("Missing Cargo.toml")?;
+
+        if let Some(workspace) = manifest.workspace {
+            if let Some(binary) = RustProvider::find_binary_in_workspace(app, &workspace)? {
+                return Ok(Some(binary));
+            }
+        }
+
+        Ok(None)
+    }
+
+    fn find_binary_in_workspace(app: &App, workspace: &Workspace) -> Result<Option<String>> {
+        let find_binary = |member: &str| -> Result<Option<String>> {
+            let mut manifest = app.read_toml::<Manifest>(&format!("{}/Cargo.toml", member))?;
+
+            manifest.complete_from_path(&app.source.join(format!("{}/Cargo.toml", member)))?;
+
+            if let Some(package) = manifest.package {
+                if !manifest.bin.is_empty() || manifest.lib.is_none() {
+                    return Ok(Some(package.name));
+                }
+            }
+
+            Ok(None)
+        };
+
+        for default_member in workspace
+            .default_members
+            .iter()
+            .filter(|default_member| !workspace.exclude.contains(default_member))
+        {
+            // a member can have globs
+            if default_member.contains('*') || default_member.contains('?') {
+                for member in app.find_directories(default_member)? {
+                    if let Some(bin) = find_binary(&member.to_string_lossy())? {
+                        return Ok(Some(bin));
+                    }
+                }
+            } else if let Some(bin) = find_binary(default_member)? {
+                return Ok(Some(bin));
+            }
+        }
+
+        for member in workspace
+            .members
+            .iter()
+            .filter(|member| !workspace.exclude.contains(member))
+        {
+            // a member can have globs
+            if member.contains('*') || member.contains('?') {
+                for member in app.find_directories(member)? {
+                    if let Some(bin) = find_binary(&member.to_string_lossy())? {
+                        return Ok(Some(bin));
+                    }
+                }
+            } else if let Some(bin) = find_binary(member)? {
+                return Ok(Some(bin));
+            }
+        }
+
+        Ok(None)
+    }
+
+    fn get_build_phase(app: &App, env: &Environment) -> Result<BuildPhase> {
+        let mut build_cmd = "cargo build --release".to_string();
+
+        if let Some(target) = RustProvider::get_target(app, env)? {
+            if let Some(workspace) = RustProvider::resolve_cargo_workspace(app, env)? {
+                write!(build_cmd, " --package {} --target {}", workspace, target)?;
+
+                let mut build_phase = BuildPhase::new(build_cmd);
+
+                build_phase.add_cmd(format!(
+                    "cp target/{}/release/{name} {name}",
+                    target,
+                    name = workspace
+                ));
+
+                Ok(build_phase)
+            } else {
+                write!(build_cmd, " --target {}", target)?;
+
+                let mut build_phase = BuildPhase::new(build_cmd);
+
+                if let Some(name) = RustProvider::get_app_name(app)? {
+                    build_phase.add_cmd(format!(
+                        "cp target/{}/release/{name} {name}",
+                        target,
+                        name = name
+                    ));
+                }
+
+                Ok(build_phase)
+            }
+        } else if let Some(workspace) = RustProvider::resolve_cargo_workspace(app, env)? {
+            write!(build_cmd, " --package {}", workspace)?;
+
+            let mut build_phase = BuildPhase::new(build_cmd);
+
+            build_phase.add_cmd(format!("cp target/release/{name} {name}", name = workspace));
+
+            Ok(build_phase)
+        } else {
+            let mut build_phase = BuildPhase::new(build_cmd);
+
+            if let Some(name) = RustProvider::get_app_name(app)? {
+                build_phase.add_cmd(format!("cp target/release/{name} {name}", name = name));
+            }
+
+            Ok(build_phase)
+        }
+    }
 }
 
 #[cfg(test)]


Test patch:
diff --git a/test-helper/src/lib.rs b/test-helper/src/lib.rs
index 33d1a51..d0f0955 100644
--- a/test-helper/src/lib.rs
+++ b/test-helper/src/lib.rs
@@ -10,6 +10,8 @@ const IGNORE: &[&str] = &[
     "rust-ring",
     "rust-openssl",
     "rust-custom-toolchain",
+    "rust-cargo-workspaces",
+    "rust-cargo-workspaces-glob",
     "ruby-no-version",
 ];
 
diff --git a/tests/docker_run_tests.rs b/tests/docker_run_tests.rs
index 8a11534..1ee40e7 100644
--- a/tests/docker_run_tests.rs
+++ b/tests/docker_run_tests.rs
@@ -476,6 +476,20 @@ fn test_rust_openssl() {
     assert!(output.contains("Hello from Rust openssl!"));
 }
 
+#[test]
+fn test_rust_cargo_workspaces() {
+    let name = simple_build("./examples/rust-cargo-workspaces");
+    let output = run_image(name, None);
+    assert!(output.contains("Hello from rust"));
+}
+
+#[test]
+fn test_rust_cargo_workspaces_glob() {
+    let name = simple_build("./examples/rust-cargo-workspaces-glob");
+    let output = run_image(name, None);
+    assert!(output.contains("Hello from rust"));
+}
+
 #[test]
 fn test_go() {
     let name = simple_build("./examples/go");
diff --git a/tests/generate_plan_tests.rs b/tests/generate_plan_tests.rs
index e926bea..5627632 100644
--- a/tests/generate_plan_tests.rs
+++ b/tests/generate_plan_tests.rs
@@ -59,6 +59,19 @@ fn test_rust_rocket_no_musl() {
     assert_plan_snapshot!(plan);
 }
 
+#[test]
+fn test_rust_cargo_workspaces() {
+    let plan = simple_gen_plan("./examples/rust-cargo-workspaces");
+
+    assert_eq!(
+        plan.build.unwrap().cmds.unwrap()[0],
+        format!(
+            "cargo build --release --package binary --target {}-unknown-linux-musl",
+            ARCH
+        )
+    );
+}
+
 #[test]
 fn test_haskell_stack() {
     let plan = simple_gen_plan("./examples/haskell-stack");