Skip to content

Added build.build_dir templating support #15236

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/cargo/core/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,6 @@ impl<'gctx> Workspace<'gctx> {
pub fn new(manifest_path: &Path, gctx: &'gctx GlobalContext) -> CargoResult<Workspace<'gctx>> {
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!(
Expand All @@ -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);
Expand Down
37 changes: 35 additions & 2 deletions src/cargo/util/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Filesystem>> {
pub fn build_dir(&self, workspace_manifest_path: &PathBuf) -> CargoResult<Option<Filesystem>> {
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() {
Expand Down
37 changes: 37 additions & 0 deletions src/cargo/util/context/path.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::{GlobalContext, StringList, Value};
use regex::Regex;
use serde::{de::Error, Deserialize};
use std::path::PathBuf;

Expand Down Expand Up @@ -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<Item = (impl AsRef<str>, impl AsRef<str>)>,
) -> Result<PathBuf, ResolveTemplateError> {
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`.
///
Expand Down Expand Up @@ -103,3 +132,11 @@ impl PathAndArgs {
}
}
}

#[derive(Debug)]
pub enum ResolveTemplateError {
UnexpectedVariable {
variable: String,
raw_template: String,
},
}
7 changes: 7 additions & 0 deletions src/doc/src/reference/unstable.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
166 changes: 165 additions & 1 deletion tests/testsuite/build_dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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);
Expand Down