1#![deny(missing_docs)]
6
7use mbx_cache_core::{CacheDigest, canonical_json};
8use serde::Serialize;
9use std::ffi::{OsStr, OsString};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13const CARGO_TARGET_DIR_ENV: &str = "CARGO_TARGET_DIR";
14const PROBE_GLOBAL_FLAGS: [&str; 3] = ["-C", "--config", "-Z"];
15const PROBE_MANIFEST_TOGGLES: [&str; 3] = ["--offline", "--frozen", "--locked"];
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CargoInvocation {
20 pub workspace_root: PathBuf,
22 pub target_dir: PathBuf,
24 pub target_dir_requested: bool,
26 pub build_identity: String,
28}
29
30pub fn resolve(
32 cargo: &OsStr,
33 arguments: &[String],
34 working_dir: &Path,
35 target_dir_env: Option<OsString>,
36) -> CargoInvocation {
37 let cargo_args = cargo_arguments(arguments);
38 let reported = cargo_roots(cargo, cargo_args, target_dir_env.as_deref());
39 resolve_with_reported(arguments, working_dir, target_dir_env, reported)
40}
41
42pub fn resolve_reported(
48 cargo: &OsStr,
49 arguments: &[String],
50 working_dir: &Path,
51 target_dir_env: Option<OsString>,
52) -> Option<CargoInvocation> {
53 let reported = cargo_roots(cargo, cargo_arguments(arguments), target_dir_env.as_deref())?;
54 Some(resolve_with_reported(
55 arguments,
56 working_dir,
57 target_dir_env,
58 Some(reported),
59 ))
60}
61
62fn resolve_with_reported(
63 arguments: &[String],
64 working_dir: &Path,
65 target_dir_env: Option<OsString>,
66 reported: Option<(PathBuf, PathBuf)>,
67) -> CargoInvocation {
68 let cargo_args = cargo_arguments(arguments);
69 let invocation_dir = invocation_dir(cargo_args, working_dir);
70 let workspace_root = reported
71 .as_ref()
72 .map(|roots| roots.0.clone())
73 .unwrap_or_else(|| workspace_root(&invocation_dir));
74 let flagged = target_dir_argument(cargo_args);
75 let target_dir_requested = flagged.is_some()
76 || target_dir_env
77 .as_ref()
78 .is_some_and(|value| !value.is_empty())
79 || cargo_config_may_set_target_dir(cargo_args, &invocation_dir);
80 let target_dir = flagged
81 .map(|value| absolute(&invocation_dir, value))
82 .or_else(|| reported.map(|roots| roots.1))
83 .or_else(|| {
84 target_dir_env
85 .filter(|value| !value.is_empty())
86 .map(PathBuf::from)
87 .map(|value| absolute(&invocation_dir, &value.to_string_lossy()))
88 })
89 .unwrap_or_else(|| workspace_root.join("target"));
90 let build_identity = build_identity(&workspace_root, arguments);
91 CargoInvocation {
92 workspace_root,
93 target_dir,
94 target_dir_requested,
95 build_identity,
96 }
97}
98
99#[derive(Serialize)]
100struct ActionIdentity<'a> {
101 version: u8,
102 workspace: &'a str,
103 os: &'static str,
104 arch: &'static str,
105}
106
107pub fn build_identity(workspace_root: &Path, _command: &[String]) -> String {
115 let workspace = workspace_marker(workspace_root);
116 let bytes = canonical_json(&ActionIdentity {
117 version: 3,
118 workspace: &workspace,
119 os: std::env::consts::OS,
120 arch: std::env::consts::ARCH,
121 })
122 .expect("Cargo build identity must serialize");
123 CacheDigest::blake3(&bytes).hash
124}
125
126fn workspace_marker(workspace_root: &Path) -> String {
127 std::fs::read(workspace_root.join("Cargo.lock")).map_or_else(
128 |_| {
129 workspace_root
130 .file_name()
131 .unwrap_or_default()
132 .to_string_lossy()
133 .into_owned()
134 },
135 |lock| CacheDigest::blake3(&lock).hash,
136 )
137}
138
139pub fn cache_root() -> Option<PathBuf> {
141 if let Some(root) = std::env::var_os("MBX_CACHE_DIR").filter(|value| !value.is_empty()) {
142 return Some(PathBuf::from(root));
143 }
144 if let Some(config) = dirs::config_dir().map(|root| root.join("mbx/config.toml"))
145 && let Ok(contents) = std::fs::read_to_string(config)
146 && let Ok(document) = toml::from_str::<toml::Value>(&contents)
147 && let Some(root) = document.get("cache_dir").and_then(toml::Value::as_str)
148 {
149 return Some(PathBuf::from(root));
150 }
151 dirs::cache_dir().map(|root| root.join("mbx"))
152}
153
154fn cargo_arguments(arguments: &[String]) -> &[String] {
155 &arguments[..arguments
156 .iter()
157 .position(|argument| argument == "--")
158 .unwrap_or(arguments.len())]
159}
160
161fn absolute(working_dir: &Path, value: &str) -> PathBuf {
162 let path = PathBuf::from(value);
163 if path.is_absolute() {
164 path
165 } else {
166 working_dir.join(path)
167 }
168}
169
170fn forwarded_flags(arguments: &[String], flags: &[&str]) -> Vec<String> {
171 let mut forwarded = Vec::new();
172 let mut remaining = arguments.iter();
173 while let Some(argument) = remaining.next() {
174 if let Some((flag, value)) = argument
175 .split_once('=')
176 .filter(|(flag, _)| flags.contains(flag))
177 {
178 forwarded.extend([flag.to_string(), value.to_string()]);
179 } else if flags.contains(&argument.as_str()) {
180 if let Some(value) = remaining.next() {
181 forwarded.extend([argument.clone(), value.clone()]);
182 }
183 } else if flags
184 .iter()
185 .any(|flag| flag.len() == 2 && argument.len() > 2 && argument.starts_with(flag))
186 {
187 forwarded.push(argument.clone());
188 }
189 }
190 forwarded
191}
192
193fn invocation_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
194 flag_value(arguments, "-C")
195 .map(|value| absolute(working_dir, value))
196 .unwrap_or_else(|| working_dir.to_path_buf())
197}
198
199fn cargo_roots(
200 cargo: &OsStr,
201 arguments: &[String],
202 target_dir_env: Option<&OsStr>,
203) -> Option<(PathBuf, PathBuf)> {
204 let mut command = Command::new(cargo);
205 match target_dir_env {
206 Some(value) => command.env(CARGO_TARGET_DIR_ENV, value),
207 None => command.env_remove(CARGO_TARGET_DIR_ENV),
208 };
209 command.args(forwarded_flags(arguments, &PROBE_GLOBAL_FLAGS));
210 command.args(["metadata", "--no-deps", "--format-version", "1"]);
211 if let Some(manifest) = flag_value(arguments, "--manifest-path") {
212 command.args(["--manifest-path", manifest]);
213 }
214 command.args(
215 arguments
216 .iter()
217 .filter(|argument| PROBE_MANIFEST_TOGGLES.contains(&argument.as_str())),
218 );
219 let output = command.output().ok()?;
220 if !output.status.success() {
221 return None;
222 }
223 let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
224 Some((
225 PathBuf::from(metadata.get("workspace_root")?.as_str()?),
226 PathBuf::from(metadata.get("target_directory")?.as_str()?),
227 ))
228}
229
230fn target_dir_argument(arguments: &[String]) -> Option<&str> {
231 flag_value(arguments, "--target-dir")
232}
233
234fn cargo_config_may_set_target_dir(arguments: &[String], invocation_dir: &Path) -> bool {
235 if std::env::var_os("CARGO_BUILD_TARGET_DIR").is_some_and(|value| !value.is_empty()) {
236 return true;
237 }
238 if config_arguments(arguments).any(|value| {
239 value
240 .split_once('=')
241 .is_none_or(|(key, _)| matches!(key.trim(), "build.target-dir" | "include"))
242 }) {
243 return true;
244 }
245 let project = invocation_dir.ancestors().flat_map(|directory| {
246 let cargo = directory.join(".cargo");
247 [cargo.join("config.toml"), cargo.join("config")]
248 });
249 let home = std::env::var_os("CARGO_HOME")
250 .map(PathBuf::from)
251 .or_else(|| dirs::home_dir().map(|root| root.join(".cargo")))
252 .into_iter()
253 .flat_map(|cargo| [cargo.join("config.toml"), cargo.join("config")]);
254 project.chain(home).any(|path| config_may_set_target(&path))
255}
256
257fn config_arguments(arguments: &[String]) -> impl Iterator<Item = &str> {
258 let mut values = Vec::new();
259 let mut remaining = arguments.iter();
260 while let Some(argument) = remaining.next() {
261 if let Some(value) = argument.strip_prefix("--config=") {
262 values.push(value);
263 } else if argument == "--config"
264 && let Some(value) = remaining.next()
265 {
266 values.push(value);
267 }
268 }
269 values.into_iter()
270}
271
272fn config_may_set_target(path: &Path) -> bool {
273 let contents = match std::fs::read_to_string(path) {
274 Ok(contents) => contents,
275 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
276 Err(_) => return true,
277 };
278 let Ok(config) = toml::from_str::<toml::Value>(&contents) else {
279 return true;
280 };
281 config
282 .get("build")
283 .and_then(|build| build.get("target-dir"))
284 .is_some()
285 || config.get("include").is_some()
286}
287
288fn flag_value<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
289 let joined = format!("{flag}=");
290 let mut arguments = arguments.iter();
291 while let Some(argument) = arguments.next() {
292 if let Some(value) = argument.strip_prefix(&joined) {
293 return Some(value);
294 }
295 if argument == flag {
296 return arguments.next().map(String::as_str);
297 }
298 }
299 None
300}
301
302fn workspace_root(start: &Path) -> PathBuf {
303 start
304 .ancestors()
305 .find(|directory| directory.join("Cargo.toml").is_file())
306 .unwrap_or(start)
307 .to_path_buf()
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn cargo_fixture() -> tempfile::TempDir {
315 let directory = tempfile::tempdir().unwrap();
316 std::fs::write(
317 directory.path().join("Cargo.toml"),
318 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
319 )
320 .unwrap();
321 std::fs::create_dir_all(directory.path().join("src")).unwrap();
322 std::fs::write(directory.path().join("src/lib.rs"), "").unwrap();
323 directory
324 }
325
326 #[test]
327 fn lockfile_makes_identity_independent_of_checkout_path() {
328 let left = tempfile::tempdir().unwrap();
329 let right = tempfile::tempdir().unwrap();
330 std::fs::write(left.path().join("Cargo.lock"), "same").unwrap();
331 std::fs::write(right.path().join("Cargo.lock"), "same").unwrap();
332 let command = vec!["build".to_string(), "--workspace".to_string()];
333 assert_eq!(
334 build_identity(left.path(), &command),
335 build_identity(right.path(), &command)
336 );
337 }
338
339 #[test]
340 fn cargo_commands_and_toolchain_selectors_share_predictions() {
341 let project = tempfile::tempdir().unwrap();
342 std::fs::write(project.path().join("Cargo.lock"), "same").unwrap();
343
344 let build = vec!["build".to_string()];
345 let test = vec!["test".to_string(), "--workspace".to_string()];
346 let msrv = vec!["+1.91".to_string(), "check".to_string()];
347
348 assert_eq!(
349 build_identity(project.path(), &build),
350 build_identity(project.path(), &test),
351 );
352 assert_eq!(
353 build_identity(project.path(), &build),
354 build_identity(project.path(), &msrv),
355 );
356 }
357
358 #[test]
359 fn rustc_flags_after_separator_are_not_cargo_globals() {
360 let args = vec![
361 "rustc".into(),
362 "--".into(),
363 "-C".into(),
364 "opt-level=3".into(),
365 ];
366 assert_eq!(
367 invocation_dir(cargo_arguments(&args), Path::new("/work")),
368 PathBuf::from("/work")
369 );
370 }
371
372 #[test]
373 fn target_directory_flags_and_environment_are_recorded_as_explicit() {
374 let directory = cargo_fixture();
375 let root = directory.path();
376 let cargo = OsStr::new("cargo-that-does-not-exist");
377 let plain = ["build".to_string()];
378
379 let default = resolve(cargo, &plain, root, None);
380 assert_eq!(default.target_dir, root.join("target"));
381 assert!(!default.target_dir_requested);
382
383 for arguments in [
384 vec!["build".into(), "--target-dir=target".into()],
385 vec!["build".into(), "--target-dir".into(), "target".into()],
386 ] {
387 let resolved = resolve(cargo, &arguments, root, None);
388 assert_eq!(resolved.target_dir, root.join("target"));
389 assert!(resolved.target_dir_requested);
390 }
391
392 let from_environment = resolve(cargo, &plain, root, Some("elsewhere".into()));
393 assert_eq!(from_environment.target_dir, root.join("elsewhere"));
394 assert!(from_environment.target_dir_requested);
395
396 let empty_environment = resolve(cargo, &plain, root, Some("".into()));
397 assert!(!empty_environment.target_dir_requested);
398
399 let dangling = ["build".into(), "--target-dir".into()];
400 assert!(!resolve(cargo, &dangling, root, None).target_dir_requested);
401 }
402
403 #[test]
404 fn project_config_that_names_the_default_target_is_still_explicit() {
405 let directory = cargo_fixture();
406 let root = directory.path();
407 std::fs::create_dir_all(root.join(".cargo")).unwrap();
408 std::fs::write(
409 root.join(".cargo/config.toml"),
410 "[build]\ntarget-dir = \"target\"\n",
411 )
412 .unwrap();
413
414 let resolved = resolve(
415 OsStr::new("cargo-that-does-not-exist"),
416 &["build".into()],
417 root,
418 None,
419 );
420
421 assert_eq!(resolved.target_dir, root.join("target"));
422 assert!(resolved.target_dir_requested);
423 }
424
425 #[test]
426 fn command_line_config_include_may_set_the_target_directory() {
427 let directory = cargo_fixture();
428 let arguments = [
429 "build".into(),
430 "--config".into(),
431 "include='target-config.toml'".into(),
432 ];
433
434 let resolved = resolve(
435 OsStr::new("cargo-that-does-not-exist"),
436 &arguments,
437 directory.path(),
438 None,
439 );
440
441 assert!(resolved.target_dir_requested);
442 }
443
444 #[test]
445 fn command_line_target_config_reaches_the_cargo_probe() {
446 let directory = cargo_fixture();
447 let root = directory.path();
448 let configured = root.join("configured-target");
449 let arguments = [
450 "build".into(),
451 "--offline".into(),
452 "--manifest-path".into(),
453 root.join("Cargo.toml").display().to_string(),
454 "--config".into(),
455 format!("build.target-dir='{}'", configured.display()),
456 ];
457
458 let resolved = resolve_reported(OsStr::new("cargo"), &arguments, root, None).unwrap();
459
460 assert_eq!(resolved.target_dir, configured);
461 assert!(resolved.target_dir_requested);
462 }
463
464 #[test]
465 fn reported_resolution_requires_a_successful_metadata_probe() {
466 let directory = cargo_fixture();
467 assert!(
468 resolve_reported(
469 OsStr::new("cargo-that-does-not-exist"),
470 &["build".into()],
471 directory.path(),
472 None,
473 )
474 .is_none()
475 );
476 }
477}