1#![deny(missing_docs)]
6
7use mbx_cache_core::{CacheDigest, canonical_json};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::ffi::{OsStr, OsString};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::SystemTime;
14
15const CARGO_TARGET_DIR_ENV: &str = "CARGO_TARGET_DIR";
16const PROBE_GLOBAL_FLAGS: [&str; 3] = ["-C", "--config", "-Z"];
17const PROBE_MANIFEST_TOGGLES: [&str; 3] = ["--offline", "--frozen", "--locked"];
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CargoInvocation {
22 pub workspace_root: PathBuf,
24 pub target_dir: PathBuf,
26 pub target_dir_requested: bool,
28 pub build_identity: String,
30}
31
32pub fn resolve(
34 cargo: &OsStr,
35 arguments: &[String],
36 working_dir: &Path,
37 target_dir_env: Option<OsString>,
38) -> CargoInvocation {
39 resolve_in(
40 cache_root().as_deref(),
41 cargo,
42 arguments,
43 working_dir,
44 target_dir_env,
45 )
46}
47
48fn resolve_in(
49 cache: Option<&Path>,
50 cargo: &OsStr,
51 arguments: &[String],
52 working_dir: &Path,
53 target_dir_env: Option<OsString>,
54) -> CargoInvocation {
55 let cargo_args = cargo_arguments(arguments);
56 let reported = recalled_cargo_roots(
57 cache,
58 effective_cargo_home().as_deref(),
59 cargo,
60 cargo_args,
61 working_dir,
62 target_dir_env.as_deref(),
63 );
64 resolve_with_reported(arguments, working_dir, target_dir_env, reported)
65}
66
67pub fn resolve_reported(
73 cargo: &OsStr,
74 arguments: &[String],
75 working_dir: &Path,
76 target_dir_env: Option<OsString>,
77) -> Option<CargoInvocation> {
78 resolve_reported_in(
79 cache_root().as_deref(),
80 cargo,
81 arguments,
82 working_dir,
83 target_dir_env,
84 )
85}
86
87fn resolve_reported_in(
88 cache: Option<&Path>,
89 cargo: &OsStr,
90 arguments: &[String],
91 working_dir: &Path,
92 target_dir_env: Option<OsString>,
93) -> Option<CargoInvocation> {
94 resolve_reported_from_home(
95 cache,
96 effective_cargo_home().as_deref(),
97 cargo,
98 arguments,
99 working_dir,
100 target_dir_env,
101 )
102}
103
104fn effective_cargo_home() -> Option<PathBuf> {
107 std::env::var_os("CARGO_HOME")
108 .filter(|value| !value.is_empty())
109 .map(PathBuf::from)
110 .or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))
111}
112
113fn resolve_reported_from_home(
114 cache: Option<&Path>,
115 cargo_home: Option<&Path>,
116 cargo: &OsStr,
117 arguments: &[String],
118 working_dir: &Path,
119 target_dir_env: Option<OsString>,
120) -> Option<CargoInvocation> {
121 let reported = recalled_cargo_roots(
122 cache,
123 cargo_home,
124 cargo,
125 cargo_arguments(arguments),
126 working_dir,
127 target_dir_env.as_deref(),
128 )?;
129 Some(resolve_with_reported(
130 arguments,
131 working_dir,
132 target_dir_env,
133 Some(reported),
134 ))
135}
136
137fn resolve_with_reported(
138 arguments: &[String],
139 working_dir: &Path,
140 target_dir_env: Option<OsString>,
141 reported: Option<(PathBuf, PathBuf)>,
142) -> CargoInvocation {
143 let cargo_args = cargo_arguments(arguments);
144 let invocation_dir = invocation_dir(cargo_args, working_dir);
145 let workspace_root = reported
146 .as_ref()
147 .map(|roots| roots.0.clone())
148 .unwrap_or_else(|| workspace_root(&invocation_dir));
149 let flagged = target_dir_argument(cargo_args);
150 let target_dir_requested = flagged.is_some()
151 || target_dir_env
152 .as_ref()
153 .is_some_and(|value| !value.is_empty())
154 || cargo_config_may_set_target_dir(cargo_args, &invocation_dir);
155 let target_dir = flagged
156 .map(|value| absolute(&invocation_dir, value))
157 .or_else(|| reported.map(|roots| roots.1))
158 .or_else(|| {
159 target_dir_env
160 .filter(|value| !value.is_empty())
161 .map(PathBuf::from)
162 .map(|value| absolute(&invocation_dir, &value.to_string_lossy()))
163 })
164 .unwrap_or_else(|| workspace_root.join("target"));
165 let build_identity = build_identity(&workspace_root, arguments);
166 CargoInvocation {
167 workspace_root,
168 target_dir,
169 target_dir_requested,
170 build_identity,
171 }
172}
173
174#[derive(Serialize)]
175struct ActionIdentity<'a> {
176 version: u8,
177 workspace: &'a str,
178 os: &'static str,
179 arch: &'static str,
180}
181
182pub fn build_identity(workspace_root: &Path, _command: &[String]) -> String {
190 identity_for_workspace(&workspace_marker(workspace_root))
191}
192
193pub fn previous_build_identities(workspace_root: &Path) -> Vec<String> {
208 let Ok(lock) = std::fs::read(workspace_root.join("Cargo.lock")) else {
209 return Vec::new();
210 };
211 if git_output(
214 workspace_root,
215 &["ls-files", "--error-unmatch", "--", "Cargo.lock"],
216 )
217 .is_none()
218 {
219 return Vec::new();
220 }
221 let limit = PREVIOUS_LOCKFILE_STATES.to_string();
222 let Some(revisions) = git_output(
223 workspace_root,
224 &["rev-list", "-n", &limit, "HEAD", "--", "Cargo.lock"],
225 ) else {
226 return Vec::new();
227 };
228 let revisions = String::from_utf8_lossy(&revisions);
232 let candidates = ["HEAD", "HEAD^"].into_iter().map(str::to_string).chain(
233 revisions
234 .lines()
235 .map(|revision| format!("{}^", revision.trim())),
236 );
237 let mut seen = BTreeSet::from([CacheDigest::blake3(&lock).hash]);
238 let mut identities = Vec::new();
239 for revision in candidates {
240 let Some(lock) = git_output(
241 workspace_root,
242 &["show", &format!("{revision}:./Cargo.lock")],
243 ) else {
244 continue;
245 };
246 let marker = CacheDigest::blake3(&lock).hash;
247 if seen.insert(marker.clone()) {
248 identities.push(identity_for_workspace(&marker));
249 }
250 if identities.len() == PREVIOUS_LOCKFILE_STATES {
251 break;
252 }
253 }
254 identities
255}
256
257const PREVIOUS_LOCKFILE_STATES: usize = 8;
264
265fn git_output(workspace_root: &Path, arguments: &[&str]) -> Option<Vec<u8>> {
268 let output = Command::new("git")
269 .arg("-C")
270 .arg(workspace_root)
271 .args(arguments)
272 .stdin(std::process::Stdio::null())
273 .stderr(std::process::Stdio::null())
274 .output()
275 .ok()?;
276 output.status.success().then_some(output.stdout)
277}
278
279fn identity_for_workspace(workspace: &str) -> String {
281 let bytes = canonical_json(&ActionIdentity {
282 version: 3,
283 workspace,
284 os: std::env::consts::OS,
285 arch: std::env::consts::ARCH,
286 })
287 .expect("Cargo build identity must serialize");
288 CacheDigest::blake3(&bytes).hash
289}
290
291fn workspace_marker(workspace_root: &Path) -> String {
292 std::fs::read(workspace_root.join("Cargo.lock")).map_or_else(
293 |_| {
294 workspace_root
295 .file_name()
296 .unwrap_or_default()
297 .to_string_lossy()
298 .into_owned()
299 },
300 |lock| CacheDigest::blake3(&lock).hash,
301 )
302}
303
304pub fn cache_root() -> Option<PathBuf> {
306 if let Some(root) = std::env::var_os("MBX_CACHE_DIR").filter(|value| !value.is_empty()) {
307 return Some(PathBuf::from(root));
308 }
309 if let Some(config) = dirs::config_dir().map(|root| root.join("mbx/config.toml"))
310 && let Ok(contents) = std::fs::read_to_string(config)
311 && let Ok(document) = toml::from_str::<toml::Value>(&contents)
312 && let Some(root) = document.get("cache_dir").and_then(toml::Value::as_str)
313 {
314 return Some(PathBuf::from(root));
315 }
316 dirs::cache_dir().map(|root| root.join("mbx"))
317}
318
319fn cargo_arguments(arguments: &[String]) -> &[String] {
320 &arguments[..arguments
321 .iter()
322 .position(|argument| argument == "--")
323 .unwrap_or(arguments.len())]
324}
325
326fn absolute(working_dir: &Path, value: &str) -> PathBuf {
327 let path = PathBuf::from(value);
328 if path.is_absolute() {
329 path
330 } else {
331 working_dir.join(path)
332 }
333}
334
335fn forwarded_flags(arguments: &[String], flags: &[&str]) -> Vec<String> {
336 let mut forwarded = Vec::new();
337 let mut remaining = arguments.iter();
338 while let Some(argument) = remaining.next() {
339 if let Some((flag, value)) = argument
340 .split_once('=')
341 .filter(|(flag, _)| flags.contains(flag))
342 {
343 forwarded.extend([flag.to_string(), value.to_string()]);
344 } else if flags.contains(&argument.as_str()) {
345 if let Some(value) = remaining.next() {
346 forwarded.extend([argument.clone(), value.clone()]);
347 }
348 } else if flags
349 .iter()
350 .any(|flag| flag.len() == 2 && argument.len() > 2 && argument.starts_with(flag))
351 {
352 forwarded.push(argument.clone());
353 }
354 }
355 forwarded
356}
357
358fn invocation_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
359 flag_value(arguments, "-C")
360 .map(|value| absolute(working_dir, value))
361 .unwrap_or_else(|| working_dir.to_path_buf())
362}
363
364fn recalled_cargo_roots(
375 cache: Option<&Path>,
376 cargo_home: Option<&Path>,
377 cargo: &OsStr,
378 arguments: &[String],
379 working_dir: &Path,
380 target_dir_env: Option<&OsStr>,
381) -> Option<(PathBuf, PathBuf)> {
382 let probe = cache.and_then(|cache| {
383 ProbeRecord::describe(
384 cache,
385 cargo_home,
386 cargo,
387 arguments,
388 working_dir,
389 target_dir_env,
390 )
391 });
392 if let Some(recalled) = probe.as_ref().and_then(ProbeRecord::recall) {
393 return Some(recalled);
394 }
395 let roots = cargo_roots(cargo, arguments, target_dir_env)?;
396 if let Some(probe) = probe {
397 probe.remember(&roots);
398 }
399 Some(roots)
400}
401
402const PROBE_RECORD_VERSION: u8 = 1;
403
404#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
406struct ProbeRecord {
407 version: u8,
408 key: ProbeKey,
409 pins: Vec<Pin>,
412 #[serde(skip)]
413 path: PathBuf,
414 workspace_root: PathBuf,
415 target_dir: PathBuf,
416}
417
418#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
421struct ProbeKey {
422 cargo: PathBuf,
423 arguments: Vec<String>,
424 working_dir: PathBuf,
425 target_dir_env: Option<String>,
426 build_target_dir_env: Option<String>,
427 cargo_home: Option<String>,
428}
429
430#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
433struct Pin {
434 path: PathBuf,
435 state: Option<(u64, u64, u32)>,
436}
437
438impl Pin {
439 fn describe(path: PathBuf) -> Option<Self> {
442 let state = match std::fs::metadata(&path) {
443 Ok(metadata) => {
444 let modified = metadata
445 .modified()
446 .ok()?
447 .duration_since(SystemTime::UNIX_EPOCH)
448 .ok()?;
449 Some((metadata.len(), modified.as_secs(), modified.subsec_nanos()))
450 }
451 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
452 Err(_) => return None,
453 };
454 Some(Self { path, state })
455 }
456
457 fn holds(&self) -> bool {
458 Pin::describe(self.path.clone()).as_ref() == Some(self)
459 }
460}
461
462impl ProbeRecord {
463 fn describe(
466 cache: &Path,
467 cargo_home: Option<&Path>,
468 cargo: &OsStr,
469 arguments: &[String],
470 working_dir: &Path,
471 target_dir_env: Option<&OsStr>,
472 ) -> Option<Self> {
473 let cargo = resolve_program(cargo)?;
474 let env =
475 |name: &str| std::env::var_os(name).map(|value| value.to_string_lossy().into_owned());
476 let key = ProbeKey {
477 cargo: cargo.clone(),
478 arguments: probe_arguments(arguments),
479 working_dir: working_dir.to_path_buf(),
480 target_dir_env: target_dir_env.map(|value| value.to_string_lossy().into_owned()),
481 build_target_dir_env: env("CARGO_BUILD_TARGET_DIR"),
482 cargo_home: cargo_home.map(|home| home.to_string_lossy().into_owned()),
486 };
487 let invocation_dir = invocation_dir(arguments, working_dir);
488 let manifest_start = flag_value(arguments, "--manifest-path")
493 .map(|manifest| absolute(&invocation_dir, manifest))
494 .and_then(|manifest| manifest.parent().map(Path::to_path_buf))
495 .unwrap_or_else(|| invocation_dir.clone());
496 let mut watched = vec![cargo];
497 watched.extend(
498 manifest_start
499 .ancestors()
500 .map(|directory| directory.join("Cargo.toml")),
501 );
502 for directory in invocation_dir.ancestors() {
503 let dot_cargo = directory.join(".cargo");
504 watched.push(dot_cargo.join("config.toml"));
505 watched.push(dot_cargo.join("config"));
506 }
507 if let Some(home) = cargo_home {
508 watched.push(home.join("config.toml"));
509 watched.push(home.join("config"));
510 }
511 watched.extend(
512 config_arguments(arguments)
513 .map(|value| absolute(&invocation_dir, value))
514 .filter(|path| path.is_file()),
515 );
516 if config_arguments(arguments).any(|value| {
521 value
522 .split_once('=')
523 .is_some_and(|(key, _)| key.trim() == "include")
524 }) || watched
525 .iter()
526 .skip(1)
527 .filter(|path| path.file_name().is_some_and(|name| name != "Cargo.toml"))
528 .any(|path| config_includes_files(path))
529 {
530 return None;
531 }
532 let pins = watched
533 .into_iter()
534 .map(Pin::describe)
535 .collect::<Option<Vec<_>>>()?;
536 let selector = canonical_json(&key).ok()?;
537 let path = cache
538 .join("cargo-roots")
539 .join("v1")
540 .join(format!("{}.json", CacheDigest::blake3(&selector).hash));
541 Some(Self {
542 version: PROBE_RECORD_VERSION,
543 key,
544 pins,
545 path,
546 workspace_root: PathBuf::new(),
547 target_dir: PathBuf::new(),
548 })
549 }
550
551 fn recall(&self) -> Option<(PathBuf, PathBuf)> {
553 let bytes = std::fs::read(&self.path).ok()?;
554 let recorded: ProbeRecord = serde_json::from_slice(&bytes).ok()?;
555 if recorded.version != PROBE_RECORD_VERSION
556 || recorded.key != self.key
557 || !recorded.pins.iter().all(Pin::holds)
558 {
559 return None;
560 }
561 Some((recorded.workspace_root, recorded.target_dir))
562 }
563
564 fn remember(mut self, roots: &(PathBuf, PathBuf)) {
567 let root_manifest = roots.0.join("Cargo.toml");
568 if !self.pins.iter().any(|pin| pin.path == root_manifest) {
569 let Some(pin) = Pin::describe(root_manifest) else {
570 return;
571 };
572 self.pins.push(pin);
573 }
574 self.workspace_root = roots.0.clone();
575 self.target_dir = roots.1.clone();
576 let Ok(bytes) = serde_json::to_vec(&self) else {
577 return;
578 };
579 let Some(directory) = self.path.parent() else {
580 return;
581 };
582 if std::fs::create_dir_all(directory).is_err() {
583 return;
584 }
585 let staged = directory.join(format!(
586 ".{}.{}",
587 self.path.file_name().unwrap_or_default().to_string_lossy(),
588 std::process::id()
589 ));
590 if std::fs::write(&staged, bytes).is_ok() && std::fs::rename(&staged, &self.path).is_err() {
591 let _ = std::fs::remove_file(&staged);
592 }
593 }
594}
595
596fn config_includes_files(path: &Path) -> bool {
602 let contents = match std::fs::read_to_string(path) {
603 Ok(contents) => contents,
604 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
605 Err(_) => return true,
606 };
607 toml::from_str::<toml::Value>(&contents).is_ok_and(|config| config.get("include").is_some())
608 || toml::from_str::<toml::Value>(&contents).is_err()
609}
610
611fn probe_arguments(arguments: &[String]) -> Vec<String> {
614 let mut probe = forwarded_flags(arguments, &PROBE_GLOBAL_FLAGS);
615 probe.extend(
616 ["metadata", "--no-deps", "--format-version", "1"]
617 .iter()
618 .map(|argument| (*argument).to_string()),
619 );
620 if let Some(manifest) = flag_value(arguments, "--manifest-path") {
621 probe.push("--manifest-path".into());
622 probe.push(manifest.into());
623 }
624 probe.extend(
625 arguments
626 .iter()
627 .filter(|argument| PROBE_MANIFEST_TOGGLES.contains(&argument.as_str()))
628 .cloned(),
629 );
630 probe
631}
632
633fn resolve_program(program: &OsStr) -> Option<PathBuf> {
635 let candidate = Path::new(program);
636 if candidate.components().count() > 1 {
637 return candidate.is_file().then(|| candidate.to_path_buf());
638 }
639 let names = if cfg!(windows) {
640 vec![candidate.as_os_str().to_os_string(), {
641 let mut exe = candidate.as_os_str().to_os_string();
642 exe.push(".exe");
643 exe
644 }]
645 } else {
646 vec![candidate.as_os_str().to_os_string()]
647 };
648 std::env::split_paths(&std::env::var_os("PATH")?)
649 .flat_map(|directory| names.iter().map(move |name| directory.join(name)))
650 .find(|path| path.is_file())
651}
652
653fn cargo_roots(
654 cargo: &OsStr,
655 arguments: &[String],
656 target_dir_env: Option<&OsStr>,
657) -> Option<(PathBuf, PathBuf)> {
658 let mut command = Command::new(cargo);
659 match target_dir_env {
660 Some(value) => command.env(CARGO_TARGET_DIR_ENV, value),
661 None => command.env_remove(CARGO_TARGET_DIR_ENV),
662 };
663 command.args(probe_arguments(arguments));
664 let output = command.output().ok()?;
665 if !output.status.success() {
666 return None;
667 }
668 let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
669 Some((
670 PathBuf::from(metadata.get("workspace_root")?.as_str()?),
671 PathBuf::from(metadata.get("target_directory")?.as_str()?),
672 ))
673}
674
675fn target_dir_argument(arguments: &[String]) -> Option<&str> {
676 flag_value(arguments, "--target-dir")
677}
678
679fn cargo_config_may_set_target_dir(arguments: &[String], invocation_dir: &Path) -> bool {
680 if std::env::var_os("CARGO_BUILD_TARGET_DIR").is_some_and(|value| !value.is_empty()) {
681 return true;
682 }
683 if config_arguments(arguments).any(|value| {
684 value
685 .split_once('=')
686 .is_none_or(|(key, _)| matches!(key.trim(), "build.target-dir" | "include"))
687 }) {
688 return true;
689 }
690 let project = invocation_dir.ancestors().flat_map(|directory| {
691 let cargo = directory.join(".cargo");
692 [cargo.join("config.toml"), cargo.join("config")]
693 });
694 let home = std::env::var_os("CARGO_HOME")
695 .map(PathBuf::from)
696 .or_else(|| dirs::home_dir().map(|root| root.join(".cargo")))
697 .into_iter()
698 .flat_map(|cargo| [cargo.join("config.toml"), cargo.join("config")]);
699 project.chain(home).any(|path| config_may_set_target(&path))
700}
701
702fn config_arguments(arguments: &[String]) -> impl Iterator<Item = &str> {
703 let mut values = Vec::new();
704 let mut remaining = arguments.iter();
705 while let Some(argument) = remaining.next() {
706 if let Some(value) = argument.strip_prefix("--config=") {
707 values.push(value);
708 } else if argument == "--config"
709 && let Some(value) = remaining.next()
710 {
711 values.push(value);
712 }
713 }
714 values.into_iter()
715}
716
717fn config_may_set_target(path: &Path) -> bool {
718 let contents = match std::fs::read_to_string(path) {
719 Ok(contents) => contents,
720 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
721 Err(_) => return true,
722 };
723 let Ok(config) = toml::from_str::<toml::Value>(&contents) else {
724 return true;
725 };
726 config
727 .get("build")
728 .and_then(|build| build.get("target-dir"))
729 .is_some()
730 || config.get("include").is_some()
731}
732
733fn flag_value<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
734 let joined = format!("{flag}=");
735 let mut arguments = arguments.iter();
736 while let Some(argument) = arguments.next() {
737 if let Some(value) = argument.strip_prefix(&joined) {
738 return Some(value);
739 }
740 if argument == flag {
741 return arguments.next().map(String::as_str);
742 }
743 }
744 None
745}
746
747fn workspace_root(start: &Path) -> PathBuf {
748 start
749 .ancestors()
750 .find(|directory| directory.join("Cargo.toml").is_file())
751 .unwrap_or(start)
752 .to_path_buf()
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 fn cargo_fixture() -> tempfile::TempDir {
760 let directory = tempfile::tempdir().unwrap();
761 std::fs::write(
762 directory.path().join("Cargo.toml"),
763 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
764 )
765 .unwrap();
766 std::fs::create_dir_all(directory.path().join("src")).unwrap();
767 std::fs::write(directory.path().join("src/lib.rs"), "").unwrap();
768 directory
769 }
770
771 #[test]
772 fn lockfile_makes_identity_independent_of_checkout_path() {
773 let left = tempfile::tempdir().unwrap();
774 let right = tempfile::tempdir().unwrap();
775 std::fs::write(left.path().join("Cargo.lock"), "same").unwrap();
776 std::fs::write(right.path().join("Cargo.lock"), "same").unwrap();
777 let command = vec!["build".to_string(), "--workspace".to_string()];
778 assert_eq!(
779 build_identity(left.path(), &command),
780 build_identity(right.path(), &command)
781 );
782 }
783
784 #[test]
785 fn cargo_commands_and_toolchain_selectors_share_predictions() {
786 let project = tempfile::tempdir().unwrap();
787 std::fs::write(project.path().join("Cargo.lock"), "same").unwrap();
788
789 let build = vec!["build".to_string()];
790 let test = vec!["test".to_string(), "--workspace".to_string()];
791 let msrv = vec!["+1.91".to_string(), "check".to_string()];
792
793 assert_eq!(
794 build_identity(project.path(), &build),
795 build_identity(project.path(), &test),
796 );
797 assert_eq!(
798 build_identity(project.path(), &build),
799 build_identity(project.path(), &msrv),
800 );
801 }
802
803 #[test]
804 fn rustc_flags_after_separator_are_not_cargo_globals() {
805 let args = vec![
806 "rustc".into(),
807 "--".into(),
808 "-C".into(),
809 "opt-level=3".into(),
810 ];
811 assert_eq!(
812 invocation_dir(cargo_arguments(&args), Path::new("/work")),
813 PathBuf::from("/work")
814 );
815 }
816
817 #[test]
818 fn target_directory_flags_and_environment_are_recorded_as_explicit() {
819 let directory = cargo_fixture();
820 let root = directory.path();
821 let cargo = OsStr::new("cargo-that-does-not-exist");
822 let plain = ["build".to_string()];
823
824 let default = resolve(cargo, &plain, root, None);
825 assert_eq!(default.target_dir, root.join("target"));
826 assert!(!default.target_dir_requested);
827
828 for arguments in [
829 vec!["build".into(), "--target-dir=target".into()],
830 vec!["build".into(), "--target-dir".into(), "target".into()],
831 ] {
832 let resolved = resolve(cargo, &arguments, root, None);
833 assert_eq!(resolved.target_dir, root.join("target"));
834 assert!(resolved.target_dir_requested);
835 }
836
837 let from_environment = resolve(cargo, &plain, root, Some("elsewhere".into()));
838 assert_eq!(from_environment.target_dir, root.join("elsewhere"));
839 assert!(from_environment.target_dir_requested);
840
841 let empty_environment = resolve(cargo, &plain, root, Some("".into()));
842 assert!(!empty_environment.target_dir_requested);
843
844 let dangling = ["build".into(), "--target-dir".into()];
845 assert!(!resolve(cargo, &dangling, root, None).target_dir_requested);
846 }
847
848 #[test]
849 fn project_config_that_names_the_default_target_is_still_explicit() {
850 let directory = cargo_fixture();
851 let root = directory.path();
852 std::fs::create_dir_all(root.join(".cargo")).unwrap();
853 std::fs::write(
854 root.join(".cargo/config.toml"),
855 "[build]\ntarget-dir = \"target\"\n",
856 )
857 .unwrap();
858
859 let resolved = resolve(
860 OsStr::new("cargo-that-does-not-exist"),
861 &["build".into()],
862 root,
863 None,
864 );
865
866 assert_eq!(resolved.target_dir, root.join("target"));
867 assert!(resolved.target_dir_requested);
868 }
869
870 #[test]
871 fn command_line_config_include_may_set_the_target_directory() {
872 let directory = cargo_fixture();
873 let arguments = [
874 "build".into(),
875 "--config".into(),
876 "include='target-config.toml'".into(),
877 ];
878
879 let resolved = resolve(
880 OsStr::new("cargo-that-does-not-exist"),
881 &arguments,
882 directory.path(),
883 None,
884 );
885
886 assert!(resolved.target_dir_requested);
887 }
888
889 #[test]
890 fn command_line_target_config_reaches_the_cargo_probe() {
891 let directory = cargo_fixture();
892 let root = directory.path();
893 let configured = root.join("configured-target");
894 let arguments = [
895 "build".into(),
896 "--offline".into(),
897 "--manifest-path".into(),
898 root.join("Cargo.toml").display().to_string(),
899 "--config".into(),
900 format!("build.target-dir='{}'", configured.display()),
901 ];
902
903 let resolved =
904 resolve_reported_in(None, OsStr::new("cargo"), &arguments, root, None).unwrap();
905
906 assert_eq!(resolved.target_dir, configured);
907 assert!(resolved.target_dir_requested);
908 }
909
910 #[cfg(unix)]
912 fn logging_cargo(directory: &Path, project: &Path, log: &Path) -> PathBuf {
913 use std::os::unix::fs::PermissionsExt as _;
914 let script = directory.join("cargo");
915 std::fs::write(
916 &script,
917 format!(
918 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\ncase \" $* \" in *' metadata '*) printf '{{\"workspace_root\":\"{root}\",\"target_directory\":\"{root}/target\",\"packages\":[]}}';; esac\n",
919 log = log.display(),
920 root = project.display(),
921 ),
922 )
923 .unwrap();
924 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
925 script
926 }
927
928 #[cfg(unix)]
929 fn probes(log: &Path) -> usize {
930 std::fs::read_to_string(log)
931 .unwrap_or_default()
932 .lines()
933 .filter(|line| line.contains("metadata"))
934 .count()
935 }
936
937 #[test]
938 #[cfg(unix)]
939 fn a_probe_is_remembered_while_what_cargo_read_stands() {
940 let directory = cargo_fixture();
941 let root = directory.path();
942 let cache = tempfile::tempdir().unwrap();
943 let log = root.join("cargo.log");
944 let cargo = logging_cargo(root, root, &log);
945 let arguments = ["build".to_string(), "--locked".to_string()];
946 let home = root.join("cargo-home");
947 let resolve = |target_dir_env: Option<&str>| {
948 resolve_reported_from_home(
949 Some(cache.path()),
950 Some(&home),
951 cargo.as_os_str(),
952 &arguments,
953 root,
954 target_dir_env.map(OsString::from),
955 )
956 .unwrap()
957 };
958
959 let first = resolve(None);
960 assert_eq!(first.workspace_root, root);
961 assert_eq!(probes(&log), 1);
962 assert_eq!(resolve(None), first);
964 assert_eq!(probes(&log), 1);
965
966 std::fs::write(
968 root.join("Cargo.toml"),
969 "[package]\nname = \"fixture\"\nversion = \"0.2.0\"\nedition = \"2021\"\n",
970 )
971 .unwrap();
972 assert_eq!(resolve(None), first);
973 assert_eq!(probes(&log), 2);
974 assert_eq!(resolve(None), first);
975 assert_eq!(probes(&log), 2);
976
977 std::fs::create_dir_all(root.join(".cargo")).unwrap();
979 std::fs::write(root.join(".cargo/config.toml"), "[build]\njobs = 2\n").unwrap();
980 assert_eq!(resolve(None), first);
981 assert_eq!(probes(&log), 3);
982
983 assert!(resolve(Some("elsewhere")).target_dir_requested);
985 assert_eq!(probes(&log), 4);
986 assert_eq!(resolve(None), first);
987 assert_eq!(probes(&log), 4);
988
989 let other_home = root.join("other-home");
992 let from_other_home = resolve_reported_from_home(
993 Some(cache.path()),
994 Some(&other_home),
995 cargo.as_os_str(),
996 &arguments,
997 root,
998 None,
999 )
1000 .unwrap();
1001 assert_eq!(from_other_home.workspace_root, first.workspace_root);
1002 assert_eq!(probes(&log), 5);
1003 std::fs::create_dir_all(&other_home).unwrap();
1004 std::fs::write(other_home.join("config.toml"), "[build]\njobs = 4\n").unwrap();
1005 resolve_reported_from_home(
1006 Some(cache.path()),
1007 Some(&other_home),
1008 cargo.as_os_str(),
1009 &arguments,
1010 root,
1011 None,
1012 )
1013 .unwrap();
1014 assert_eq!(probes(&log), 6);
1015 assert_eq!(resolve(None), first);
1016 assert_eq!(probes(&log), 6);
1017
1018 std::fs::write(
1021 root.join(".cargo/config.toml"),
1022 "include = \"other.toml\"\n",
1023 )
1024 .unwrap();
1025 assert_eq!(resolve(None).workspace_root, first.workspace_root);
1026 assert_eq!(resolve(None).workspace_root, first.workspace_root);
1027 assert_eq!(probes(&log), 8);
1028 }
1029
1030 #[test]
1031 #[cfg(unix)]
1032 fn a_config_file_named_on_the_command_line_is_pinned() {
1033 let directory = cargo_fixture();
1034 let root = directory.path();
1035 let cache = tempfile::tempdir().unwrap();
1036 let log = root.join("cargo.log");
1037 let cargo = logging_cargo(root, root, &log);
1038 let extra = root.join("extra.toml");
1039 std::fs::write(&extra, "[build]\njobs = 2\n").unwrap();
1040 let arguments = [
1041 "build".to_string(),
1042 "--config".to_string(),
1043 extra.display().to_string(),
1044 ];
1045 let resolve = || {
1046 resolve_reported_in(
1047 Some(cache.path()),
1048 cargo.as_os_str(),
1049 &arguments,
1050 root,
1051 None,
1052 )
1053 .unwrap()
1054 };
1055
1056 let first = resolve();
1057 assert_eq!(resolve(), first);
1058 assert_eq!(probes(&log), 1);
1059 std::fs::write(&extra, "[build]\njobs = 3\n").unwrap();
1061 assert_eq!(resolve(), first);
1062 assert_eq!(probes(&log), 2);
1063 std::fs::write(&extra, "include = \"more.toml\"\n").unwrap();
1065 resolve();
1066 resolve();
1067 assert_eq!(probes(&log), 4);
1068 }
1069
1070 #[test]
1071 #[cfg(unix)]
1072 fn a_failed_probe_is_not_remembered() {
1073 use std::os::unix::fs::PermissionsExt as _;
1074 let directory = cargo_fixture();
1075 let root = directory.path();
1076 let cache = tempfile::tempdir().unwrap();
1077 let script = root.join("cargo");
1078 std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
1079 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1080 let arguments = ["build".to_string()];
1081
1082 assert!(
1083 resolve_reported_in(
1084 Some(cache.path()),
1085 script.as_os_str(),
1086 &arguments,
1087 root,
1088 None
1089 )
1090 .is_none()
1091 );
1092 assert!(!cache.path().join("cargo-roots").exists());
1093 }
1094
1095 #[test]
1096 fn reported_resolution_requires_a_successful_metadata_probe() {
1097 let directory = cargo_fixture();
1098 assert!(
1099 resolve_reported(
1100 OsStr::new("cargo-that-does-not-exist"),
1101 &["build".into()],
1102 directory.path(),
1103 None,
1104 )
1105 .is_none()
1106 );
1107 }
1108
1109 fn git(directory: &Path, arguments: &[&str]) {
1110 let status = Command::new("git")
1111 .arg("-C")
1112 .arg(directory)
1113 .args(["-c", "commit.gpgsign=false", "-c", "user.name=t"])
1114 .args(["-c", "user.email=t@example.com"])
1115 .args(arguments)
1116 .stdout(std::process::Stdio::null())
1117 .stderr(std::process::Stdio::null())
1118 .status()
1119 .unwrap();
1120 assert!(status.success(), "git {arguments:?} failed");
1121 }
1122
1123 #[test]
1124 fn earlier_lockfile_states_are_offered_newest_first() {
1125 let directory = tempfile::tempdir().unwrap();
1126 let root = directory.path();
1127 let command = Vec::new();
1128 git(root, &["init", "-q"]);
1129 let mut committed = Vec::new();
1130 for state in ["one", "two", "three"] {
1131 std::fs::write(root.join("Cargo.lock"), state).unwrap();
1132 committed.push(build_identity(root, &command));
1133 git(root, &["add", "Cargo.lock"]);
1134 git(root, &["commit", "-q", "-m", state]);
1135 }
1136 committed.reverse();
1137
1138 std::fs::write(root.join("Cargo.lock"), "four").unwrap();
1140 assert!(!committed.contains(&build_identity(root, &command)));
1141 assert_eq!(previous_build_identities(root), committed);
1142
1143 git(root, &["commit", "-q", "-am", "four"]);
1146 assert_eq!(previous_build_identities(root), committed);
1147 }
1148
1149 #[test]
1150 fn a_shallow_clone_offers_the_states_it_can_reach() {
1151 let origin = tempfile::tempdir().unwrap();
1152 let root = origin.path();
1153 let command = Vec::new();
1154 git(root, &["init", "-q"]);
1155 let mut committed = Vec::new();
1156 for state in ["one", "two", "three", "four"] {
1157 std::fs::write(root.join("Cargo.lock"), state).unwrap();
1158 committed.push(build_identity(root, &command));
1159 git(root, &["add", "Cargo.lock"]);
1160 git(root, &["commit", "-q", "-m", state]);
1161 }
1162 let clones = tempfile::tempdir().unwrap();
1163 let shallow = clones.path().join("shallow");
1164 git(
1165 clones.path(),
1166 &[
1167 "clone",
1168 "-q",
1169 "--depth",
1170 "2",
1171 &format!("file://{}", root.display()),
1172 shallow.to_str().unwrap(),
1173 ],
1174 );
1175 assert_eq!(
1177 previous_build_identities(&shallow),
1178 vec![committed[2].clone()]
1179 );
1180 }
1181
1182 #[test]
1183 fn an_untracked_lockfile_has_no_history_to_borrow_from() {
1184 let directory = tempfile::tempdir().unwrap();
1185 let root = directory.path();
1186 git(root, &["init", "-q"]);
1187 std::fs::write(root.join("Cargo.lock"), "tracked").unwrap();
1188 git(root, &["add", "Cargo.lock"]);
1189 git(root, &["commit", "-q", "-m", "tracked"]);
1190 git(root, &["rm", "-q", "Cargo.lock"]);
1191 git(root, &["commit", "-q", "-m", "removed"]);
1192 std::fs::write(root.join("Cargo.lock"), "generated").unwrap();
1194 assert!(previous_build_identities(root).is_empty());
1195 }
1196
1197 #[test]
1198 fn a_workspace_outside_version_control_has_no_earlier_states() {
1199 let directory = cargo_fixture();
1200 std::fs::write(directory.path().join("Cargo.lock"), "one").unwrap();
1201 assert!(previous_build_identities(directory.path()).is_empty());
1202 }
1203}