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 build_dir: Option<PathBuf>,
28 pub target_dir_requested: bool,
30 pub build_identity: String,
32}
33
34pub fn resolve(
36 cargo: &OsStr,
37 arguments: &[String],
38 working_dir: &Path,
39 target_dir_env: Option<OsString>,
40) -> CargoInvocation {
41 resolve_in(
42 cache_root().as_deref(),
43 cargo,
44 arguments,
45 working_dir,
46 target_dir_env,
47 )
48}
49
50fn resolve_in(
51 cache: Option<&Path>,
52 cargo: &OsStr,
53 arguments: &[String],
54 working_dir: &Path,
55 target_dir_env: Option<OsString>,
56) -> CargoInvocation {
57 let cargo_args = cargo_arguments(arguments);
58 let reported = recalled_cargo_roots(
59 cache,
60 effective_cargo_home().as_deref(),
61 cargo,
62 cargo_args,
63 working_dir,
64 target_dir_env.as_deref(),
65 );
66 resolve_with_reported(arguments, working_dir, target_dir_env, reported)
67}
68
69pub fn resolve_reported(
75 cargo: &OsStr,
76 arguments: &[String],
77 working_dir: &Path,
78 target_dir_env: Option<OsString>,
79) -> Option<CargoInvocation> {
80 resolve_reported_in(
81 cache_root().as_deref(),
82 cargo,
83 arguments,
84 working_dir,
85 target_dir_env,
86 )
87}
88
89fn resolve_reported_in(
90 cache: Option<&Path>,
91 cargo: &OsStr,
92 arguments: &[String],
93 working_dir: &Path,
94 target_dir_env: Option<OsString>,
95) -> Option<CargoInvocation> {
96 resolve_reported_from_home(
97 cache,
98 effective_cargo_home().as_deref(),
99 cargo,
100 arguments,
101 working_dir,
102 target_dir_env,
103 )
104}
105
106fn effective_cargo_home() -> Option<PathBuf> {
109 std::env::var_os("CARGO_HOME")
110 .filter(|value| !value.is_empty())
111 .map(PathBuf::from)
112 .or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))
113}
114
115fn resolve_reported_from_home(
116 cache: Option<&Path>,
117 cargo_home: Option<&Path>,
118 cargo: &OsStr,
119 arguments: &[String],
120 working_dir: &Path,
121 target_dir_env: Option<OsString>,
122) -> Option<CargoInvocation> {
123 let reported = recalled_cargo_roots(
124 cache,
125 cargo_home,
126 cargo,
127 cargo_arguments(arguments),
128 working_dir,
129 target_dir_env.as_deref(),
130 )?;
131 Some(resolve_with_reported(
132 arguments,
133 working_dir,
134 target_dir_env,
135 Some(reported),
136 ))
137}
138
139fn resolve_with_reported(
140 arguments: &[String],
141 working_dir: &Path,
142 target_dir_env: Option<OsString>,
143 reported: Option<(PathBuf, PathBuf, Option<PathBuf>)>,
144) -> CargoInvocation {
145 let cargo_args = cargo_arguments(arguments);
146 let install_source = path_install_dir(cargo_args, working_dir);
147 let invocation_dir = if install_source.is_some() {
148 directory_option_dir(cargo_args, working_dir)
149 } else {
150 invocation_dir(cargo_args, working_dir)
151 };
152 let workspace_root = reported
153 .as_ref()
154 .map(|roots| roots.0.clone())
155 .unwrap_or_else(|| workspace_root(&invocation_dir));
156 let flagged = target_dir_argument(cargo_args);
157 let target_dir_requested = flagged.is_some()
158 || target_dir_env
159 .as_ref()
160 .is_some_and(|value| !value.is_empty())
161 || cargo_config_may_set_target_dir(
162 cargo_args,
163 install_source.as_deref().unwrap_or(&invocation_dir),
164 );
165 let build_dir = reported.as_ref().and_then(|roots| roots.2.clone());
166 let target_dir = flagged
167 .map(|value| absolute(&invocation_dir, value))
168 .or_else(|| reported.map(|roots| roots.1))
169 .or_else(|| {
170 target_dir_env
171 .filter(|value| !value.is_empty())
172 .map(PathBuf::from)
173 .map(|value| absolute(&invocation_dir, &value.to_string_lossy()))
174 })
175 .unwrap_or_else(|| workspace_root.join("target"));
176 let build_identity = build_identity(&workspace_root, arguments);
177 CargoInvocation {
178 workspace_root,
179 target_dir,
180 target_dir_requested,
181 build_dir,
182 build_identity,
183 }
184}
185
186#[derive(Serialize)]
187struct ActionIdentity<'a> {
188 version: u8,
189 workspace: &'a str,
190 os: &'static str,
191 arch: &'static str,
192}
193
194pub fn build_identity(workspace_root: &Path, _command: &[String]) -> String {
202 identity_for_workspace(&workspace_marker(workspace_root))
203}
204
205pub fn previous_build_identities(workspace_root: &Path) -> Vec<String> {
220 let Ok(lock) = std::fs::read(workspace_root.join("Cargo.lock")) else {
221 return Vec::new();
222 };
223 if git_output(
226 workspace_root,
227 &["ls-files", "--error-unmatch", "--", "Cargo.lock"],
228 )
229 .is_none()
230 {
231 return Vec::new();
232 }
233 let limit = PREVIOUS_LOCKFILE_STATES.to_string();
234 let Some(revisions) = git_output(
235 workspace_root,
236 &["rev-list", "-n", &limit, "HEAD", "--", "Cargo.lock"],
237 ) else {
238 return Vec::new();
239 };
240 let revisions = String::from_utf8_lossy(&revisions);
244 let candidates = ["HEAD", "HEAD^"].into_iter().map(str::to_string).chain(
245 revisions
246 .lines()
247 .map(|revision| format!("{}^", revision.trim())),
248 );
249 let mut seen = BTreeSet::from([CacheDigest::blake3(&lock).hash]);
250 let mut identities = Vec::new();
251 for revision in candidates {
252 let Some(lock) = git_output(
253 workspace_root,
254 &["show", &format!("{revision}:./Cargo.lock")],
255 ) else {
256 continue;
257 };
258 let marker = CacheDigest::blake3(&lock).hash;
259 if seen.insert(marker.clone()) {
260 identities.push(identity_for_workspace(&marker));
261 }
262 if identities.len() == PREVIOUS_LOCKFILE_STATES {
263 break;
264 }
265 }
266 identities
267}
268
269const PREVIOUS_LOCKFILE_STATES: usize = 8;
276
277fn git_output(workspace_root: &Path, arguments: &[&str]) -> Option<Vec<u8>> {
280 let output = Command::new("git")
281 .arg("-C")
282 .arg(workspace_root)
283 .args(arguments)
284 .stdin(std::process::Stdio::null())
285 .stderr(std::process::Stdio::null())
286 .output()
287 .ok()?;
288 output.status.success().then_some(output.stdout)
289}
290
291fn identity_for_workspace(workspace: &str) -> String {
293 let bytes = canonical_json(&ActionIdentity {
294 version: 3,
295 workspace,
296 os: std::env::consts::OS,
297 arch: std::env::consts::ARCH,
298 })
299 .expect("Cargo build identity must serialize");
300 CacheDigest::blake3(&bytes).hash
301}
302
303fn workspace_marker(workspace_root: &Path) -> String {
304 std::fs::read(workspace_root.join("Cargo.lock")).map_or_else(
305 |_| {
306 workspace_root
307 .file_name()
308 .unwrap_or_default()
309 .to_string_lossy()
310 .into_owned()
311 },
312 |lock| CacheDigest::blake3(&lock).hash,
313 )
314}
315
316pub fn cache_root() -> Option<PathBuf> {
318 if let Some(root) = std::env::var_os("MBX_CACHE_DIR").filter(|value| !value.is_empty()) {
319 return Some(PathBuf::from(root));
320 }
321 if let Some(config) = dirs::config_dir().map(|root| root.join("mbx/config.toml"))
322 && let Ok(contents) = std::fs::read_to_string(config)
323 && let Ok(document) = toml::from_str::<toml::Value>(&contents)
324 && let Some(root) = document.get("cache_dir").and_then(toml::Value::as_str)
325 {
326 return Some(PathBuf::from(root));
327 }
328 dirs::cache_dir().map(|root| root.join("mbx"))
329}
330
331fn cargo_arguments(arguments: &[String]) -> &[String] {
332 &arguments[..arguments
333 .iter()
334 .position(|argument| argument == "--")
335 .unwrap_or(arguments.len())]
336}
337
338fn absolute(working_dir: &Path, value: &str) -> PathBuf {
339 let path = PathBuf::from(value);
340 if path.is_absolute() {
341 path
342 } else {
343 working_dir.join(path)
344 }
345}
346
347fn forwarded_flags(arguments: &[String], flags: &[&str]) -> Vec<String> {
348 let mut forwarded = Vec::new();
349 let mut remaining = arguments.iter();
350 while let Some(argument) = remaining.next() {
351 if let Some((flag, value)) = argument
352 .split_once('=')
353 .filter(|(flag, _)| flags.contains(flag))
354 {
355 forwarded.extend([flag.to_string(), value.to_string()]);
356 } else if flags.contains(&argument.as_str()) {
357 if let Some(value) = remaining.next() {
358 forwarded.extend([argument.clone(), value.clone()]);
359 }
360 } else if flags
361 .iter()
362 .any(|flag| flag.len() == 2 && argument.len() > 2 && argument.starts_with(flag))
363 {
364 forwarded.push(argument.clone());
365 }
366 }
367 forwarded
368}
369
370fn invocation_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
371 flag_value(arguments, "-C")
372 .map(|value| absolute(working_dir, value))
373 .unwrap_or_else(|| working_dir.to_path_buf())
374}
375
376fn directory_option_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
379 flag_value(arguments, "-C")
380 .or_else(|| flag_value(arguments, "--directory"))
381 .or_else(|| {
382 arguments
383 .iter()
384 .find_map(|arg| arg.strip_prefix("-C").filter(|value| !value.is_empty()))
385 })
386 .map(|value| absolute(working_dir, value))
387 .unwrap_or_else(|| working_dir.to_path_buf())
388}
389
390pub fn manifest_in_scope(arguments: &[String], working_dir: &Path) -> bool {
397 let arguments = cargo_arguments(arguments);
398 let invocation = directory_option_dir(arguments, working_dir);
399 if let Some(source) = path_install_dir(arguments, working_dir) {
406 return source.join("Cargo.toml").is_file();
407 }
408 match flag_value(arguments, "--manifest-path") {
409 Some(manifest) => absolute(&invocation, manifest).is_file(),
410 None => invocation
411 .ancestors()
412 .any(|directory| directory.join("Cargo.toml").is_file()),
413 }
414}
415
416fn path_install_dir(arguments: &[String], working_dir: &Path) -> Option<PathBuf> {
419 let arguments = cargo_arguments(arguments);
420 let mut remaining = arguments.iter();
421 while let Some(argument) = remaining.next() {
422 match argument.as_str() {
423 "--color" | "--config" | "-Z" | "-C" | "--directory" => {
424 remaining.next()?;
425 }
426 value if !value.starts_with('-') && !value.starts_with('+') => {
427 return (value == "install")
428 .then(|| {
429 flag_value(arguments, "--path").map(|path| {
430 absolute(&directory_option_dir(arguments, working_dir), path)
431 })
432 })
433 .flatten();
434 }
435 _ => {}
436 }
437 }
438 None
439}
440
441fn rebase_cli_include(value: &str, caller: &Path) -> String {
444 fn rebase(value: &mut toml::Value, caller: &Path) {
445 match value {
446 toml::Value::String(path) => {
447 *path = absolute(caller, path).to_string_lossy().into_owned();
448 }
449 toml::Value::Array(entries) => {
450 for entry in entries {
451 rebase(entry, caller);
452 }
453 }
454 toml::Value::Table(entry) => {
455 if let Some(toml::Value::String(path)) = entry.get_mut("path") {
456 *path = absolute(caller, path).to_string_lossy().into_owned();
457 }
458 }
459 _ => {}
460 }
461 }
462 if let Ok(mut config) = toml::from_str::<toml::Value>(value)
463 && config.as_table().is_some_and(|table| table.len() == 1)
464 && let Some(include) = config.get_mut("include")
465 {
466 rebase(include, caller);
467 return format!("include = {include}");
468 }
469 value.to_owned()
470}
471
472fn recalled_cargo_roots(
483 cache: Option<&Path>,
484 cargo_home: Option<&Path>,
485 cargo: &OsStr,
486 arguments: &[String],
487 working_dir: &Path,
488 target_dir_env: Option<&OsStr>,
489) -> Option<(PathBuf, PathBuf, Option<PathBuf>)> {
490 if let Some(source) = path_install_dir(arguments, working_dir) {
491 let caller = directory_option_dir(arguments, working_dir);
495 let mut probe_args = Vec::new();
496 if let Some(target) =
497 std::env::var_os("CARGO_BUILD_TARGET_DIR").filter(|value| !value.is_empty())
498 {
499 probe_args.extend([
502 "--config".into(),
503 format!(
504 "build.target-dir = {}",
505 toml::Value::String(
506 absolute(&caller, &target.to_string_lossy())
507 .to_string_lossy()
508 .into_owned()
509 )
510 ),
511 ]);
512 }
513 for value in config_arguments(arguments) {
514 let value = if value.contains('=') {
515 rebase_cli_include(value, &caller)
516 } else {
517 absolute(&caller, value).to_string_lossy().into_owned()
518 };
519 probe_args.extend(["--config".into(), value.clone()]);
520 if let Ok(config) = toml::from_str::<toml::Value>(&value)
523 && let Some(target) = config
524 .get("build")
525 .and_then(|build| build.get("target-dir"))
526 .and_then(toml::Value::as_str)
527 {
528 probe_args.extend([
529 "--config".into(),
530 format!(
531 "build.target-dir = {}",
532 toml::Value::String(
533 absolute(&caller, target).to_string_lossy().into_owned()
534 )
535 ),
536 ]);
537 }
538 }
539 probe_args.extend(forwarded_flags(arguments, &["-Z"]));
540 probe_args.push("build".into());
541 probe_args.extend(
542 arguments
543 .iter()
544 .filter(|arg| PROBE_MANIFEST_TOGGLES.contains(&arg.as_str()))
545 .cloned(),
546 );
547 let target = target_dir_env
548 .filter(|value| !value.is_empty())
549 .map(|value| absolute(&caller, &value.to_string_lossy()).into_os_string());
550 return recalled_cargo_roots(
551 cache,
552 cargo_home,
553 cargo,
554 &probe_args,
555 &source,
556 target.as_deref(),
557 );
558 }
559 let probe = cache.and_then(|cache| {
560 ProbeRecord::describe(
561 cache,
562 cargo_home,
563 cargo,
564 arguments,
565 working_dir,
566 target_dir_env,
567 )
568 });
569 if let Some(recalled) = probe.as_ref().and_then(ProbeRecord::recall) {
570 return Some(recalled);
571 }
572 let roots = cargo_roots(cargo, arguments, working_dir, target_dir_env)?;
573 if let Some(probe) = probe {
574 probe.remember(&roots);
575 }
576 Some(roots)
577}
578
579const PROBE_RECORD_VERSION: u8 = 2;
580
581#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
583struct ProbeRecord {
584 version: u8,
585 key: ProbeKey,
586 pins: Vec<Pin>,
589 #[serde(skip)]
590 path: PathBuf,
591 workspace_root: PathBuf,
592 target_dir: PathBuf,
593 build_dir: Option<PathBuf>,
594}
595
596#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
599struct ProbeKey {
600 cargo: PathBuf,
601 arguments: Vec<String>,
602 working_dir: PathBuf,
603 target_dir_env: Option<String>,
604 build_target_dir_env: Option<String>,
605 build_dir_env: Option<String>,
606 target_dir_argument: Option<String>,
607 cargo_home: Option<String>,
608}
609
610#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
613struct Pin {
614 path: PathBuf,
615 state: Option<(u64, u64, u32)>,
616}
617
618impl Pin {
619 fn describe(path: PathBuf) -> Option<Self> {
622 let state = match std::fs::metadata(&path) {
623 Ok(metadata) => {
624 let modified = metadata
625 .modified()
626 .ok()?
627 .duration_since(SystemTime::UNIX_EPOCH)
628 .ok()?;
629 Some((metadata.len(), modified.as_secs(), modified.subsec_nanos()))
630 }
631 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
632 Err(_) => return None,
633 };
634 Some(Self { path, state })
635 }
636
637 fn holds(&self) -> bool {
638 Pin::describe(self.path.clone()).as_ref() == Some(self)
639 }
640}
641
642impl ProbeRecord {
643 fn describe(
646 cache: &Path,
647 cargo_home: Option<&Path>,
648 cargo: &OsStr,
649 arguments: &[String],
650 working_dir: &Path,
651 target_dir_env: Option<&OsStr>,
652 ) -> Option<Self> {
653 let cargo = resolve_program(cargo)?;
654 let env =
655 |name: &str| std::env::var_os(name).map(|value| value.to_string_lossy().into_owned());
656 let key = ProbeKey {
657 cargo: cargo.clone(),
658 arguments: probe_arguments(arguments),
659 working_dir: working_dir.to_path_buf(),
660 target_dir_env: target_dir_env.map(|value| value.to_string_lossy().into_owned()),
661 build_target_dir_env: env("CARGO_BUILD_TARGET_DIR"),
662 build_dir_env: env("CARGO_BUILD_BUILD_DIR"),
663 target_dir_argument: target_dir_argument(arguments).map(str::to_owned),
664 cargo_home: cargo_home.map(|home| home.to_string_lossy().into_owned()),
668 };
669 let invocation_dir = invocation_dir(arguments, working_dir);
670 let manifest_start = flag_value(arguments, "--manifest-path")
675 .map(|manifest| absolute(&invocation_dir, manifest))
676 .and_then(|manifest| manifest.parent().map(Path::to_path_buf))
677 .unwrap_or_else(|| invocation_dir.clone());
678 let mut watched = vec![cargo];
679 watched.extend(
680 manifest_start
681 .ancestors()
682 .map(|directory| directory.join("Cargo.toml")),
683 );
684 for directory in invocation_dir.ancestors() {
685 let dot_cargo = directory.join(".cargo");
686 watched.push(dot_cargo.join("config.toml"));
687 watched.push(dot_cargo.join("config"));
688 }
689 if let Some(home) = cargo_home {
690 watched.push(home.join("config.toml"));
691 watched.push(home.join("config"));
692 }
693 watched.extend(
694 config_arguments(arguments)
695 .map(|value| absolute(&invocation_dir, value))
696 .filter(|path| path.is_file()),
697 );
698 if config_arguments(arguments).any(|value| {
703 value
704 .split_once('=')
705 .is_some_and(|(key, _)| key.trim() == "include")
706 }) || watched
707 .iter()
708 .skip(1)
709 .filter(|path| path.file_name().is_some_and(|name| name != "Cargo.toml"))
710 .any(|path| config_includes_files(path))
711 {
712 return None;
713 }
714 let pins = watched
715 .into_iter()
716 .map(Pin::describe)
717 .collect::<Option<Vec<_>>>()?;
718 let selector = canonical_json(&key).ok()?;
719 let path = cache
720 .join("cargo-roots")
721 .join("v1")
722 .join(format!("{}.json", CacheDigest::blake3(&selector).hash));
723 Some(Self {
724 version: PROBE_RECORD_VERSION,
725 key,
726 pins,
727 path,
728 workspace_root: PathBuf::new(),
729 target_dir: PathBuf::new(),
730 build_dir: None,
731 })
732 }
733
734 fn recall(&self) -> Option<(PathBuf, PathBuf, Option<PathBuf>)> {
736 let bytes = std::fs::read(&self.path).ok()?;
737 let recorded: ProbeRecord = serde_json::from_slice(&bytes).ok()?;
738 if recorded.version != PROBE_RECORD_VERSION
739 || recorded.key != self.key
740 || !recorded.pins.iter().all(Pin::holds)
741 {
742 return None;
743 }
744 Some((
745 recorded.workspace_root,
746 recorded.target_dir,
747 recorded.build_dir,
748 ))
749 }
750
751 fn remember(mut self, roots: &(PathBuf, PathBuf, Option<PathBuf>)) {
754 let root_manifest = roots.0.join("Cargo.toml");
755 if !self.pins.iter().any(|pin| pin.path == root_manifest) {
756 let Some(pin) = Pin::describe(root_manifest) else {
757 return;
758 };
759 self.pins.push(pin);
760 }
761 self.workspace_root = roots.0.clone();
762 self.target_dir = roots.1.clone();
763 self.build_dir = roots.2.clone();
764 let Ok(bytes) = serde_json::to_vec(&self) else {
765 return;
766 };
767 let Some(directory) = self.path.parent() else {
768 return;
769 };
770 if std::fs::create_dir_all(directory).is_err() {
771 return;
772 }
773 let staged = directory.join(format!(
774 ".{}.{}",
775 self.path.file_name().unwrap_or_default().to_string_lossy(),
776 std::process::id()
777 ));
778 if std::fs::write(&staged, bytes).is_ok() && std::fs::rename(&staged, &self.path).is_err() {
779 let _ = std::fs::remove_file(&staged);
780 }
781 }
782}
783
784fn config_includes_files(path: &Path) -> bool {
790 let contents = match std::fs::read_to_string(path) {
791 Ok(contents) => contents,
792 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
793 Err(_) => return true,
794 };
795 toml::from_str::<toml::Value>(&contents).is_ok_and(|config| config.get("include").is_some())
796 || toml::from_str::<toml::Value>(&contents).is_err()
797}
798
799fn probe_arguments(arguments: &[String]) -> Vec<String> {
802 let mut probe = forwarded_flags(arguments, &PROBE_GLOBAL_FLAGS);
803 probe.extend(
804 ["metadata", "--no-deps", "--format-version", "1"]
805 .iter()
806 .map(|argument| (*argument).to_string()),
807 );
808 if let Some(manifest) = flag_value(arguments, "--manifest-path") {
809 probe.push("--manifest-path".into());
810 probe.push(manifest.into());
811 }
812 probe.extend(
813 arguments
814 .iter()
815 .filter(|argument| PROBE_MANIFEST_TOGGLES.contains(&argument.as_str()))
816 .cloned(),
817 );
818 probe
819}
820
821fn resolve_program(program: &OsStr) -> Option<PathBuf> {
823 let candidate = Path::new(program);
824 if candidate.components().count() > 1 {
825 return candidate.is_file().then(|| candidate.to_path_buf());
826 }
827 let names = if cfg!(windows) {
828 vec![candidate.as_os_str().to_os_string(), {
829 let mut exe = candidate.as_os_str().to_os_string();
830 exe.push(".exe");
831 exe
832 }]
833 } else {
834 vec![candidate.as_os_str().to_os_string()]
835 };
836 std::env::split_paths(&std::env::var_os("PATH")?)
837 .flat_map(|directory| names.iter().map(move |name| directory.join(name)))
838 .find(|path| path.is_file())
839}
840
841fn cargo_roots(
842 cargo: &OsStr,
843 arguments: &[String],
844 working_dir: &Path,
845 target_dir_env: Option<&OsStr>,
846) -> Option<(PathBuf, PathBuf, Option<PathBuf>)> {
847 let mut command = Command::new(cargo);
848 match target_dir_argument(arguments)
849 .map(OsStr::new)
850 .or(target_dir_env)
851 {
852 Some(value) => command.env(CARGO_TARGET_DIR_ENV, value),
853 None => command.env_remove(CARGO_TARGET_DIR_ENV),
854 };
855 command
856 .current_dir(working_dir)
857 .args(probe_arguments(arguments));
858 let output = command.output().ok()?;
859 if !output.status.success() {
860 return None;
861 }
862 let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
863 Some((
864 PathBuf::from(metadata.get("workspace_root")?.as_str()?),
865 PathBuf::from(metadata.get("target_directory")?.as_str()?),
866 metadata
867 .get("build_directory")
868 .and_then(|value| value.as_str())
869 .map(PathBuf::from),
870 ))
871}
872
873fn target_dir_argument(arguments: &[String]) -> Option<&str> {
874 flag_value(arguments, "--target-dir")
875}
876
877fn cargo_config_may_set_target_dir(arguments: &[String], invocation_dir: &Path) -> bool {
878 if std::env::var_os("CARGO_BUILD_TARGET_DIR").is_some_and(|value| !value.is_empty()) {
879 return true;
880 }
881 if config_arguments(arguments).any(|value| {
882 value
883 .split_once('=')
884 .is_none_or(|(key, _)| matches!(key.trim(), "build.target-dir" | "include"))
885 }) {
886 return true;
887 }
888 let project = invocation_dir.ancestors().flat_map(|directory| {
889 let cargo = directory.join(".cargo");
890 [cargo.join("config.toml"), cargo.join("config")]
891 });
892 let home = std::env::var_os("CARGO_HOME")
893 .map(PathBuf::from)
894 .or_else(|| dirs::home_dir().map(|root| root.join(".cargo")))
895 .into_iter()
896 .flat_map(|cargo| [cargo.join("config.toml"), cargo.join("config")]);
897 project.chain(home).any(|path| config_may_set_target(&path))
898}
899
900fn config_arguments(arguments: &[String]) -> impl Iterator<Item = &str> {
901 let mut values = Vec::new();
902 let mut remaining = arguments.iter();
903 while let Some(argument) = remaining.next() {
904 if let Some(value) = argument.strip_prefix("--config=") {
905 values.push(value);
906 } else if argument == "--config"
907 && let Some(value) = remaining.next()
908 {
909 values.push(value);
910 }
911 }
912 values.into_iter()
913}
914
915fn config_may_set_target(path: &Path) -> bool {
916 let contents = match std::fs::read_to_string(path) {
917 Ok(contents) => contents,
918 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
919 Err(_) => return true,
920 };
921 let Ok(config) = toml::from_str::<toml::Value>(&contents) else {
922 return true;
923 };
924 config
925 .get("build")
926 .and_then(|build| build.get("target-dir"))
927 .is_some()
928 || config.get("include").is_some()
929}
930
931fn flag_value<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
932 let joined = format!("{flag}=");
933 let mut arguments = arguments.iter();
934 while let Some(argument) = arguments.next() {
935 if let Some(value) = argument.strip_prefix(&joined) {
936 return Some(value);
937 }
938 if argument == flag {
939 return arguments.next().map(String::as_str);
940 }
941 }
942 None
943}
944
945fn workspace_root(start: &Path) -> PathBuf {
946 start
947 .ancestors()
948 .find(|directory| directory.join("Cargo.toml").is_file())
949 .unwrap_or(start)
950 .to_path_buf()
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956
957 fn cargo_fixture() -> tempfile::TempDir {
958 let directory = tempfile::tempdir().unwrap();
959 std::fs::write(
960 directory.path().join("Cargo.toml"),
961 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
962 )
963 .unwrap();
964 std::fs::create_dir_all(directory.path().join("src")).unwrap();
965 std::fs::write(directory.path().join("src/lib.rs"), "").unwrap();
966 directory
967 }
968
969 fn fixture_root(path: &Path) -> PathBuf {
970 if cfg!(windows) {
972 path.to_path_buf()
973 } else {
974 path.canonicalize().unwrap()
975 }
976 }
977
978 #[test]
979 fn a_manifest_is_in_scope_only_where_cargo_would_read_one() {
980 let outside = tempfile::tempdir().unwrap();
981 let outside = outside.path();
982 let arguments = |value: &str| {
983 value
984 .split_whitespace()
985 .map(str::to_owned)
986 .collect::<Vec<_>>()
987 };
988 assert!(!manifest_in_scope(&arguments("binstall"), outside));
989 assert!(!manifest_in_scope(&arguments("build"), outside));
990
991 let project = cargo_fixture();
992 let project = project.path();
993 let nested = project.join("src");
994 assert!(manifest_in_scope(&arguments("binstall"), project));
995 assert!(manifest_in_scope(&arguments("binstall"), &nested));
998
999 assert!(manifest_in_scope(
1001 &[
1002 "-C".into(),
1003 project.to_string_lossy().into_owned(),
1004 "binstall".into()
1005 ],
1006 outside
1007 ));
1008 assert!(!manifest_in_scope(
1009 &[
1010 "--directory".into(),
1011 outside.to_string_lossy().into_owned(),
1012 "binstall".into()
1013 ],
1014 project
1015 ));
1016
1017 assert!(manifest_in_scope(
1020 &[
1021 "binstall".into(),
1022 "--manifest-path".into(),
1023 "Cargo.toml".into()
1024 ],
1025 project
1026 ));
1027 assert!(!manifest_in_scope(
1028 &["binstall".into(), "--manifest-path=src/Cargo.toml".into()],
1029 project
1030 ));
1031
1032 assert!(manifest_in_scope(
1037 &[
1038 "install".into(),
1039 "--path".into(),
1040 project.to_string_lossy().into_owned()
1041 ],
1042 outside
1043 ));
1044 assert!(!manifest_in_scope(
1045 &[
1046 "generate".into(),
1047 "--path".into(),
1048 project.to_string_lossy().into_owned()
1049 ],
1050 outside
1051 ));
1052 assert!(!manifest_in_scope(
1053 &[
1054 "install".into(),
1055 "--path".into(),
1056 outside.join("absent").to_string_lossy().into_owned()
1057 ],
1058 outside
1059 ));
1060 assert!(!manifest_in_scope(
1062 &[
1063 "run".into(),
1064 "--".into(),
1065 "--path".into(),
1066 project.to_string_lossy().into_owned()
1067 ],
1068 outside
1069 ));
1070 }
1071
1072 #[test]
1073 fn lockfile_makes_identity_independent_of_checkout_path() {
1074 let left = tempfile::tempdir().unwrap();
1075 let right = tempfile::tempdir().unwrap();
1076 std::fs::write(left.path().join("Cargo.lock"), "same").unwrap();
1077 std::fs::write(right.path().join("Cargo.lock"), "same").unwrap();
1078 let command = vec!["build".to_string(), "--workspace".to_string()];
1079 assert_eq!(
1080 build_identity(left.path(), &command),
1081 build_identity(right.path(), &command)
1082 );
1083 }
1084
1085 #[test]
1086 fn cargo_commands_and_toolchain_selectors_share_predictions() {
1087 let project = tempfile::tempdir().unwrap();
1088 std::fs::write(project.path().join("Cargo.lock"), "same").unwrap();
1089
1090 let build = vec!["build".to_string()];
1091 let test = vec!["test".to_string(), "--workspace".to_string()];
1092 let msrv = vec!["+1.91".to_string(), "check".to_string()];
1093
1094 assert_eq!(
1095 build_identity(project.path(), &build),
1096 build_identity(project.path(), &test),
1097 );
1098 assert_eq!(
1099 build_identity(project.path(), &build),
1100 build_identity(project.path(), &msrv),
1101 );
1102 }
1103
1104 #[test]
1105 fn rustc_flags_after_separator_are_not_cargo_globals() {
1106 let args = vec![
1107 "rustc".into(),
1108 "--".into(),
1109 "-C".into(),
1110 "opt-level=3".into(),
1111 ];
1112 assert_eq!(
1113 invocation_dir(cargo_arguments(&args), Path::new("/work")),
1114 PathBuf::from("/work")
1115 );
1116 }
1117
1118 #[test]
1119 fn target_directory_flags_and_environment_are_recorded_as_explicit() {
1120 let directory = cargo_fixture();
1121 let root = directory.path();
1122 let cargo = OsStr::new("cargo-that-does-not-exist");
1123 let plain = ["build".to_string()];
1124
1125 let default = resolve(cargo, &plain, root, None);
1126 assert_eq!(default.target_dir, root.join("target"));
1127 assert!(!default.target_dir_requested);
1128
1129 for arguments in [
1130 vec!["build".into(), "--target-dir=target".into()],
1131 vec!["build".into(), "--target-dir".into(), "target".into()],
1132 ] {
1133 let resolved = resolve(cargo, &arguments, root, None);
1134 assert_eq!(resolved.target_dir, root.join("target"));
1135 assert!(resolved.target_dir_requested);
1136 }
1137
1138 let from_environment = resolve(cargo, &plain, root, Some("elsewhere".into()));
1139 assert_eq!(from_environment.target_dir, root.join("elsewhere"));
1140 assert!(from_environment.target_dir_requested);
1141
1142 let empty_environment = resolve(cargo, &plain, root, Some("".into()));
1143 assert!(!empty_environment.target_dir_requested);
1144
1145 let dangling = ["build".into(), "--target-dir".into()];
1146 assert!(!resolve(cargo, &dangling, root, None).target_dir_requested);
1147 }
1148
1149 #[test]
1150 fn project_config_that_names_the_default_target_is_still_explicit() {
1151 let directory = cargo_fixture();
1152 let root = directory.path();
1153 std::fs::create_dir_all(root.join(".cargo")).unwrap();
1154 std::fs::write(
1155 root.join(".cargo/config.toml"),
1156 "[build]\ntarget-dir = \"target\"\n",
1157 )
1158 .unwrap();
1159
1160 let resolved = resolve(
1161 OsStr::new("cargo-that-does-not-exist"),
1162 &["build".into()],
1163 root,
1164 None,
1165 );
1166
1167 assert_eq!(resolved.target_dir, root.join("target"));
1168 assert!(resolved.target_dir_requested);
1169 }
1170
1171 #[test]
1172 fn command_line_config_include_may_set_the_target_directory() {
1173 let directory = cargo_fixture();
1174 let arguments = [
1175 "build".into(),
1176 "--config".into(),
1177 "include='target-config.toml'".into(),
1178 ];
1179
1180 let resolved = resolve(
1181 OsStr::new("cargo-that-does-not-exist"),
1182 &arguments,
1183 directory.path(),
1184 None,
1185 );
1186
1187 assert!(resolved.target_dir_requested);
1188 }
1189
1190 #[test]
1191 fn command_line_target_config_reaches_the_cargo_probe() {
1192 let directory = cargo_fixture();
1193 let root = directory.path();
1194 let configured = root.join("configured-target");
1195 let arguments = [
1196 "build".into(),
1197 "--offline".into(),
1198 "--manifest-path".into(),
1199 root.join("Cargo.toml").display().to_string(),
1200 "--config".into(),
1201 format!("build.target-dir='{}'", configured.display()),
1202 ];
1203
1204 let resolved =
1205 resolve_reported_in(None, OsStr::new("cargo"), &arguments, root, None).unwrap();
1206
1207 assert_eq!(resolved.target_dir, configured);
1208 assert!(resolved.target_dir_requested);
1209 }
1210
1211 #[test]
1212 fn path_install_probes_source_config_and_preserves_caller_relative_targets() {
1213 let source = cargo_fixture();
1214 let caller = cargo_fixture();
1215 let source_root = fixture_root(source.path());
1216 let caller_root = fixture_root(caller.path());
1217 for (root, target) in [
1218 (source_root.as_path(), "source-target"),
1219 (caller_root.as_path(), "caller-target"),
1220 ] {
1221 std::fs::create_dir_all(root.join(".cargo")).unwrap();
1222 std::fs::write(
1223 root.join(".cargo/config.toml"),
1224 format!("[build]\ntarget-dir = '{target}'\n"),
1225 )
1226 .unwrap();
1227 }
1228 let arguments = vec![
1229 "install".into(),
1230 format!("--path={}", source_root.as_path().display()),
1231 "--offline".into(),
1232 ];
1233 let cache = tempfile::tempdir().unwrap();
1234 let resolve = |args: &[String], target| {
1235 resolve_reported_in(
1236 Some(cache.path()),
1237 OsStr::new("cargo"),
1238 args,
1239 caller_root.as_path(),
1240 target,
1241 )
1242 .unwrap()
1243 };
1244 let roots = resolve(&arguments, None);
1245 assert_eq!(roots.workspace_root, source_root.as_path());
1246 assert_eq!(
1247 roots.target_dir,
1248 source_root.as_path().join("source-target")
1249 );
1250 assert!(roots.target_dir_requested);
1251
1252 let mut flagged = arguments.clone();
1253 flagged.extend(["--target-dir".into(), "flag-target".into()]);
1254 assert_eq!(
1255 resolve(&flagged, None).target_dir,
1256 caller_root.as_path().join("flag-target")
1257 );
1258 assert_eq!(
1259 resolve(&arguments, Some("env-target".into())).target_dir,
1260 caller_root.as_path().join("env-target")
1261 );
1262 let mut configured = arguments.clone();
1263 configured.extend(["--config".into(), "build.target-dir='cli-target'".into()]);
1264 assert_eq!(
1265 resolve(&configured, None).target_dir,
1266 caller_root.as_path().join("cli-target")
1267 );
1268
1269 std::fs::write(
1271 source_root.as_path().join(".cargo/config.toml"),
1272 "[build]\ntarget-dir = 'changed-source-target'\n",
1273 )
1274 .unwrap();
1275 assert_eq!(
1276 resolve(&arguments, None).target_dir,
1277 source_root.as_path().join("changed-source-target")
1278 );
1279 }
1280
1281 #[test]
1282 fn path_install_directory_flags_rebase_the_source_and_target() {
1283 let source = cargo_fixture();
1284 let source_root = fixture_root(source.path());
1285 let caller = source_root.parent().unwrap();
1286 let directory = source_root.file_name().unwrap().to_str().unwrap();
1287 for flags in [
1288 vec!["--directory".into(), directory.into()],
1289 vec![format!("--directory={directory}")],
1290 vec!["-C".into(), directory.into()],
1291 vec![format!("-C{directory}")],
1292 ] {
1293 let mut arguments = flags;
1294 arguments.extend(
1295 [
1296 "install",
1297 "--path",
1298 ".",
1299 "--target-dir",
1300 "shared",
1301 "--offline",
1302 ]
1303 .map(str::to_owned),
1304 );
1305 let roots =
1306 resolve_reported_in(None, OsStr::new("cargo"), &arguments, caller, None).unwrap();
1307 assert_eq!(roots.workspace_root, source_root);
1308 assert_eq!(roots.target_dir, source_root.join("shared"));
1309 assert!(roots.target_dir_requested);
1310 }
1311 }
1312
1313 #[test]
1314 fn path_install_cli_includes_keep_caller_paths_and_override_order() {
1315 let source = cargo_fixture();
1316 let caller = cargo_fixture();
1317 let source_root = fixture_root(source.path());
1318 let caller_root = fixture_root(caller.path());
1319 for (root, target) in [
1320 (&caller_root, "caller-target"),
1321 (&source_root, "wrong-target"),
1322 ] {
1323 std::fs::create_dir_all(root.join(".config")).unwrap();
1324 std::fs::write(
1325 root.join(".config/extra.toml"),
1326 format!("[build]\ntarget-dir = '{target}'\n"),
1327 )
1328 .unwrap();
1329 }
1330 let resolve = |overrides: &[&str]| {
1331 let mut args = vec![
1332 "install".into(),
1333 "--path".into(),
1334 source_root.display().to_string(),
1335 "--offline".into(),
1336 ];
1337 for value in overrides {
1338 args.extend(["--config".into(), (*value).into()]);
1339 }
1340 resolve_reported_in(None, OsStr::new("cargo"), &args, &caller_root, None)
1341 };
1342 for include in [
1343 "include=['.config/extra.toml']",
1344 "include=[{path='.config/extra.toml'}, {path='.config/missing.toml', optional=true}]",
1345 ] {
1346 assert_eq!(
1347 resolve(&[include]).unwrap().target_dir,
1348 caller_root.join("caller-target")
1349 );
1350 assert_eq!(
1351 resolve(&[include, "build.target-dir='override'"])
1352 .unwrap()
1353 .target_dir,
1354 caller_root.join("override")
1355 );
1356 }
1357 let absolute_include = format!(
1358 "include=[{}]",
1359 toml::Value::String(caller_root.join(".config/extra.toml").display().to_string())
1360 );
1361 assert_eq!(
1362 resolve(&[&absolute_include]).unwrap().target_dir,
1363 caller_root.join("caller-target")
1364 );
1365 assert!(resolve(&["include=['.config/missing.toml']"]).is_none());
1366
1367 assert!(resolve(&["include=42"]).is_none());
1369 }
1370
1371 #[cfg(unix)]
1373 fn logging_cargo(directory: &Path, project: &Path, log: &Path) -> PathBuf {
1374 use std::os::unix::fs::PermissionsExt as _;
1375 let script = directory.join("cargo");
1376 std::fs::write(
1377 &script,
1378 format!(
1379 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\ncase \" $* \" in *' metadata '*) printf '{{\"workspace_root\":\"{root}\",\"target_directory\":\"{root}/target\",\"packages\":[]}}';; esac\n",
1380 log = log.display(),
1381 root = project.display(),
1382 ),
1383 )
1384 .unwrap();
1385 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1386 script
1387 }
1388
1389 #[cfg(unix)]
1393 fn build_dir_cargo(directory: &Path, project: &Path, log: &Path) -> PathBuf {
1394 use std::os::unix::fs::PermissionsExt as _;
1395 let script = directory.join("cargo");
1396 std::fs::write(
1397 &script,
1398 format!(
1399 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\ncase \" $* \" in *' metadata '*) target=\"${{CARGO_TARGET_DIR:-{root}/target}}\"; build=\"${{CARGO_BUILD_BUILD_DIR:-$target/build-dir}}\"; printf '{{\"workspace_root\":\"{root}\",\"target_directory\":\"%s\",\"build_directory\":\"%s\",\"packages\":[]}}' \"$target\" \"$build\";; esac\n",
1400 log = log.display(),
1401 root = project.display(),
1402 ),
1403 )
1404 .unwrap();
1405 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1406 script
1407 }
1408
1409 #[cfg(unix)]
1410 fn probes(log: &Path) -> usize {
1411 std::fs::read_to_string(log)
1412 .unwrap_or_default()
1413 .lines()
1414 .filter(|line| line.contains("metadata"))
1415 .count()
1416 }
1417
1418 #[test]
1419 fn reported_intermediates_survive_resolution_and_cached_records() {
1420 let directory = cargo_fixture();
1421 let root = directory.path();
1422 let cache = tempfile::tempdir().unwrap();
1423 let arguments = ["build".to_owned()];
1424 let roots = (
1425 root.to_path_buf(),
1426 root.join("target"),
1427 Some(root.join("intermediates")),
1428 );
1429 let resolved = resolve_with_reported(&arguments, root, None, Some(roots.clone()));
1430 assert_eq!(resolved.build_dir, roots.2);
1431 let describe = || {
1432 ProbeRecord::describe(
1433 cache.path(),
1434 None,
1435 OsStr::new("cargo"),
1436 &arguments,
1437 root,
1438 None,
1439 )
1440 .unwrap()
1441 };
1442 describe().remember(&roots);
1443 assert_eq!(describe().recall(), Some(roots.clone()));
1444 let old_cargo =
1445 resolve_with_reported(&arguments, root, None, Some((roots.0, roots.1, None)));
1446 assert_eq!(old_cargo.build_dir, None);
1447 }
1448
1449 #[cfg(unix)]
1451 const BUILD_DIR_PROBE_CHILD: &str = "MBX_CARGO_BUILD_DIR_PROBE_CHILD";
1452
1453 #[test]
1464 #[cfg(unix)]
1465 fn a_probed_build_directory_is_parsed_recalled_and_rekeyed() {
1466 if std::env::var_os(BUILD_DIR_PROBE_CHILD).is_none() {
1467 let status = Command::new(std::env::current_exe().unwrap())
1468 .args([
1469 "--exact",
1470 "--nocapture",
1471 "--test-threads=1",
1472 "tests::a_probed_build_directory_is_parsed_recalled_and_rekeyed",
1473 ])
1474 .env(BUILD_DIR_PROBE_CHILD, "1")
1475 .env_remove("CARGO_BUILD_BUILD_DIR")
1476 .env_remove("CARGO_TARGET_DIR")
1477 .env_remove("CARGO_BUILD_TARGET_DIR")
1478 .status()
1479 .unwrap();
1480 assert!(status.success(), "the child run of this test failed");
1481 return;
1482 }
1483
1484 let directory = cargo_fixture();
1485 let root = directory.path();
1486 let cache = tempfile::tempdir().unwrap();
1487 let log = root.join("cargo.log");
1488 let cargo = build_dir_cargo(root, root, &log);
1489 let home = root.join("cargo-home");
1490 let resolve = |arguments: &[String]| {
1491 resolve_reported_from_home(
1492 Some(cache.path()),
1493 Some(&home),
1494 cargo.as_os_str(),
1495 arguments,
1496 root,
1497 None,
1498 )
1499 .unwrap()
1500 };
1501 let build = ["build".to_string()];
1502
1503 let first = resolve(&build);
1505 assert_eq!(first.build_dir, Some(root.join("target/build-dir")));
1506 assert_eq!(probes(&log), 1);
1507
1508 assert_eq!(resolve(&build).build_dir, first.build_dir);
1510 assert_eq!(probes(&log), 1);
1511
1512 let elsewhere = root.join("elsewhere");
1514 unsafe { std::env::set_var("CARGO_BUILD_BUILD_DIR", &elsewhere) };
1516 assert_eq!(resolve(&build).build_dir, Some(elsewhere));
1517 assert_eq!(probes(&log), 2);
1518 unsafe { std::env::remove_var("CARGO_BUILD_BUILD_DIR") };
1520 assert_eq!(resolve(&build).build_dir, first.build_dir);
1521 assert_eq!(probes(&log), 2);
1522
1523 let other_target = root.join("other-target");
1526 let targeted = [
1527 "build".to_string(),
1528 "--target-dir".to_string(),
1529 other_target.display().to_string(),
1530 ];
1531 let with_target = resolve(&targeted);
1532 assert_eq!(with_target.build_dir, Some(other_target.join("build-dir")));
1533 assert_eq!(probes(&log), 3);
1534 assert_eq!(resolve(&targeted).build_dir, with_target.build_dir);
1535 assert_eq!(resolve(&build).build_dir, first.build_dir);
1536 assert_eq!(probes(&log), 3);
1537 }
1538
1539 #[test]
1540 #[cfg(unix)]
1541 fn a_probe_is_remembered_while_what_cargo_read_stands() {
1542 let directory = cargo_fixture();
1543 let root = directory.path();
1544 let cache = tempfile::tempdir().unwrap();
1545 let log = root.join("cargo.log");
1546 let cargo = logging_cargo(root, root, &log);
1547 let arguments = ["build".to_string(), "--locked".to_string()];
1548 let home = root.join("cargo-home");
1549 let resolve = |target_dir_env: Option<&str>| {
1550 resolve_reported_from_home(
1551 Some(cache.path()),
1552 Some(&home),
1553 cargo.as_os_str(),
1554 &arguments,
1555 root,
1556 target_dir_env.map(OsString::from),
1557 )
1558 .unwrap()
1559 };
1560
1561 let first = resolve(None);
1562 assert_eq!(first.workspace_root, root);
1563 assert_eq!(probes(&log), 1);
1564 assert_eq!(resolve(None), first);
1566 assert_eq!(probes(&log), 1);
1567
1568 std::fs::write(
1570 root.join("Cargo.toml"),
1571 "[package]\nname = \"fixture\"\nversion = \"0.2.0\"\nedition = \"2021\"\n",
1572 )
1573 .unwrap();
1574 assert_eq!(resolve(None), first);
1575 assert_eq!(probes(&log), 2);
1576 assert_eq!(resolve(None), first);
1577 assert_eq!(probes(&log), 2);
1578
1579 std::fs::create_dir_all(root.join(".cargo")).unwrap();
1581 std::fs::write(root.join(".cargo/config.toml"), "[build]\njobs = 2\n").unwrap();
1582 assert_eq!(resolve(None), first);
1583 assert_eq!(probes(&log), 3);
1584
1585 assert!(resolve(Some("elsewhere")).target_dir_requested);
1587 assert_eq!(probes(&log), 4);
1588 assert_eq!(resolve(None), first);
1589 assert_eq!(probes(&log), 4);
1590
1591 let other_home = root.join("other-home");
1594 let from_other_home = resolve_reported_from_home(
1595 Some(cache.path()),
1596 Some(&other_home),
1597 cargo.as_os_str(),
1598 &arguments,
1599 root,
1600 None,
1601 )
1602 .unwrap();
1603 assert_eq!(from_other_home.workspace_root, first.workspace_root);
1604 assert_eq!(probes(&log), 5);
1605 std::fs::create_dir_all(&other_home).unwrap();
1606 std::fs::write(other_home.join("config.toml"), "[build]\njobs = 4\n").unwrap();
1607 resolve_reported_from_home(
1608 Some(cache.path()),
1609 Some(&other_home),
1610 cargo.as_os_str(),
1611 &arguments,
1612 root,
1613 None,
1614 )
1615 .unwrap();
1616 assert_eq!(probes(&log), 6);
1617 assert_eq!(resolve(None), first);
1618 assert_eq!(probes(&log), 6);
1619
1620 std::fs::write(
1623 root.join(".cargo/config.toml"),
1624 "include = \"other.toml\"\n",
1625 )
1626 .unwrap();
1627 assert_eq!(resolve(None).workspace_root, first.workspace_root);
1628 assert_eq!(resolve(None).workspace_root, first.workspace_root);
1629 assert_eq!(probes(&log), 8);
1630 }
1631
1632 #[test]
1633 #[cfg(unix)]
1634 fn a_config_file_named_on_the_command_line_is_pinned() {
1635 let directory = cargo_fixture();
1636 let root = directory.path();
1637 let cache = tempfile::tempdir().unwrap();
1638 let log = root.join("cargo.log");
1639 let cargo = logging_cargo(root, root, &log);
1640 let extra = root.join("extra.toml");
1641 std::fs::write(&extra, "[build]\njobs = 2\n").unwrap();
1642 let arguments = [
1643 "build".to_string(),
1644 "--config".to_string(),
1645 extra.display().to_string(),
1646 ];
1647 let resolve = || {
1648 resolve_reported_in(
1649 Some(cache.path()),
1650 cargo.as_os_str(),
1651 &arguments,
1652 root,
1653 None,
1654 )
1655 .unwrap()
1656 };
1657
1658 let first = resolve();
1659 assert_eq!(resolve(), first);
1660 assert_eq!(probes(&log), 1);
1661 std::fs::write(&extra, "[build]\njobs = 3\n").unwrap();
1663 assert_eq!(resolve(), first);
1664 assert_eq!(probes(&log), 2);
1665 std::fs::write(&extra, "include = \"more.toml\"\n").unwrap();
1667 resolve();
1668 resolve();
1669 assert_eq!(probes(&log), 4);
1670 }
1671
1672 #[test]
1673 #[cfg(unix)]
1674 fn a_failed_probe_is_not_remembered() {
1675 use std::os::unix::fs::PermissionsExt as _;
1676 let directory = cargo_fixture();
1677 let root = directory.path();
1678 let cache = tempfile::tempdir().unwrap();
1679 let script = root.join("cargo");
1680 std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
1681 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1682 let arguments = ["build".to_string()];
1683
1684 assert!(
1685 resolve_reported_in(
1686 Some(cache.path()),
1687 script.as_os_str(),
1688 &arguments,
1689 root,
1690 None
1691 )
1692 .is_none()
1693 );
1694 assert!(!cache.path().join("cargo-roots").exists());
1695 }
1696
1697 #[test]
1698 fn reported_resolution_requires_a_successful_metadata_probe() {
1699 let directory = cargo_fixture();
1700 assert!(
1701 resolve_reported(
1702 OsStr::new("cargo-that-does-not-exist"),
1703 &["build".into()],
1704 directory.path(),
1705 None,
1706 )
1707 .is_none()
1708 );
1709 }
1710
1711 fn git(directory: &Path, arguments: &[&str]) {
1712 let status = Command::new("git")
1713 .arg("-C")
1714 .arg(directory)
1715 .args(["-c", "commit.gpgsign=false", "-c", "user.name=t"])
1716 .args(["-c", "user.email=t@example.com"])
1717 .args(arguments)
1718 .stdout(std::process::Stdio::null())
1719 .stderr(std::process::Stdio::null())
1720 .status()
1721 .unwrap();
1722 assert!(status.success(), "git {arguments:?} failed");
1723 }
1724
1725 #[test]
1726 fn earlier_lockfile_states_are_offered_newest_first() {
1727 let directory = tempfile::tempdir().unwrap();
1728 let root = directory.path();
1729 let command = Vec::new();
1730 git(root, &["init", "-q"]);
1731 let mut committed = Vec::new();
1732 for state in ["one", "two", "three"] {
1733 std::fs::write(root.join("Cargo.lock"), state).unwrap();
1734 committed.push(build_identity(root, &command));
1735 git(root, &["add", "Cargo.lock"]);
1736 git(root, &["commit", "-q", "-m", state]);
1737 }
1738 committed.reverse();
1739
1740 std::fs::write(root.join("Cargo.lock"), "four").unwrap();
1742 assert!(!committed.contains(&build_identity(root, &command)));
1743 assert_eq!(previous_build_identities(root), committed);
1744
1745 git(root, &["commit", "-q", "-am", "four"]);
1748 assert_eq!(previous_build_identities(root), committed);
1749 }
1750
1751 #[test]
1752 fn a_shallow_clone_offers_the_states_it_can_reach() {
1753 let origin = tempfile::tempdir().unwrap();
1754 let root = origin.path();
1755 let command = Vec::new();
1756 git(root, &["init", "-q"]);
1757 let mut committed = Vec::new();
1758 for state in ["one", "two", "three", "four"] {
1759 std::fs::write(root.join("Cargo.lock"), state).unwrap();
1760 committed.push(build_identity(root, &command));
1761 git(root, &["add", "Cargo.lock"]);
1762 git(root, &["commit", "-q", "-m", state]);
1763 }
1764 let clones = tempfile::tempdir().unwrap();
1765 let shallow = clones.path().join("shallow");
1766 git(
1767 clones.path(),
1768 &[
1769 "clone",
1770 "-q",
1771 "--depth",
1772 "2",
1773 &format!("file://{}", root.display()),
1774 shallow.to_str().unwrap(),
1775 ],
1776 );
1777 assert_eq!(
1779 previous_build_identities(&shallow),
1780 vec![committed[2].clone()]
1781 );
1782 }
1783
1784 #[test]
1785 fn an_untracked_lockfile_has_no_history_to_borrow_from() {
1786 let directory = tempfile::tempdir().unwrap();
1787 let root = directory.path();
1788 git(root, &["init", "-q"]);
1789 std::fs::write(root.join("Cargo.lock"), "tracked").unwrap();
1790 git(root, &["add", "Cargo.lock"]);
1791 git(root, &["commit", "-q", "-m", "tracked"]);
1792 git(root, &["rm", "-q", "Cargo.lock"]);
1793 git(root, &["commit", "-q", "-m", "removed"]);
1794 std::fs::write(root.join("Cargo.lock"), "generated").unwrap();
1796 assert!(previous_build_identities(root).is_empty());
1797 }
1798
1799 #[test]
1800 fn a_workspace_outside_version_control_has_no_earlier_states() {
1801 let directory = cargo_fixture();
1802 std::fs::write(directory.path().join("Cargo.lock"), "one").unwrap();
1803 assert!(previous_build_identities(directory.path()).is_empty());
1804 }
1805}