diff --git a/src/cargo/core/workspace.rs b/src/cargo/core/workspace.rs index 455b3df36c8..50b1de570e8 100644 --- a/src/cargo/core/workspace.rs +++ b/src/cargo/core/workspace.rs @@ -213,7 +213,6 @@ impl<'gctx> Workspace<'gctx> { pub fn new(manifest_path: &Path, gctx: &'gctx GlobalContext) -> CargoResult> { let mut ws = Workspace::new_default(manifest_path.to_path_buf(), gctx); ws.target_dir = gctx.target_dir()?; - ws.build_dir = gctx.build_dir()?; if manifest_path.is_relative() { bail!( @@ -224,6 +223,12 @@ impl<'gctx> Workspace<'gctx> { ws.root_manifest = ws.find_root(manifest_path)?; } + ws.build_dir = gctx.build_dir( + ws.root_manifest + .as_ref() + .unwrap_or(&manifest_path.to_path_buf()), + )?; + ws.custom_metadata = ws .load_workspace_config()? .and_then(|cfg| cfg.custom_metadata); diff --git a/src/cargo/util/context/mod.rs b/src/cargo/util/context/mod.rs index b148d0d6ef4..46ca8323a75 100644 --- a/src/cargo/util/context/mod.rs +++ b/src/cargo/util/context/mod.rs @@ -652,12 +652,45 @@ impl GlobalContext { /// Falls back to the target directory if not specified. /// /// Callers should prefer [`Workspace::build_dir`] instead. - pub fn build_dir(&self) -> CargoResult> { + pub fn build_dir(&self, workspace_manifest_path: &PathBuf) -> CargoResult> { if !self.cli_unstable().build_dir { return self.target_dir(); } if let Some(val) = &self.build_config()?.build_dir { - let path = val.resolve_path(self); + let replacements = vec![ + ( + "{workspace-root}", + workspace_manifest_path + .parent() + .unwrap() + .to_str() + .context("workspace root was not valid utf-8")? + .to_string(), + ), + ( + "{cargo-cache-home}", + self.home() + .as_path_unlocked() + .to_str() + .context("cargo home was not valid utf-8")? + .to_string(), + ), + ("{workspace-manifest-path-hash}", { + let hash = crate::util::hex::short_hash(&workspace_manifest_path); + format!("{}{}{}", &hash[0..2], std::path::MAIN_SEPARATOR, &hash[2..]) + }), + ]; + + let path = val + .resolve_templated_path(self, replacements) + .map_err(|e| match e { + path::ResolveTemplateError::UnexpectedVariable { + variable, + raw_template, + } => anyhow!( + "unexpected variable `{variable}` in build.build-dir path `{raw_template}`" + ), + })?; // Check if the target directory is set to an empty string in the config.toml file. if val.raw_value().is_empty() { diff --git a/src/cargo/util/context/path.rs b/src/cargo/util/context/path.rs index 3a6c9e4a397..4fd72cebb7e 100644 --- a/src/cargo/util/context/path.rs +++ b/src/cargo/util/context/path.rs @@ -1,4 +1,5 @@ use super::{GlobalContext, StringList, Value}; +use regex::Regex; use serde::{de::Error, Deserialize}; use std::path::PathBuf; @@ -32,6 +33,34 @@ impl ConfigRelativePath { self.0.definition.root(gctx).join(&self.0.val) } + /// Same as [`Self::resolve_path`] but will make string replacements + /// before resolving the path. + /// + /// `replacements` should be an an [`IntoIterator`] of tuples with the "from" and "to" for the + /// string replacement + pub fn resolve_templated_path( + &self, + gctx: &GlobalContext, + replacements: impl IntoIterator, impl AsRef)>, + ) -> Result { + let mut value = self.0.val.clone(); + + for (from, to) in replacements { + value = value.replace(from.as_ref(), to.as_ref()); + } + + // Check for expected variables + let re = Regex::new(r"\{(.*)\}").unwrap(); + if let Some(caps) = re.captures(&value) { + return Err(ResolveTemplateError::UnexpectedVariable { + variable: caps[1].to_string(), + raw_template: self.0.val.clone(), + }); + }; + + Ok(self.0.definition.root(gctx).join(&value)) + } + /// Resolves this configuration-relative path to either an absolute path or /// something appropriate to execute from `PATH`. /// @@ -103,3 +132,11 @@ impl PathAndArgs { } } } + +#[derive(Debug)] +pub enum ResolveTemplateError { + UnexpectedVariable { + variable: String, + raw_template: String, + }, +} diff --git a/src/doc/src/reference/unstable.md b/src/doc/src/reference/unstable.md index e7fab986de8..eef1c28ecee 100644 --- a/src/doc/src/reference/unstable.md +++ b/src/doc/src/reference/unstable.md @@ -260,6 +260,13 @@ build-dir = "out" The path to where internal files used as part of the build are placed. +This option supports path templating. + +Avaiable template variables: +* `{workspace-root}` resolves to root of the current workspace. +* `{cargo-cache-home}` resolves to `CARGO_HOME` +* `{workspace-manifest-path-hash}` resolves to a hash of the manifest path + ## root-dir * Original Issue: [#9887](https://github.com/rust-lang/cargo/issues/9887) diff --git a/tests/testsuite/build_dir.rs b/tests/testsuite/build_dir.rs index bc9c27d0ffa..4b464cb3815 100644 --- a/tests/testsuite/build_dir.rs +++ b/tests/testsuite/build_dir.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; use cargo_test_support::prelude::*; -use cargo_test_support::project; +use cargo_test_support::{paths, project, str}; use std::env::consts::{DLL_PREFIX, DLL_SUFFIX, EXE_SUFFIX}; #[cargo_test] @@ -491,6 +491,170 @@ fn future_incompat_should_output_to_build_dir() { assert_exists(&p.root().join("build-dir/.future-incompat-report.json")); } +#[cargo_test] +fn template_should_error_for_invalid_variables() { + let p = project() + .file("src/main.rs", r#"fn main() { println!("Hello, World!") }"#) + .file( + ".cargo/config.toml", + r#" + [build] + build-dir = "{fake}/build-dir" + target-dir = "target-dir" + "#, + ) + .build(); + + p.cargo("build -Z build-dir") + .masquerade_as_nightly_cargo(&["build-dir"]) + .enable_mac_dsym() + .with_status(101) + .with_stderr_data(str![[r#" +[ERROR] unexpected variable `fake` in build.build-dir path `{fake}/build-dir` + +"#]]) + .run(); +} + +#[cargo_test] +fn template_workspace_root() { + let p = project() + .file("src/main.rs", r#"fn main() { println!("Hello, World!") }"#) + .file( + ".cargo/config.toml", + r#" + [build] + build-dir = "{workspace-root}/build-dir" + target-dir = "target-dir" + "#, + ) + .build(); + + p.cargo("build -Z build-dir") + .masquerade_as_nightly_cargo(&["build-dir"]) + .enable_mac_dsym() + .run(); + + assert_build_dir_layout(p.root().join("build-dir"), "debug"); + assert_artifact_dir_layout(p.root().join("target-dir"), "debug"); + + // Verify the binary was uplifted to the target-dir + assert_exists(&p.root().join(&format!("target-dir/debug/foo{EXE_SUFFIX}"))); +} + +#[cargo_test] +fn template_cargo_cache_home() { + let p = project() + .file("src/main.rs", r#"fn main() { println!("Hello, World!") }"#) + .file( + ".cargo/config.toml", + r#" + [build] + build-dir = "{cargo-cache-home}/build-dir" + target-dir = "target-dir" + "#, + ) + .build(); + + p.cargo("build -Z build-dir") + .masquerade_as_nightly_cargo(&["build-dir"]) + .enable_mac_dsym() + .run(); + + assert_build_dir_layout(paths::home().join(".cargo/build-dir"), "debug"); + assert_artifact_dir_layout(p.root().join("target-dir"), "debug"); + + // Verify the binary was uplifted to the target-dir + assert_exists(&p.root().join(&format!("target-dir/debug/foo{EXE_SUFFIX}"))); +} + +#[cargo_test] +fn template_workspace_manfiest_path_hash() { + let p = project() + .file("src/main.rs", r#"fn main() { println!("Hello, World!") }"#) + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "1.0.0" + authors = [] + edition = "2015" + "#, + ) + .file( + ".cargo/config.toml", + r#" + [build] + build-dir = "foo/{workspace-manifest-path-hash}/build-dir" + target-dir = "target-dir" + "#, + ) + .build(); + + p.cargo("build -Z build-dir") + .masquerade_as_nightly_cargo(&["build-dir"]) + .enable_mac_dsym() + .run(); + + let foo_dir = p.root().join("foo"); + assert_exists(&foo_dir); + let hash_dir = parse_workspace_manifest_path_hash(&foo_dir); + + let build_dir = hash_dir.as_path().join("build-dir"); + assert_build_dir_layout(build_dir, "debug"); + assert_artifact_dir_layout(p.root().join("target-dir"), "debug"); + + // Verify the binary was uplifted to the target-dir + assert_exists(&p.root().join(&format!("target-dir/debug/foo{EXE_SUFFIX}"))); +} + +fn parse_workspace_manifest_path_hash(hash_dir: &PathBuf) -> PathBuf { + // Since the hash will change between test runs simply find the first directories and assume + // that is the hash dir. The format is a 2 char directory followed by the remaining hash in the + // inner directory (ie. `34/f9d02eb8411c05`) + let mut dirs = std::fs::read_dir(hash_dir).unwrap().into_iter(); + let outer_hash_dir = dirs.next().unwrap().unwrap(); + // Validate there are no other directories in `hash_dir` + assert!( + dirs.next().is_none(), + "Found multiple dir entries in {hash_dir:?}" + ); + // Validate the outer hash dir hash is a directory and has the correct hash length + assert!( + outer_hash_dir.path().is_dir(), + "{outer_hash_dir:?} was not a directory" + ); + assert_eq!( + outer_hash_dir.path().file_name().unwrap().len(), + 2, + "Path {:?} should have been 2 chars", + outer_hash_dir.path().file_name() + ); + + let mut dirs = std::fs::read_dir(outer_hash_dir.path()) + .unwrap() + .into_iter(); + let inner_hash_dir = dirs.next().unwrap().unwrap(); + // Validate there are no other directories in first hash dir + assert!( + dirs.next().is_none(), + "Found multiple dir entries in {outer_hash_dir:?}" + ); + // Validate the outer hash dir hash is a directory and has the correct hash length + assert!( + inner_hash_dir.path().is_dir(), + "{inner_hash_dir:?} was not a directory" + ); + assert_eq!( + inner_hash_dir.path().file_name().unwrap().len(), + 14, + "Path {:?} should have been 2 chars", + inner_hash_dir.path().file_name() + ); + return inner_hash_dir.path(); +} + #[track_caller] fn assert_build_dir_layout(path: PathBuf, profile: &str) { assert_dir_layout(path, profile, true);