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)]
21#[non_exhaustive]
22pub enum ProbeFailure {
23 Io(std::io::Error),
25 Exit {
27 status: std::process::ExitStatus,
29 stderr: String,
31 },
32 Json(serde_json::Error),
34 Schema(&'static str),
36}
37
38impl std::fmt::Display for ProbeFailure {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 Self::Io(error) => write!(f, "cargo metadata I/O error: {error}"),
42 Self::Exit { status, stderr } => {
43 write!(f, "cargo metadata failed with {status}")?;
44 if !stderr.is_empty() {
45 write!(f, ":\n{stderr}")?;
46 }
47 Ok(())
48 }
49 Self::Json(error) => write!(f, "invalid cargo metadata JSON: {error}"),
50 Self::Schema(field) => write!(f, "cargo metadata {field} missing or not a string"),
51 }
52 }
53}
54
55impl std::error::Error for ProbeFailure {
56 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
57 match self {
58 Self::Io(error) => Some(error),
59 Self::Json(error) => Some(error),
60 _ => None,
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct CargoInvocation {
68 pub workspace_root: PathBuf,
70 pub target_dir: PathBuf,
72 pub build_dir: Option<PathBuf>,
74 pub target_dir_requested: bool,
76 pub build_identity: String,
78}
79
80pub fn resolve(
82 cargo: &OsStr,
83 arguments: &[String],
84 working_dir: &Path,
85 target_dir_env: Option<OsString>,
86) -> CargoInvocation {
87 resolve_in(
88 cache_root().as_deref(),
89 cargo,
90 arguments,
91 working_dir,
92 target_dir_env,
93 )
94}
95
96fn resolve_in(
97 cache: Option<&Path>,
98 cargo: &OsStr,
99 arguments: &[String],
100 working_dir: &Path,
101 target_dir_env: Option<OsString>,
102) -> CargoInvocation {
103 let (reported, arguments) = probed_roots(
104 cache,
105 effective_cargo_home().as_deref(),
106 cargo,
107 arguments,
108 working_dir,
109 target_dir_env.as_deref(),
110 );
111 resolve_with_reported(arguments, working_dir, target_dir_env, reported.ok())
112}
113
114pub fn resolve_reported(
120 cargo: &OsStr,
121 arguments: &[String],
122 working_dir: &Path,
123 target_dir_env: Option<OsString>,
124) -> Option<CargoInvocation> {
125 try_resolve_reported(cargo, arguments, working_dir, target_dir_env).ok()
126}
127
128pub fn try_resolve_reported(
140 cargo: &OsStr,
141 arguments: &[String],
142 working_dir: &Path,
143 target_dir_env: Option<OsString>,
144) -> Result<CargoInvocation, ProbeFailure> {
145 try_resolve_reported_from_home(
146 cache_root().as_deref(),
147 effective_cargo_home().as_deref(),
148 cargo,
149 arguments,
150 working_dir,
151 target_dir_env,
152 )
153}
154
155#[cfg(test)]
156fn resolve_reported_in(
157 cache: Option<&Path>,
158 cargo: &OsStr,
159 arguments: &[String],
160 working_dir: &Path,
161 target_dir_env: Option<OsString>,
162) -> Option<CargoInvocation> {
163 resolve_reported_from_home(
164 cache,
165 effective_cargo_home().as_deref(),
166 cargo,
167 arguments,
168 working_dir,
169 target_dir_env,
170 )
171}
172
173fn effective_cargo_home() -> Option<PathBuf> {
176 std::env::var_os("CARGO_HOME")
177 .filter(|value| !value.is_empty())
178 .map(PathBuf::from)
179 .or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))
180}
181
182#[cfg(test)]
183fn resolve_reported_from_home(
184 cache: Option<&Path>,
185 cargo_home: Option<&Path>,
186 cargo: &OsStr,
187 arguments: &[String],
188 working_dir: &Path,
189 target_dir_env: Option<OsString>,
190) -> Option<CargoInvocation> {
191 try_resolve_reported_from_home(
192 cache,
193 cargo_home,
194 cargo,
195 arguments,
196 working_dir,
197 target_dir_env,
198 )
199 .ok()
200}
201
202fn try_resolve_reported_from_home(
203 cache: Option<&Path>,
204 cargo_home: Option<&Path>,
205 cargo: &OsStr,
206 arguments: &[String],
207 working_dir: &Path,
208 target_dir_env: Option<OsString>,
209) -> Result<CargoInvocation, ProbeFailure> {
210 let (reported, arguments) = probed_roots(
211 cache,
212 cargo_home,
213 cargo,
214 arguments,
215 working_dir,
216 target_dir_env.as_deref(),
217 );
218 Ok(resolve_with_reported(
219 arguments,
220 working_dir,
221 target_dir_env,
222 Some(reported?),
223 ))
224}
225
226type ReportedRoots = (PathBuf, PathBuf, Option<PathBuf>);
228
229fn probed_roots<'a>(
239 cache: Option<&Path>,
240 cargo_home: Option<&Path>,
241 cargo: &OsStr,
242 arguments: &'a [String],
243 working_dir: &Path,
244 target_dir_env: Option<&OsStr>,
245) -> (Result<ReportedRoots, ProbeFailure>, &'a [String]) {
246 let probe = |arguments: &[String]| {
247 recalled_cargo_roots(
248 cache,
249 cargo_home,
250 cargo,
251 cargo_arguments(arguments),
252 working_dir,
253 target_dir_env,
254 )
255 };
256 let full = probe(arguments);
257 if full.is_ok() {
258 return (full, arguments);
259 }
260 match owned_arguments(arguments) {
261 Some(owned) => (probe(owned), owned),
262 None => (full, arguments),
263 }
264}
265
266fn owned_arguments(arguments: &[String]) -> Option<&[String]> {
269 let arguments = cargo_arguments(arguments);
270 let (index, command) = subcommand(arguments)?;
271 (!is_builtin_command(command) && index + 1 < arguments.len()).then(|| &arguments[..=index])
272}
273
274pub fn is_builtin_command(command: &str) -> bool {
277 matches!(
278 command,
279 "build"
280 | "check"
281 | "test"
282 | "run"
283 | "bench"
284 | "doc"
285 | "rustc"
286 | "rustdoc"
287 | "fix"
288 | "install"
289 | "help"
290 | "new"
291 | "init"
292 | "add"
293 | "remove"
294 | "update"
295 | "fetch"
296 | "clean"
297 | "config"
298 | "tree"
299 | "search"
300 | "info"
301 | "login"
302 | "logout"
303 | "owner"
304 | "package"
305 | "publish"
306 | "uninstall"
307 | "yank"
308 | "locate-project"
309 | "metadata"
310 | "generate-lockfile"
311 | "pkgid"
312 | "read-manifest"
313 | "report"
314 | "vendor"
315 | "verify-project"
316 | "version"
317 | "git-checkout"
318 )
319}
320
321fn resolve_with_reported(
322 arguments: &[String],
323 working_dir: &Path,
324 target_dir_env: Option<OsString>,
325 reported: Option<(PathBuf, PathBuf, Option<PathBuf>)>,
326) -> CargoInvocation {
327 let cargo_args = cargo_arguments(arguments);
328 let install_source = path_install_dir(cargo_args, working_dir);
329 let invocation_dir = if install_source.is_some() {
330 directory_option_dir(cargo_args, working_dir)
331 } else {
332 invocation_dir(cargo_args, working_dir)
333 };
334 let workspace_root = reported
335 .as_ref()
336 .map(|roots| roots.0.clone())
337 .unwrap_or_else(|| workspace_root(&invocation_dir));
338 let flagged = target_dir_argument(cargo_args);
339 let target_dir_requested = flagged.is_some()
340 || target_dir_env
341 .as_ref()
342 .is_some_and(|value| !value.is_empty())
343 || cargo_config_may_set_target_dir(
344 cargo_args,
345 install_source.as_deref().unwrap_or(&invocation_dir),
346 );
347 let build_dir = reported.as_ref().and_then(|roots| roots.2.clone());
348 let target_dir = flagged
349 .map(|value| absolute(&invocation_dir, value))
350 .or_else(|| reported.map(|roots| roots.1))
351 .or_else(|| {
352 target_dir_env
353 .filter(|value| !value.is_empty())
354 .map(PathBuf::from)
355 .map(|value| absolute(&invocation_dir, &value.to_string_lossy()))
356 })
357 .unwrap_or_else(|| workspace_root.join("target"));
358 let build_identity = build_identity(&workspace_root, arguments);
359 CargoInvocation {
360 workspace_root,
361 target_dir,
362 target_dir_requested,
363 build_dir,
364 build_identity,
365 }
366}
367
368#[derive(Serialize)]
369struct ActionIdentity<'a> {
370 version: u8,
371 workspace: &'a str,
372 os: &'static str,
373 arch: &'static str,
374}
375
376pub fn build_identity(workspace_root: &Path, _command: &[String]) -> String {
384 identity_for_workspace(&workspace_marker(workspace_root))
385}
386
387pub fn previous_build_identities(workspace_root: &Path) -> Vec<String> {
410 let Ok(lock) = std::fs::read(workspace_root.join("Cargo.lock")) else {
411 return Vec::new();
412 };
413 let current = lockfile_marker(&lock);
414 let checked_out = CacheDigest::blake3(&lock).hash;
415 let converts = current != checked_out;
417 let mut seen = BTreeSet::from([current]);
418 let mut identities = Vec::new();
419 if seen.insert(checked_out.clone()) {
420 identities.push(identity_for_workspace(&checked_out));
421 }
422 if git_output(
425 workspace_root,
426 &["ls-files", "--error-unmatch", "--", "Cargo.lock"],
427 )
428 .is_none()
429 {
430 return identities;
431 }
432 let limit = PREVIOUS_LOCKFILE_STATES.to_string();
433 let Some(revisions) = git_output(
434 workspace_root,
435 &["rev-list", "-n", &limit, "HEAD", "--", "Cargo.lock"],
436 ) else {
437 return identities;
438 };
439 let revisions = String::from_utf8_lossy(&revisions);
443 let candidates = ["HEAD", "HEAD^"].into_iter().map(str::to_string).chain(
444 revisions
445 .lines()
446 .map(|revision| format!("{}^", revision.trim())),
447 );
448 let mut states = 0;
449 for revision in candidates {
450 let Some(lock) = git_output(
451 workspace_root,
452 &["show", &format!("{revision}:./Cargo.lock")],
453 ) else {
454 continue;
455 };
456 let normalized = lockfile_marker(&lock);
457 let new_state = !seen.contains(&normalized);
458 let mut markers = vec![normalized, CacheDigest::blake3(&lock).hash];
459 if converts {
460 markers.push(CacheDigest::blake3(&crlf_checkout(&lock)).hash);
461 }
462 for marker in markers {
463 if seen.insert(marker.clone()) {
464 identities.push(identity_for_workspace(&marker));
465 }
466 }
467 states += usize::from(new_state);
468 if states == PREVIOUS_LOCKFILE_STATES {
469 break;
470 }
471 }
472 identities
473}
474
475const PREVIOUS_LOCKFILE_STATES: usize = 8;
482
483fn git_output(workspace_root: &Path, arguments: &[&str]) -> Option<Vec<u8>> {
486 let output = Command::new("git")
487 .arg("-C")
488 .arg(workspace_root)
489 .args(arguments)
490 .stdin(std::process::Stdio::null())
491 .stderr(std::process::Stdio::null())
492 .output()
493 .ok()?;
494 output.status.success().then_some(output.stdout)
495}
496
497fn identity_for_workspace(workspace: &str) -> String {
499 let bytes = canonical_json(&ActionIdentity {
500 version: 3,
501 workspace,
502 os: std::env::consts::OS,
503 arch: std::env::consts::ARCH,
504 })
505 .expect("Cargo build identity must serialize");
506 CacheDigest::blake3(&bytes).hash
507}
508
509fn workspace_marker(workspace_root: &Path) -> String {
510 std::fs::read(workspace_root.join("Cargo.lock")).map_or_else(
511 |_| {
512 workspace_root
513 .file_name()
514 .unwrap_or_default()
515 .to_string_lossy()
516 .into_owned()
517 },
518 |lock| lockfile_marker(&lock),
519 )
520}
521
522fn lockfile_marker(lock: &[u8]) -> String {
530 let mut normalized = Vec::with_capacity(lock.len());
531 let mut bytes = lock.iter().copied().peekable();
532 while let Some(byte) = bytes.next() {
533 if byte != b'\r' || bytes.peek() != Some(&b'\n') {
534 normalized.push(byte);
535 }
536 }
537 CacheDigest::blake3(&normalized).hash
538}
539
540fn crlf_checkout(lock: &[u8]) -> Vec<u8> {
542 let mut converted = Vec::with_capacity(lock.len() + lock.len() / 32);
543 let mut previous = None;
544 for &byte in lock {
545 if byte == b'\n' && previous != Some(b'\r') {
546 converted.push(b'\r');
547 }
548 converted.push(byte);
549 previous = Some(byte);
550 }
551 converted
552}
553
554pub fn cache_root() -> Option<PathBuf> {
560 if let Some(root) = std::env::var_os("MBX_CACHE_DIR").filter(|value| value.to_str().is_some()) {
561 return (!root.is_empty()).then(|| PathBuf::from(root));
562 }
563 if let Some(config) = dirs::config_dir().map(|root| root.join("mbx/config.toml"))
564 && let Ok(contents) = std::fs::read_to_string(config)
565 && let Ok(document) = toml::from_str::<toml::Value>(&contents)
566 && let Some(root) = document.get("cache_dir").and_then(toml::Value::as_str)
567 {
568 return Some(PathBuf::from(root));
569 }
570 dirs::cache_dir().map(|root| root.join("mbx"))
571}
572
573fn cargo_arguments(arguments: &[String]) -> &[String] {
574 &arguments[..arguments
575 .iter()
576 .position(|argument| argument == "--")
577 .unwrap_or(arguments.len())]
578}
579
580fn absolute(working_dir: &Path, value: &str) -> PathBuf {
581 let path = PathBuf::from(value);
582 if path.is_absolute() {
583 path
584 } else {
585 working_dir.join(path)
586 }
587}
588
589fn forwarded_flags(arguments: &[String], flags: &[&str]) -> Vec<String> {
590 let mut forwarded = Vec::new();
591 let mut remaining = arguments.iter();
592 while let Some(argument) = remaining.next() {
593 if let Some((flag, value)) = argument
594 .split_once('=')
595 .filter(|(flag, _)| flags.contains(flag))
596 {
597 forwarded.extend([flag.to_string(), value.to_string()]);
598 } else if flags.contains(&argument.as_str()) {
599 if let Some(value) = remaining.next() {
600 forwarded.extend([argument.clone(), value.clone()]);
601 }
602 } else if flags
603 .iter()
604 .any(|flag| flag.len() == 2 && argument.len() > 2 && argument.starts_with(flag))
605 {
606 forwarded.push(argument.clone());
607 }
608 }
609 forwarded
610}
611
612fn invocation_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
613 flag_value(arguments, "-C")
614 .map(|value| absolute(working_dir, value))
615 .unwrap_or_else(|| working_dir.to_path_buf())
616}
617
618fn directory_option_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
621 flag_value(arguments, "-C")
622 .or_else(|| flag_value(arguments, "--directory"))
623 .or_else(|| {
624 arguments
625 .iter()
626 .find_map(|arg| arg.strip_prefix("-C").filter(|value| !value.is_empty()))
627 })
628 .map(|value| absolute(working_dir, value))
629 .unwrap_or_else(|| working_dir.to_path_buf())
630}
631
632pub fn manifest_in_scope(arguments: &[String], working_dir: &Path) -> bool {
639 let arguments = cargo_arguments(arguments);
640 let invocation = directory_option_dir(arguments, working_dir);
641 if let Some(source) = path_install_dir(arguments, working_dir) {
648 return source.join("Cargo.toml").is_file();
649 }
650 match flag_value(arguments, "--manifest-path") {
651 Some(manifest) => absolute(&invocation, manifest).is_file(),
652 None => invocation
653 .ancestors()
654 .any(|directory| directory.join("Cargo.toml").is_file()),
655 }
656}
657
658fn path_install_dir(arguments: &[String], working_dir: &Path) -> Option<PathBuf> {
661 let arguments = cargo_arguments(arguments);
662 let (_, command) = subcommand(arguments)?;
663 (command == "install")
664 .then(|| {
665 flag_value(arguments, "--path")
666 .map(|path| absolute(&directory_option_dir(arguments, working_dir), path))
667 })
668 .flatten()
669}
670
671fn subcommand(arguments: &[String]) -> Option<(usize, &str)> {
673 let mut remaining = arguments.iter().enumerate();
674 while let Some((index, argument)) = remaining.next() {
675 match argument.as_str() {
676 "--color" | "--config" | "-Z" | "-C" | "--directory" => {
677 remaining.next()?;
678 }
679 value if !value.starts_with('-') && !value.starts_with('+') => {
680 return Some((index, value));
681 }
682 _ => {}
683 }
684 }
685 None
686}
687
688pub fn is_remote_install(arguments: &[String]) -> bool {
695 let arguments = cargo_arguments(arguments);
696 subcommand(arguments).is_some_and(|(_, command)| command == "install")
697 && flag_value(arguments, "--path").is_none()
698 && !arguments.iter().any(|argument| argument == "--list")
699}
700
701pub fn remote_install_target_dir(
705 arguments: &[String],
706 working_dir: &Path,
707 target_dir_env: Option<&OsStr>,
708) -> Option<PathBuf> {
709 let invocation = directory_option_dir(cargo_arguments(arguments), working_dir);
710 target_dir_argument(cargo_arguments(arguments))
711 .map(|value| absolute(&invocation, value))
712 .or_else(|| {
713 target_dir_env
714 .filter(|value| !value.is_empty())
715 .map(|value| absolute(&invocation, &value.to_string_lossy()))
716 })
717}
718
719pub fn remote_install_name(arguments: &[String]) -> String {
724 const VALUE_FLAGS: [&str; 24] = [
725 "--version",
726 "--git",
727 "--branch",
728 "--tag",
729 "--rev",
730 "--root",
731 "--registry",
732 "--index",
733 "--features",
734 "-F",
735 "--bin",
736 "--example",
737 "--profile",
738 "--target",
739 "--target-dir",
740 "-j",
741 "--jobs",
742 "--config",
743 "-Z",
744 "--color",
745 "--message-format",
746 "--directory",
747 "-C",
748 "--path",
749 ];
750 let arguments = cargo_arguments(arguments);
751 let start = subcommand(arguments).map_or(arguments.len(), |(index, _)| index + 1);
752 let mut packages = Vec::new();
753 let mut remaining = arguments[start..].iter();
754 while let Some(argument) = remaining.next() {
755 if VALUE_FLAGS.contains(&argument.as_str()) {
756 remaining.next();
757 } else if !argument.starts_with('-') {
758 packages.push(argument.as_str());
759 }
760 }
761 let identity = canonical_json(&(
762 &packages,
763 flag_value(arguments, "--git"),
764 flag_value(arguments, "--version"),
765 ))
766 .unwrap_or_default();
767 let label: String = packages
768 .first()
769 .copied()
770 .unwrap_or("git")
771 .chars()
772 .map(|c| {
773 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
774 c
775 } else {
776 '_'
777 }
778 })
779 .take(64)
780 .collect();
781 let hash = CacheDigest::blake3(&identity).hash;
782 format!("{label}-{}", &hash[..12.min(hash.len())])
783}
784
785fn rebase_cli_include(value: &str, caller: &Path) -> String {
788 fn rebase(value: &mut toml::Value, caller: &Path) {
789 match value {
790 toml::Value::String(path) => {
791 *path = absolute(caller, path).to_string_lossy().into_owned();
792 }
793 toml::Value::Array(entries) => {
794 for entry in entries {
795 rebase(entry, caller);
796 }
797 }
798 toml::Value::Table(entry) => {
799 if let Some(toml::Value::String(path)) = entry.get_mut("path") {
800 *path = absolute(caller, path).to_string_lossy().into_owned();
801 }
802 }
803 _ => {}
804 }
805 }
806 if let Ok(mut config) = toml::from_str::<toml::Value>(value)
807 && config.as_table().is_some_and(|table| table.len() == 1)
808 && let Some(include) = config.get_mut("include")
809 {
810 rebase(include, caller);
811 return format!("include = {include}");
812 }
813 value.to_owned()
814}
815
816fn recalled_cargo_roots(
827 cache: Option<&Path>,
828 cargo_home: Option<&Path>,
829 cargo: &OsStr,
830 arguments: &[String],
831 working_dir: &Path,
832 target_dir_env: Option<&OsStr>,
833) -> Result<(PathBuf, PathBuf, Option<PathBuf>), ProbeFailure> {
834 if let Some(source) = path_install_dir(arguments, working_dir) {
835 let caller = directory_option_dir(arguments, working_dir);
839 let mut probe_args = Vec::new();
840 if let Some(target) =
841 std::env::var_os("CARGO_BUILD_TARGET_DIR").filter(|value| !value.is_empty())
842 {
843 probe_args.extend([
846 "--config".into(),
847 format!(
848 "build.target-dir = {}",
849 toml::Value::String(
850 absolute(&caller, &target.to_string_lossy())
851 .to_string_lossy()
852 .into_owned()
853 )
854 ),
855 ]);
856 }
857 for value in config_arguments(arguments) {
858 let value = if value.contains('=') {
859 rebase_cli_include(value, &caller)
860 } else {
861 absolute(&caller, value).to_string_lossy().into_owned()
862 };
863 probe_args.extend(["--config".into(), value.clone()]);
864 if let Ok(config) = toml::from_str::<toml::Value>(&value)
867 && let Some(target) = config
868 .get("build")
869 .and_then(|build| build.get("target-dir"))
870 .and_then(toml::Value::as_str)
871 {
872 probe_args.extend([
873 "--config".into(),
874 format!(
875 "build.target-dir = {}",
876 toml::Value::String(
877 absolute(&caller, target).to_string_lossy().into_owned()
878 )
879 ),
880 ]);
881 }
882 }
883 probe_args.extend(forwarded_flags(arguments, &["-Z"]));
884 probe_args.push("build".into());
885 probe_args.extend(
886 arguments
887 .iter()
888 .filter(|arg| PROBE_MANIFEST_TOGGLES.contains(&arg.as_str()))
889 .cloned(),
890 );
891 let target = target_dir_env
892 .filter(|value| !value.is_empty())
893 .map(|value| absolute(&caller, &value.to_string_lossy()).into_os_string());
894 return recalled_cargo_roots(
895 cache,
896 cargo_home,
897 cargo,
898 &probe_args,
899 &source,
900 target.as_deref(),
901 );
902 }
903 let probe = cache.and_then(|cache| {
904 ProbeRecord::describe(
905 cache,
906 cargo_home,
907 cargo,
908 arguments,
909 working_dir,
910 target_dir_env,
911 )
912 });
913 if let Some(recalled) = probe.as_ref().and_then(ProbeRecord::recall) {
914 return Ok(recalled);
915 }
916 let roots = cargo_roots(cargo, arguments, working_dir, target_dir_env)?;
917 if let Some(probe) = probe {
918 probe.remember(&roots);
919 }
920 Ok(roots)
921}
922
923const PROBE_RECORD_VERSION: u8 = 3;
926
927#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
929struct ProbeRecord {
930 version: u8,
931 key: ProbeKey,
932 pins: Vec<Pin>,
935 #[serde(skip)]
936 path: PathBuf,
937 #[serde(skip)]
939 observed_at: Option<SystemTime>,
940 workspace_root: PathBuf,
941 target_dir: PathBuf,
942 build_dir: Option<PathBuf>,
943}
944
945#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
948struct ProbeKey {
949 cargo: PathBuf,
950 arguments: Vec<String>,
951 working_dir: PathBuf,
952 target_dir_env: Option<String>,
953 build_target_dir_env: Option<String>,
954 build_dir_env: Option<String>,
955 target_dir_argument: Option<String>,
956 cargo_home: Option<String>,
957}
958
959#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
962struct Pin {
963 path: PathBuf,
964 state: Option<(u64, u64, u32)>,
965}
966
967impl Pin {
968 fn describe(path: PathBuf) -> Option<Self> {
971 let state = match std::fs::metadata(&path) {
972 Ok(metadata) => {
973 let modified = metadata
974 .modified()
975 .ok()?
976 .duration_since(SystemTime::UNIX_EPOCH)
977 .ok()?;
978 Some((metadata.len(), modified.as_secs(), modified.subsec_nanos()))
979 }
980 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
981 Err(_) => return None,
982 };
983 Some(Self { path, state })
984 }
985
986 fn holds(&self) -> bool {
987 Pin::describe(self.path.clone()).as_ref() == Some(self)
988 }
989
990 fn is_racy(&self, observed_at: SystemTime) -> bool {
996 let Some((_, seconds, nanos)) = self.state else {
997 return false;
998 };
999 let window = if nanos == 0 {
1000 COARSE_TIMESTAMP_RACE_WINDOW
1001 } else {
1002 FINE_TIMESTAMP_RACE_WINDOW
1003 };
1004 let modified = SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds, nanos);
1005 observed_at
1006 .duration_since(modified)
1007 .map_or(true, |age| age < window)
1008 }
1009}
1010
1011const FINE_TIMESTAMP_RACE_WINDOW: std::time::Duration = std::time::Duration::from_millis(20);
1016
1017const COARSE_TIMESTAMP_RACE_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
1020
1021impl ProbeRecord {
1022 fn describe(
1025 cache: &Path,
1026 cargo_home: Option<&Path>,
1027 cargo: &OsStr,
1028 arguments: &[String],
1029 working_dir: &Path,
1030 target_dir_env: Option<&OsStr>,
1031 ) -> Option<Self> {
1032 let cargo = resolve_program(cargo)?;
1033 let env =
1034 |name: &str| std::env::var_os(name).map(|value| value.to_string_lossy().into_owned());
1035 let key = ProbeKey {
1036 cargo: cargo.clone(),
1037 arguments: probe_arguments(arguments),
1038 working_dir: working_dir.to_path_buf(),
1039 target_dir_env: target_dir_env.map(|value| value.to_string_lossy().into_owned()),
1040 build_target_dir_env: env("CARGO_BUILD_TARGET_DIR"),
1041 build_dir_env: env("CARGO_BUILD_BUILD_DIR"),
1042 target_dir_argument: target_dir_argument(arguments).map(str::to_owned),
1043 cargo_home: cargo_home.map(|home| home.to_string_lossy().into_owned()),
1047 };
1048 let invocation_dir = invocation_dir(arguments, working_dir);
1049 let manifest_start = flag_value(arguments, "--manifest-path")
1054 .map(|manifest| absolute(&invocation_dir, manifest))
1055 .and_then(|manifest| manifest.parent().map(Path::to_path_buf))
1056 .unwrap_or_else(|| invocation_dir.clone());
1057 let mut watched = vec![cargo];
1058 watched.extend(
1059 manifest_start
1060 .ancestors()
1061 .map(|directory| directory.join("Cargo.toml")),
1062 );
1063 for directory in invocation_dir.ancestors() {
1064 let dot_cargo = directory.join(".cargo");
1065 watched.push(dot_cargo.join("config.toml"));
1066 watched.push(dot_cargo.join("config"));
1067 }
1068 if let Some(home) = cargo_home {
1069 watched.push(home.join("config.toml"));
1070 watched.push(home.join("config"));
1071 }
1072 watched.extend(
1073 config_arguments(arguments)
1074 .map(|value| absolute(&invocation_dir, value))
1075 .filter(|path| path.is_file()),
1076 );
1077 if config_arguments(arguments).any(|value| {
1082 value
1083 .split_once('=')
1084 .is_some_and(|(key, _)| key.trim() == "include")
1085 }) || watched
1086 .iter()
1087 .skip(1)
1088 .filter(|path| path.file_name().is_some_and(|name| name != "Cargo.toml"))
1089 .any(|path| config_includes_files(path))
1090 {
1091 return None;
1092 }
1093 let observed_at = SystemTime::now();
1094 let pins = watched
1095 .into_iter()
1096 .map(Pin::describe)
1097 .collect::<Option<Vec<_>>>()?;
1098 let selector = canonical_json(&key).ok()?;
1099 let path = cache
1100 .join("cargo-roots")
1101 .join("v1")
1102 .join(format!("{}.json", CacheDigest::blake3(&selector).hash));
1103 Some(Self {
1104 version: PROBE_RECORD_VERSION,
1105 key,
1106 pins,
1107 path,
1108 observed_at: Some(observed_at),
1109 workspace_root: PathBuf::new(),
1110 target_dir: PathBuf::new(),
1111 build_dir: None,
1112 })
1113 }
1114
1115 fn recall(&self) -> Option<(PathBuf, PathBuf, Option<PathBuf>)> {
1117 let bytes = std::fs::read(&self.path).ok()?;
1118 let recorded: ProbeRecord = serde_json::from_slice(&bytes).ok()?;
1119 if recorded.version != PROBE_RECORD_VERSION
1120 || recorded.key != self.key
1121 || !recorded.pins.iter().all(Pin::holds)
1122 {
1123 return None;
1124 }
1125 Some((
1126 recorded.workspace_root,
1127 recorded.target_dir,
1128 recorded.build_dir,
1129 ))
1130 }
1131
1132 fn remember(mut self, roots: &(PathBuf, PathBuf, Option<PathBuf>)) {
1135 let root_manifest = roots.0.join("Cargo.toml");
1136 if !self.pins.iter().any(|pin| pin.path == root_manifest) {
1137 let Some(pin) = Pin::describe(root_manifest) else {
1138 return;
1139 };
1140 self.pins.push(pin);
1141 }
1142 if let Some(observed_at) = self.observed_at
1147 && self.pins.iter().any(|pin| pin.is_racy(observed_at))
1148 {
1149 return;
1150 }
1151 self.workspace_root = roots.0.clone();
1152 self.target_dir = roots.1.clone();
1153 self.build_dir = roots.2.clone();
1154 let Ok(bytes) = serde_json::to_vec(&self) else {
1155 return;
1156 };
1157 let Some(directory) = self.path.parent() else {
1158 return;
1159 };
1160 if std::fs::create_dir_all(directory).is_err() {
1161 return;
1162 }
1163 let staged = directory.join(format!(
1164 ".{}.{}",
1165 self.path.file_name().unwrap_or_default().to_string_lossy(),
1166 std::process::id()
1167 ));
1168 if std::fs::write(&staged, bytes).is_ok() && std::fs::rename(&staged, &self.path).is_err() {
1169 let _ = std::fs::remove_file(&staged);
1170 }
1171 }
1172}
1173
1174fn config_includes_files(path: &Path) -> bool {
1180 let contents = match std::fs::read_to_string(path) {
1181 Ok(contents) => contents,
1182 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
1183 Err(_) => return true,
1184 };
1185 toml::from_str::<toml::Value>(&contents).is_ok_and(|config| config.get("include").is_some())
1186 || toml::from_str::<toml::Value>(&contents).is_err()
1187}
1188
1189fn probe_arguments(arguments: &[String]) -> Vec<String> {
1192 let mut probe = forwarded_flags(arguments, &PROBE_GLOBAL_FLAGS);
1193 probe.extend(
1194 ["metadata", "--no-deps", "--format-version", "1"]
1195 .iter()
1196 .map(|argument| (*argument).to_string()),
1197 );
1198 if let Some(manifest) = flag_value(arguments, "--manifest-path") {
1199 probe.push("--manifest-path".into());
1200 probe.push(manifest.into());
1201 }
1202 probe.extend(
1203 arguments
1204 .iter()
1205 .filter(|argument| PROBE_MANIFEST_TOGGLES.contains(&argument.as_str()))
1206 .cloned(),
1207 );
1208 probe
1209}
1210
1211fn resolve_program(program: &OsStr) -> Option<PathBuf> {
1213 let candidate = Path::new(program);
1214 if candidate.components().count() > 1 {
1215 return candidate.is_file().then(|| candidate.to_path_buf());
1216 }
1217 let names = if cfg!(windows) {
1218 vec![candidate.as_os_str().to_os_string(), {
1219 let mut exe = candidate.as_os_str().to_os_string();
1220 exe.push(".exe");
1221 exe
1222 }]
1223 } else {
1224 vec![candidate.as_os_str().to_os_string()]
1225 };
1226 std::env::split_paths(&std::env::var_os("PATH")?)
1227 .flat_map(|directory| names.iter().map(move |name| directory.join(name)))
1228 .find(|path| path.is_file())
1229}
1230
1231fn cargo_roots(
1232 cargo: &OsStr,
1233 arguments: &[String],
1234 working_dir: &Path,
1235 target_dir_env: Option<&OsStr>,
1236) -> Result<(PathBuf, PathBuf, Option<PathBuf>), ProbeFailure> {
1237 let mut command = Command::new(cargo);
1238 match target_dir_argument(arguments)
1239 .map(OsStr::new)
1240 .or(target_dir_env)
1241 {
1242 Some(value) => command.env(CARGO_TARGET_DIR_ENV, value),
1243 None => command.env_remove(CARGO_TARGET_DIR_ENV),
1244 };
1245 command
1246 .current_dir(working_dir)
1247 .args(probe_arguments(arguments));
1248 let output = command.output().map_err(ProbeFailure::Io)?;
1249 log::debug!(
1250 "cargo metadata stderr: {}",
1251 String::from_utf8_lossy(&output.stderr)
1252 );
1253 if !output.status.success() {
1254 return Err(ProbeFailure::Exit {
1255 status: output.status,
1256 stderr: stderr_tail(&output.stderr),
1257 });
1258 }
1259 parse_metadata(&output.stdout)
1260}
1261
1262fn parse_metadata(bytes: &[u8]) -> Result<(PathBuf, PathBuf, Option<PathBuf>), ProbeFailure> {
1263 let metadata: serde_json::Value = serde_json::from_slice(bytes).map_err(ProbeFailure::Json)?;
1264 let required = |field: &'static str| {
1265 metadata
1266 .get(field)
1267 .and_then(|value| value.as_str())
1268 .map(PathBuf::from)
1269 .ok_or(ProbeFailure::Schema(field))
1270 };
1271 Ok((
1272 required("workspace_root")?,
1273 required("target_directory")?,
1274 metadata
1275 .get("build_directory")
1276 .and_then(|value| value.as_str())
1277 .map(PathBuf::from),
1278 ))
1279}
1280
1281fn stderr_tail(bytes: &[u8]) -> String {
1282 let stderr = String::from_utf8_lossy(bytes);
1283 let lines: Vec<_> = stderr
1284 .lines()
1285 .filter(|line| !line.trim().is_empty())
1286 .rev()
1287 .take(5)
1288 .collect();
1289 let tail = lines.into_iter().rev().collect::<Vec<_>>().join("\n");
1290 let mut start = tail.len().saturating_sub(1024);
1291 while !tail.is_char_boundary(start) {
1292 start += 1;
1293 }
1294 tail[start..].to_owned()
1295}
1296
1297fn target_dir_argument(arguments: &[String]) -> Option<&str> {
1298 flag_value(arguments, "--target-dir")
1299}
1300
1301fn cargo_config_may_set_target_dir(arguments: &[String], invocation_dir: &Path) -> bool {
1302 if std::env::var_os("CARGO_BUILD_TARGET_DIR").is_some_and(|value| !value.is_empty()) {
1303 return true;
1304 }
1305 if config_arguments(arguments).any(|value| {
1306 value
1307 .split_once('=')
1308 .is_none_or(|(key, _)| matches!(key.trim(), "build.target-dir" | "include"))
1309 }) {
1310 return true;
1311 }
1312 let project = invocation_dir.ancestors().flat_map(|directory| {
1313 let cargo = directory.join(".cargo");
1314 [cargo.join("config.toml"), cargo.join("config")]
1315 });
1316 let home = std::env::var_os("CARGO_HOME")
1317 .map(PathBuf::from)
1318 .or_else(|| dirs::home_dir().map(|root| root.join(".cargo")))
1319 .into_iter()
1320 .flat_map(|cargo| [cargo.join("config.toml"), cargo.join("config")]);
1321 project.chain(home).any(|path| config_may_set_target(&path))
1322}
1323
1324fn config_arguments(arguments: &[String]) -> impl Iterator<Item = &str> {
1325 let mut values = Vec::new();
1326 let mut remaining = arguments.iter();
1327 while let Some(argument) = remaining.next() {
1328 if let Some(value) = argument.strip_prefix("--config=") {
1329 values.push(value);
1330 } else if argument == "--config"
1331 && let Some(value) = remaining.next()
1332 {
1333 values.push(value);
1334 }
1335 }
1336 values.into_iter()
1337}
1338
1339fn config_may_set_target(path: &Path) -> bool {
1340 let contents = match std::fs::read_to_string(path) {
1341 Ok(contents) => contents,
1342 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
1343 Err(_) => return true,
1344 };
1345 let Ok(config) = toml::from_str::<toml::Value>(&contents) else {
1346 return true;
1347 };
1348 config
1349 .get("build")
1350 .and_then(|build| build.get("target-dir"))
1351 .is_some()
1352 || config.get("include").is_some()
1353}
1354
1355fn flag_value<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
1356 let joined = format!("{flag}=");
1357 let mut arguments = arguments.iter();
1358 while let Some(argument) = arguments.next() {
1359 if let Some(value) = argument.strip_prefix(&joined) {
1360 return Some(value);
1361 }
1362 if argument == flag {
1363 return arguments.next().map(String::as_str);
1364 }
1365 }
1366 None
1367}
1368
1369fn workspace_root(start: &Path) -> PathBuf {
1370 start
1371 .ancestors()
1372 .find(|directory| directory.join("Cargo.toml").is_file())
1373 .unwrap_or(start)
1374 .to_path_buf()
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380
1381 fn cargo_fixture() -> tempfile::TempDir {
1382 let directory = tempfile::tempdir().unwrap();
1383 std::fs::write(
1384 directory.path().join("Cargo.toml"),
1385 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1386 )
1387 .unwrap();
1388 std::fs::create_dir_all(directory.path().join("src")).unwrap();
1389 std::fs::write(directory.path().join("src/lib.rs"), "").unwrap();
1390 directory
1391 }
1392
1393 fn fixture_root(path: &Path) -> PathBuf {
1394 if cfg!(windows) {
1396 path.to_path_buf()
1397 } else {
1398 path.canonicalize().unwrap()
1399 }
1400 }
1401
1402 #[test]
1403 fn a_manifest_is_in_scope_only_where_cargo_would_read_one() {
1404 let outside = tempfile::tempdir().unwrap();
1405 let outside = outside.path();
1406 let arguments = |value: &str| {
1407 value
1408 .split_whitespace()
1409 .map(str::to_owned)
1410 .collect::<Vec<_>>()
1411 };
1412 assert!(!manifest_in_scope(&arguments("binstall"), outside));
1413 assert!(!manifest_in_scope(&arguments("build"), outside));
1414
1415 let project = cargo_fixture();
1416 let project = project.path();
1417 let nested = project.join("src");
1418 assert!(manifest_in_scope(&arguments("binstall"), project));
1419 assert!(manifest_in_scope(&arguments("binstall"), &nested));
1422
1423 assert!(manifest_in_scope(
1425 &[
1426 "-C".into(),
1427 project.to_string_lossy().into_owned(),
1428 "binstall".into()
1429 ],
1430 outside
1431 ));
1432 assert!(!manifest_in_scope(
1433 &[
1434 "--directory".into(),
1435 outside.to_string_lossy().into_owned(),
1436 "binstall".into()
1437 ],
1438 project
1439 ));
1440
1441 assert!(manifest_in_scope(
1444 &[
1445 "binstall".into(),
1446 "--manifest-path".into(),
1447 "Cargo.toml".into()
1448 ],
1449 project
1450 ));
1451 assert!(!manifest_in_scope(
1452 &["binstall".into(), "--manifest-path=src/Cargo.toml".into()],
1453 project
1454 ));
1455
1456 assert!(manifest_in_scope(
1461 &[
1462 "install".into(),
1463 "--path".into(),
1464 project.to_string_lossy().into_owned()
1465 ],
1466 outside
1467 ));
1468 assert!(!manifest_in_scope(
1469 &[
1470 "generate".into(),
1471 "--path".into(),
1472 project.to_string_lossy().into_owned()
1473 ],
1474 outside
1475 ));
1476 assert!(!manifest_in_scope(
1477 &[
1478 "install".into(),
1479 "--path".into(),
1480 outside.join("absent").to_string_lossy().into_owned()
1481 ],
1482 outside
1483 ));
1484 assert!(!manifest_in_scope(
1486 &[
1487 "run".into(),
1488 "--".into(),
1489 "--path".into(),
1490 project.to_string_lossy().into_owned()
1491 ],
1492 outside
1493 ));
1494 }
1495
1496 #[test]
1497 fn lockfile_makes_identity_independent_of_checkout_path() {
1498 let left = tempfile::tempdir().unwrap();
1499 let right = tempfile::tempdir().unwrap();
1500 std::fs::write(left.path().join("Cargo.lock"), "same").unwrap();
1501 std::fs::write(right.path().join("Cargo.lock"), "same").unwrap();
1502 let command = vec!["build".to_string(), "--workspace".to_string()];
1503 assert_eq!(
1504 build_identity(left.path(), &command),
1505 build_identity(right.path(), &command)
1506 );
1507 }
1508
1509 #[test]
1510 fn cargo_commands_and_toolchain_selectors_share_predictions() {
1511 let project = tempfile::tempdir().unwrap();
1512 std::fs::write(project.path().join("Cargo.lock"), "same").unwrap();
1513
1514 let build = vec!["build".to_string()];
1515 let test = vec!["test".to_string(), "--workspace".to_string()];
1516 let msrv = vec!["+1.91".to_string(), "check".to_string()];
1517
1518 assert_eq!(
1519 build_identity(project.path(), &build),
1520 build_identity(project.path(), &test),
1521 );
1522 assert_eq!(
1523 build_identity(project.path(), &build),
1524 build_identity(project.path(), &msrv),
1525 );
1526 }
1527
1528 #[test]
1529 fn rustc_flags_after_separator_are_not_cargo_globals() {
1530 let args = vec![
1531 "rustc".into(),
1532 "--".into(),
1533 "-C".into(),
1534 "opt-level=3".into(),
1535 ];
1536 assert_eq!(
1537 invocation_dir(cargo_arguments(&args), Path::new("/work")),
1538 PathBuf::from("/work")
1539 );
1540 }
1541
1542 #[test]
1543 fn target_directory_flags_and_environment_are_recorded_as_explicit() {
1544 let directory = cargo_fixture();
1545 let root = directory.path();
1546 let cargo = OsStr::new("cargo-that-does-not-exist");
1547 let plain = ["build".to_string()];
1548
1549 let default = resolve(cargo, &plain, root, None);
1550 assert_eq!(default.target_dir, root.join("target"));
1551 assert!(!default.target_dir_requested);
1552
1553 for arguments in [
1554 vec!["build".into(), "--target-dir=target".into()],
1555 vec!["build".into(), "--target-dir".into(), "target".into()],
1556 ] {
1557 let resolved = resolve(cargo, &arguments, root, None);
1558 assert_eq!(resolved.target_dir, root.join("target"));
1559 assert!(resolved.target_dir_requested);
1560 }
1561
1562 let from_environment = resolve(cargo, &plain, root, Some("elsewhere".into()));
1563 assert_eq!(from_environment.target_dir, root.join("elsewhere"));
1564 assert!(from_environment.target_dir_requested);
1565
1566 let empty_environment = resolve(cargo, &plain, root, Some("".into()));
1567 assert!(!empty_environment.target_dir_requested);
1568
1569 let dangling = ["build".into(), "--target-dir".into()];
1570 assert!(!resolve(cargo, &dangling, root, None).target_dir_requested);
1571 }
1572
1573 #[test]
1574 fn project_config_that_names_the_default_target_is_still_explicit() {
1575 let directory = cargo_fixture();
1576 let root = directory.path();
1577 std::fs::create_dir_all(root.join(".cargo")).unwrap();
1578 std::fs::write(
1579 root.join(".cargo/config.toml"),
1580 "[build]\ntarget-dir = \"target\"\n",
1581 )
1582 .unwrap();
1583
1584 let resolved = resolve(
1585 OsStr::new("cargo-that-does-not-exist"),
1586 &["build".into()],
1587 root,
1588 None,
1589 );
1590
1591 assert_eq!(resolved.target_dir, root.join("target"));
1592 assert!(resolved.target_dir_requested);
1593 }
1594
1595 #[test]
1596 fn command_line_config_include_may_set_the_target_directory() {
1597 let directory = cargo_fixture();
1598 let arguments = [
1599 "build".into(),
1600 "--config".into(),
1601 "include='target-config.toml'".into(),
1602 ];
1603
1604 let resolved = resolve(
1605 OsStr::new("cargo-that-does-not-exist"),
1606 &arguments,
1607 directory.path(),
1608 None,
1609 );
1610
1611 assert!(resolved.target_dir_requested);
1612 }
1613
1614 #[test]
1615 fn command_line_target_config_reaches_the_cargo_probe() {
1616 let directory = cargo_fixture();
1617 let root = directory.path();
1618 let configured = root.join("configured-target");
1619 let arguments = [
1620 "build".into(),
1621 "--offline".into(),
1622 "--manifest-path".into(),
1623 root.join("Cargo.toml").display().to_string(),
1624 "--config".into(),
1625 format!("build.target-dir='{}'", configured.display()),
1626 ];
1627
1628 let resolved =
1629 resolve_reported_in(None, OsStr::new("cargo"), &arguments, root, None).unwrap();
1630
1631 assert_eq!(resolved.target_dir, configured);
1632 assert!(resolved.target_dir_requested);
1633 }
1634
1635 #[test]
1636 fn remote_installs_are_installs_without_a_path_or_listing() {
1637 let remote = |words: &[&str]| {
1638 is_remote_install(
1639 &words
1640 .iter()
1641 .map(|word| word.to_string())
1642 .collect::<Vec<_>>(),
1643 )
1644 };
1645 assert!(remote(&["install", "ripgrep"]));
1646 assert!(remote(&[
1647 "+stable",
1648 "install",
1649 "--git",
1650 "https://example.com/r"
1651 ]));
1652 assert!(remote(&[
1653 "--color",
1654 "always",
1655 "install",
1656 "--version",
1657 "1.0.0",
1658 "x"
1659 ]));
1660 assert!(!remote(&["install", "--path", "."]));
1661 assert!(!remote(&["install", "--path=."]));
1662 assert!(!remote(&["install", "--list"]));
1663 assert!(!remote(&["build"]));
1664 assert!(!remote(&["-C", "install", "build"]));
1665 assert!(!remote(&["run", "--", "install", "x"]));
1666 }
1667
1668 #[test]
1669 fn remote_install_target_is_the_one_the_caller_named() {
1670 let words = |words: &[&str]| {
1671 words
1672 .iter()
1673 .map(|word| word.to_string())
1674 .collect::<Vec<_>>()
1675 };
1676 let cwd = Path::new("/work");
1677 assert_eq!(
1678 remote_install_target_dir(&words(&["install", "x"]), cwd, None),
1679 None
1680 );
1681 assert_eq!(
1682 remote_install_target_dir(&words(&["install", "x", "--target-dir", "out"]), cwd, None),
1683 Some(PathBuf::from("/work/out"))
1684 );
1685 assert_eq!(
1686 remote_install_target_dir(&words(&["install", "x"]), cwd, Some(OsStr::new("/env"))),
1687 Some(PathBuf::from("/env"))
1688 );
1689 assert_eq!(
1690 remote_install_target_dir(
1691 &words(&["install", "x", "--target-dir=flag"]),
1692 cwd,
1693 Some(OsStr::new("/env"))
1694 ),
1695 Some(PathBuf::from("/work/flag"))
1696 );
1697 assert_eq!(
1698 remote_install_target_dir(&words(&["install", "x"]), cwd, Some(OsStr::new(""))),
1699 None
1700 );
1701 }
1702
1703 #[test]
1704 fn remote_install_name_ignores_options_that_do_not_change_the_package() {
1705 let name = |words: &[&str]| {
1706 remote_install_name(
1707 &words
1708 .iter()
1709 .map(|word| word.to_string())
1710 .collect::<Vec<_>>(),
1711 )
1712 };
1713 let plain = name(&["install", "helloworld-rs"]);
1714 assert!(plain.starts_with("helloworld-rs-"));
1715 assert_eq!(
1716 plain,
1717 name(&[
1718 "install",
1719 "--root",
1720 "out",
1721 "--force",
1722 "helloworld-rs",
1723 "--locked"
1724 ])
1725 );
1726 assert_ne!(plain, name(&["install", "helloworld-rs", "--version", "2"]));
1727 assert_ne!(plain, name(&["install", "other"]));
1728 assert!(name(&["install", "--git", "https://example.com/r"]).starts_with("git-"));
1729 assert_ne!(
1730 name(&["install", "--git", "https://example.com/a"]),
1731 name(&["install", "--git", "https://example.com/b"])
1732 );
1733 assert!(!name(&["install", "../evil/x"]).contains('/'));
1734 }
1735
1736 #[test]
1737 fn path_install_probes_source_config_and_preserves_caller_relative_targets() {
1738 let source = cargo_fixture();
1739 let caller = cargo_fixture();
1740 let source_root = fixture_root(source.path());
1741 let caller_root = fixture_root(caller.path());
1742 for (root, target) in [
1743 (source_root.as_path(), "source-target"),
1744 (caller_root.as_path(), "caller-target"),
1745 ] {
1746 std::fs::create_dir_all(root.join(".cargo")).unwrap();
1747 std::fs::write(
1748 root.join(".cargo/config.toml"),
1749 format!("[build]\ntarget-dir = '{target}'\n"),
1750 )
1751 .unwrap();
1752 }
1753 let arguments = vec![
1754 "install".into(),
1755 format!("--path={}", source_root.as_path().display()),
1756 "--offline".into(),
1757 ];
1758 let cache = tempfile::tempdir().unwrap();
1759 let resolve = |args: &[String], target| {
1760 resolve_reported_in(
1761 Some(cache.path()),
1762 OsStr::new("cargo"),
1763 args,
1764 caller_root.as_path(),
1765 target,
1766 )
1767 .unwrap()
1768 };
1769 let roots = resolve(&arguments, None);
1770 assert_eq!(roots.workspace_root, source_root.as_path());
1771 assert_eq!(
1772 roots.target_dir,
1773 source_root.as_path().join("source-target")
1774 );
1775 assert!(roots.target_dir_requested);
1776
1777 let mut flagged = arguments.clone();
1778 flagged.extend(["--target-dir".into(), "flag-target".into()]);
1779 assert_eq!(
1780 resolve(&flagged, None).target_dir,
1781 caller_root.as_path().join("flag-target")
1782 );
1783 assert_eq!(
1784 resolve(&arguments, Some("env-target".into())).target_dir,
1785 caller_root.as_path().join("env-target")
1786 );
1787 let mut configured = arguments.clone();
1788 configured.extend(["--config".into(), "build.target-dir='cli-target'".into()]);
1789 assert_eq!(
1790 resolve(&configured, None).target_dir,
1791 caller_root.as_path().join("cli-target")
1792 );
1793
1794 std::fs::write(
1796 source_root.as_path().join(".cargo/config.toml"),
1797 "[build]\ntarget-dir = 'changed-source-target'\n",
1798 )
1799 .unwrap();
1800 assert_eq!(
1801 resolve(&arguments, None).target_dir,
1802 source_root.as_path().join("changed-source-target")
1803 );
1804 }
1805
1806 #[test]
1807 fn path_install_directory_flags_rebase_the_source_and_target() {
1808 let source = cargo_fixture();
1809 let source_root = fixture_root(source.path());
1810 let caller = source_root.parent().unwrap();
1811 let directory = source_root.file_name().unwrap().to_str().unwrap();
1812 for flags in [
1813 vec!["--directory".into(), directory.into()],
1814 vec![format!("--directory={directory}")],
1815 vec!["-C".into(), directory.into()],
1816 vec![format!("-C{directory}")],
1817 ] {
1818 let mut arguments = flags;
1819 arguments.extend(
1820 [
1821 "install",
1822 "--path",
1823 ".",
1824 "--target-dir",
1825 "shared",
1826 "--offline",
1827 ]
1828 .map(str::to_owned),
1829 );
1830 let roots =
1831 resolve_reported_in(None, OsStr::new("cargo"), &arguments, caller, None).unwrap();
1832 assert_eq!(roots.workspace_root, source_root);
1833 assert_eq!(roots.target_dir, source_root.join("shared"));
1834 assert!(roots.target_dir_requested);
1835 }
1836 }
1837
1838 #[test]
1839 fn path_install_cli_includes_keep_caller_paths_and_override_order() {
1840 let source = cargo_fixture();
1841 let caller = cargo_fixture();
1842 let source_root = fixture_root(source.path());
1843 let caller_root = fixture_root(caller.path());
1844 for (root, target) in [
1845 (&caller_root, "caller-target"),
1846 (&source_root, "wrong-target"),
1847 ] {
1848 std::fs::create_dir_all(root.join(".config")).unwrap();
1849 std::fs::write(
1850 root.join(".config/extra.toml"),
1851 format!("[build]\ntarget-dir = '{target}'\n"),
1852 )
1853 .unwrap();
1854 }
1855 let resolve = |overrides: &[&str]| {
1856 let mut args = vec![
1857 "install".into(),
1858 "--path".into(),
1859 source_root.display().to_string(),
1860 "--offline".into(),
1861 ];
1862 for value in overrides {
1863 args.extend(["--config".into(), (*value).into()]);
1864 }
1865 resolve_reported_in(None, OsStr::new("cargo"), &args, &caller_root, None)
1866 };
1867 for include in [
1868 "include=['.config/extra.toml']",
1869 "include=[{path='.config/extra.toml'}, {path='.config/missing.toml', optional=true}]",
1870 ] {
1871 assert_eq!(
1872 resolve(&[include]).unwrap().target_dir,
1873 caller_root.join("caller-target")
1874 );
1875 assert_eq!(
1876 resolve(&[include, "build.target-dir='override'"])
1877 .unwrap()
1878 .target_dir,
1879 caller_root.join("override")
1880 );
1881 }
1882 let absolute_include = format!(
1883 "include=[{}]",
1884 toml::Value::String(caller_root.join(".config/extra.toml").display().to_string())
1885 );
1886 assert_eq!(
1887 resolve(&[&absolute_include]).unwrap().target_dir,
1888 caller_root.join("caller-target")
1889 );
1890 assert!(resolve(&["include=['.config/missing.toml']"]).is_none());
1891
1892 assert!(resolve(&["include=42"]).is_none());
1894 }
1895
1896 #[cfg(unix)]
1898 fn logging_cargo(directory: &Path, project: &Path, log: &Path) -> PathBuf {
1899 use std::os::unix::fs::PermissionsExt as _;
1900 let script = directory.join("cargo");
1901 std::fs::write(
1902 &script,
1903 format!(
1904 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\ncase \" $* \" in *' metadata '*) printf '{{\"workspace_root\":\"{root}\",\"target_directory\":\"{root}/target\",\"packages\":[]}}';; esac\n",
1905 log = log.display(),
1906 root = project.display(),
1907 ),
1908 )
1909 .unwrap();
1910 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1911 script
1912 }
1913
1914 #[cfg(unix)]
1918 fn build_dir_cargo(directory: &Path, project: &Path, log: &Path) -> PathBuf {
1919 use std::os::unix::fs::PermissionsExt as _;
1920 let script = directory.join("cargo");
1921 std::fs::write(
1922 &script,
1923 format!(
1924 "#!/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",
1925 log = log.display(),
1926 root = project.display(),
1927 ),
1928 )
1929 .unwrap();
1930 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1931 script
1932 }
1933
1934 #[cfg(unix)]
1935 fn probes(log: &Path) -> usize {
1936 std::fs::read_to_string(log)
1937 .unwrap_or_default()
1938 .lines()
1939 .filter(|line| line.contains("metadata"))
1940 .count()
1941 }
1942
1943 fn settle() {
1948 let probe = tempfile::NamedTempFile::new().unwrap();
1949 let coarse = probe
1950 .as_file()
1951 .metadata()
1952 .and_then(|metadata| metadata.modified())
1953 .ok()
1954 .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
1955 .is_none_or(|since| since.subsec_nanos() == 0);
1956 std::thread::sleep(if coarse {
1957 COARSE_TIMESTAMP_RACE_WINDOW + FINE_TIMESTAMP_RACE_WINDOW
1958 } else {
1959 FINE_TIMESTAMP_RACE_WINDOW * 2
1960 });
1961 }
1962
1963 #[cfg(unix)]
1966 fn stable_cargo(directory: &Path, project: &Path, log: &Path) -> PathBuf {
1967 use std::os::unix::fs::PermissionsExt as _;
1968 let script = directory.join("cargo");
1969 std::fs::write(
1970 &script,
1971 format!(
1972 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\ncase \" $* \" in *' -Z'*|*' -C'*) exit 101;; esac\ncase \" $* \" in *' metadata '*) printf '{{\"workspace_root\":\"{root}\",\"target_directory\":\"{root}/target\",\"packages\":[]}}';; esac\n",
1973 log = log.display(),
1974 root = project.display(),
1975 ),
1976 )
1977 .unwrap();
1978 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1979 script
1980 }
1981
1982 #[test]
1983 #[cfg(unix)]
1984 fn flags_after_an_external_subcommand_stay_out_of_the_probe() {
1985 let directory = cargo_fixture();
1986 let root = directory.path();
1987 let scratch = tempfile::tempdir().unwrap();
1988 let log = scratch.path().join("log");
1989 let cargo = stable_cargo(scratch.path(), root, &log);
1990 let resolve = |words: &[&str]| {
1991 let arguments = words
1992 .iter()
1993 .map(|word| (*word).to_owned())
1994 .collect::<Vec<_>>();
1995 resolve_reported_in(None, cargo.as_os_str(), &arguments, root, None)
1996 };
1997
1998 for words in [
1999 &["foo", "-Z", "x"][..],
2000 &["foo", "-Zx"],
2001 &["foo", "-C", "dir"],
2002 &["foo", "autoharness", "-Z", "autoharness"],
2003 &["+stable", "foo", "-Z", "x"],
2004 ] {
2005 let resolved = resolve(words).unwrap_or_else(|| panic!("{words:?} was refused"));
2006 assert_eq!(resolved.workspace_root, root, "{words:?}");
2007 assert_eq!(resolved.target_dir, root.join("target"), "{words:?}");
2009 }
2010
2011 assert!(resolve(&["-Z", "x", "foo"]).is_none());
2014 assert!(resolve(&["build", "-Z", "x"]).is_none());
2016 assert!(resolve(&["build", "-C", "dir"]).is_none());
2017 }
2018
2019 #[test]
2020 #[cfg(unix)]
2021 fn flags_after_an_alias_or_external_subcommand_still_reach_a_probe_that_accepts_them() {
2022 let directory = cargo_fixture();
2023 let root = directory.path();
2024 let scratch = tempfile::tempdir().unwrap();
2025 let log = scratch.path().join("log");
2026 let cargo = logging_cargo(scratch.path(), root, &log);
2027 let arguments = ["b", "--config", "net.offline=true"].map(str::to_owned);
2028 resolve_reported_in(None, cargo.as_os_str(), &arguments, root, None).unwrap();
2029 let logged = std::fs::read_to_string(&log).unwrap();
2030 assert!(
2031 logged.contains("--config net.offline=true"),
2032 "an alias is Cargo's to parse: {logged}"
2033 );
2034 assert_eq!(probes(&log), 1, "{logged}");
2035 }
2036
2037 #[test]
2038 fn reported_intermediates_survive_resolution_and_cached_records() {
2039 let directory = cargo_fixture();
2040 let root = directory.path();
2041 let cache = tempfile::tempdir().unwrap();
2042 let arguments = ["build".to_owned()];
2043 let roots = (
2044 root.to_path_buf(),
2045 root.join("target"),
2046 Some(root.join("intermediates")),
2047 );
2048 let resolved = resolve_with_reported(&arguments, root, None, Some(roots.clone()));
2049 assert_eq!(resolved.build_dir, roots.2);
2050 let describe = || {
2051 ProbeRecord::describe(
2052 cache.path(),
2053 None,
2054 OsStr::new("cargo"),
2055 &arguments,
2056 root,
2057 None,
2058 )
2059 .unwrap()
2060 };
2061 settle();
2062 describe().remember(&roots);
2063 assert_eq!(describe().recall(), Some(roots.clone()));
2064 let old_cargo =
2065 resolve_with_reported(&arguments, root, None, Some((roots.0, roots.1, None)));
2066 assert_eq!(old_cargo.build_dir, None);
2067 }
2068
2069 #[cfg(unix)]
2071 const BUILD_DIR_PROBE_CHILD: &str = "MBX_CARGO_BUILD_DIR_PROBE_CHILD";
2072
2073 #[test]
2084 #[cfg(unix)]
2085 fn a_probed_build_directory_is_parsed_recalled_and_rekeyed() {
2086 if std::env::var_os(BUILD_DIR_PROBE_CHILD).is_none() {
2087 let status = Command::new(std::env::current_exe().unwrap())
2088 .args([
2089 "--exact",
2090 "--nocapture",
2091 "--test-threads=1",
2092 "tests::a_probed_build_directory_is_parsed_recalled_and_rekeyed",
2093 ])
2094 .env(BUILD_DIR_PROBE_CHILD, "1")
2095 .env_remove("CARGO_BUILD_BUILD_DIR")
2096 .env_remove("CARGO_TARGET_DIR")
2097 .env_remove("CARGO_BUILD_TARGET_DIR")
2098 .status()
2099 .unwrap();
2100 assert!(status.success(), "the child run of this test failed");
2101 return;
2102 }
2103
2104 let directory = cargo_fixture();
2105 let root = directory.path();
2106 let cache = tempfile::tempdir().unwrap();
2107 let log = root.join("cargo.log");
2108 let cargo = build_dir_cargo(root, root, &log);
2109 let home = root.join("cargo-home");
2110 let resolve = |arguments: &[String]| {
2111 resolve_reported_from_home(
2112 Some(cache.path()),
2113 Some(&home),
2114 cargo.as_os_str(),
2115 arguments,
2116 root,
2117 None,
2118 )
2119 .unwrap()
2120 };
2121 let build = ["build".to_string()];
2122 settle();
2123
2124 let first = resolve(&build);
2126 assert_eq!(first.build_dir, Some(root.join("target/build-dir")));
2127 assert_eq!(probes(&log), 1);
2128
2129 assert_eq!(resolve(&build).build_dir, first.build_dir);
2131 assert_eq!(probes(&log), 1);
2132
2133 let elsewhere = root.join("elsewhere");
2135 unsafe { std::env::set_var("CARGO_BUILD_BUILD_DIR", &elsewhere) };
2137 assert_eq!(resolve(&build).build_dir, Some(elsewhere));
2138 assert_eq!(probes(&log), 2);
2139 unsafe { std::env::remove_var("CARGO_BUILD_BUILD_DIR") };
2141 assert_eq!(resolve(&build).build_dir, first.build_dir);
2142 assert_eq!(probes(&log), 2);
2143
2144 let other_target = root.join("other-target");
2147 let targeted = [
2148 "build".to_string(),
2149 "--target-dir".to_string(),
2150 other_target.display().to_string(),
2151 ];
2152 let with_target = resolve(&targeted);
2153 assert_eq!(with_target.build_dir, Some(other_target.join("build-dir")));
2154 assert_eq!(probes(&log), 3);
2155 assert_eq!(resolve(&targeted).build_dir, with_target.build_dir);
2156 assert_eq!(resolve(&build).build_dir, first.build_dir);
2157 assert_eq!(probes(&log), 3);
2158 }
2159
2160 #[test]
2161 #[cfg(unix)]
2162 fn a_probe_is_remembered_while_what_cargo_read_stands() {
2163 let directory = cargo_fixture();
2164 let root = directory.path();
2165 let cache = tempfile::tempdir().unwrap();
2166 let log = root.join("cargo.log");
2167 let cargo = logging_cargo(root, root, &log);
2168 let arguments = ["build".to_string(), "--locked".to_string()];
2169 let home = root.join("cargo-home");
2170 let resolve = |target_dir_env: Option<&str>| {
2171 resolve_reported_from_home(
2172 Some(cache.path()),
2173 Some(&home),
2174 cargo.as_os_str(),
2175 &arguments,
2176 root,
2177 target_dir_env.map(OsString::from),
2178 )
2179 .unwrap()
2180 };
2181 settle();
2182
2183 let first = resolve(None);
2184 assert_eq!(first.workspace_root, root);
2185 assert_eq!(probes(&log), 1);
2186 assert_eq!(resolve(None), first);
2188 assert_eq!(probes(&log), 1);
2189
2190 std::fs::write(
2192 root.join("Cargo.toml"),
2193 "[package]\nname = \"fixture\"\nversion = \"0.2.0\"\nedition = \"2021\"\n",
2194 )
2195 .unwrap();
2196 settle();
2197 assert_eq!(resolve(None), first);
2198 assert_eq!(probes(&log), 2);
2199 assert_eq!(resolve(None), first);
2200 assert_eq!(probes(&log), 2);
2201
2202 std::fs::create_dir_all(root.join(".cargo")).unwrap();
2204 std::fs::write(root.join(".cargo/config.toml"), "[build]\njobs = 2\n").unwrap();
2205 settle();
2206 assert_eq!(resolve(None), first);
2207 assert_eq!(probes(&log), 3);
2208
2209 assert!(resolve(Some("elsewhere")).target_dir_requested);
2211 assert_eq!(probes(&log), 4);
2212 assert_eq!(resolve(None), first);
2213 assert_eq!(probes(&log), 4);
2214
2215 let other_home = root.join("other-home");
2218 let from_other_home = resolve_reported_from_home(
2219 Some(cache.path()),
2220 Some(&other_home),
2221 cargo.as_os_str(),
2222 &arguments,
2223 root,
2224 None,
2225 )
2226 .unwrap();
2227 assert_eq!(from_other_home.workspace_root, first.workspace_root);
2228 assert_eq!(probes(&log), 5);
2229 std::fs::create_dir_all(&other_home).unwrap();
2230 std::fs::write(other_home.join("config.toml"), "[build]\njobs = 4\n").unwrap();
2231 resolve_reported_from_home(
2232 Some(cache.path()),
2233 Some(&other_home),
2234 cargo.as_os_str(),
2235 &arguments,
2236 root,
2237 None,
2238 )
2239 .unwrap();
2240 assert_eq!(probes(&log), 6);
2241 assert_eq!(resolve(None), first);
2242 assert_eq!(probes(&log), 6);
2243
2244 std::fs::write(
2247 root.join(".cargo/config.toml"),
2248 "include = \"other.toml\"\n",
2249 )
2250 .unwrap();
2251 settle();
2252 assert_eq!(resolve(None).workspace_root, first.workspace_root);
2253 assert_eq!(resolve(None).workspace_root, first.workspace_root);
2254 assert_eq!(probes(&log), 8);
2255 }
2256
2257 #[test]
2258 #[cfg(unix)]
2259 fn a_config_file_named_on_the_command_line_is_pinned() {
2260 let directory = cargo_fixture();
2261 let root = directory.path();
2262 let cache = tempfile::tempdir().unwrap();
2263 let log = root.join("cargo.log");
2264 let cargo = logging_cargo(root, root, &log);
2265 let extra = root.join("extra.toml");
2266 std::fs::write(&extra, "[build]\njobs = 2\n").unwrap();
2267 let arguments = [
2268 "build".to_string(),
2269 "--config".to_string(),
2270 extra.display().to_string(),
2271 ];
2272 let resolve = || {
2273 resolve_reported_in(
2274 Some(cache.path()),
2275 cargo.as_os_str(),
2276 &arguments,
2277 root,
2278 None,
2279 )
2280 .unwrap()
2281 };
2282 settle();
2283
2284 let first = resolve();
2285 assert_eq!(resolve(), first);
2286 assert_eq!(probes(&log), 1);
2287 std::fs::write(&extra, "[build]\njobs = 3\n").unwrap();
2289 settle();
2290 assert_eq!(resolve(), first);
2291 assert_eq!(resolve(), first);
2292 assert_eq!(probes(&log), 2);
2293 std::fs::write(&extra, "include = \"more.toml\"\n").unwrap();
2295 settle();
2296 resolve();
2297 resolve();
2298 assert_eq!(probes(&log), 4);
2299 }
2300
2301 #[test]
2306 #[cfg(unix)]
2307 fn a_probe_taken_within_a_timestamp_tick_of_a_write_is_not_remembered() {
2308 use std::os::unix::fs::PermissionsExt as _;
2309 let directory = cargo_fixture();
2310 let root = directory.path();
2311 let cache = tempfile::tempdir().unwrap();
2312 let log = root.join("cargo.log");
2313 let extra = root.join("extra.toml");
2314 let cargo = root.join("cargo");
2317 std::fs::write(
2318 &cargo,
2319 format!(
2320 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\ncase \" $* \" in *' metadata '*) target=$(sed -n 's/^target-dir = \"\\(.*\\)\"$/\\1/p' {extra}); printf '{{\"workspace_root\":\"{root}\",\"target_directory\":\"%s\",\"packages\":[]}}' \"$target\";; esac\n",
2321 log = log.display(),
2322 extra = extra.display(),
2323 root = root.display(),
2324 ),
2325 )
2326 .unwrap();
2327 std::fs::set_permissions(&cargo, std::fs::Permissions::from_mode(0o755)).unwrap();
2328 let config = |target: &Path| format!("[build]\ntarget-dir = \"{}\"\n", target.display());
2329 let (before, after) = (root.join("aaa"), root.join("bbb"));
2330 let arguments = [
2331 "build".to_string(),
2332 "--config".to_string(),
2333 extra.display().to_string(),
2334 ];
2335 let resolve = || {
2336 resolve_reported_in(
2337 Some(cache.path()),
2338 cargo.as_os_str(),
2339 &arguments,
2340 root,
2341 None,
2342 )
2343 .unwrap()
2344 .target_dir
2345 };
2346 let stamp = |at: SystemTime| {
2347 std::fs::File::options()
2348 .write(true)
2349 .open(&extra)
2350 .unwrap()
2351 .set_modified(at)
2352 .unwrap();
2353 };
2354 let ahead = SystemTime::now() + std::time::Duration::from_secs(3600);
2357 settle();
2358
2359 std::fs::write(&extra, config(&before)).unwrap();
2360 stamp(ahead);
2361 assert_eq!(resolve(), before);
2362 std::fs::write(&extra, config(&after)).unwrap();
2363 stamp(ahead);
2364 assert_eq!(resolve(), after);
2365 assert_eq!(probes(&log), 2);
2366
2367 stamp(SystemTime::now() - std::time::Duration::from_secs(3600));
2369 assert_eq!(resolve(), after);
2370 assert_eq!(resolve(), after);
2371 assert_eq!(probes(&log), 3);
2372 }
2373
2374 #[test]
2375 fn a_pin_is_racy_within_its_filesystems_timestamp_tick() {
2376 let at = |seconds, nanos| SystemTime::UNIX_EPOCH + std::time::Duration::new(seconds, nanos);
2377 let pin = |seconds, nanos| Pin {
2378 path: PathBuf::new(),
2379 state: Some((0, seconds, nanos)),
2380 };
2381 let now = at(1_000, 500_000_000);
2382 assert!(pin(1_000, 495_000_000).is_racy(now));
2383 assert!(!pin(1_000, 470_000_000).is_racy(now));
2384 assert!(pin(1_000, 600_000_000).is_racy(now));
2386 assert!(pin(999, 0).is_racy(now));
2388 assert!(!pin(998, 0).is_racy(now));
2389 assert!(
2391 !Pin {
2392 path: PathBuf::new(),
2393 state: None,
2394 }
2395 .is_racy(now)
2396 );
2397 }
2398
2399 #[test]
2400 #[cfg(unix)]
2401 fn a_failed_probe_is_not_remembered() {
2402 use std::os::unix::fs::PermissionsExt as _;
2403 let directory = cargo_fixture();
2404 let root = directory.path();
2405 let cache = tempfile::tempdir().unwrap();
2406 let script = root.join("cargo");
2407 std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
2408 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2409 let arguments = ["build".to_string()];
2410
2411 assert!(
2412 resolve_reported_in(
2413 Some(cache.path()),
2414 script.as_os_str(),
2415 &arguments,
2416 root,
2417 None
2418 )
2419 .is_none()
2420 );
2421 assert!(!cache.path().join("cargo-roots").exists());
2422 }
2423
2424 #[test]
2425 fn reported_resolution_requires_a_successful_metadata_probe() {
2426 let directory = cargo_fixture();
2427 assert!(
2428 resolve_reported(
2429 OsStr::new("cargo-that-does-not-exist"),
2430 &["build".into()],
2431 directory.path(),
2432 None,
2433 )
2434 .is_none()
2435 );
2436 }
2437
2438 #[test]
2439 fn missing_cargo_reports_the_io_error() {
2440 let directory = tempfile::tempdir().unwrap();
2441 let missing = directory.path().join("missing-cargo");
2442 let error = try_resolve_reported(
2443 missing.as_os_str(),
2444 &["build".into()],
2445 directory.path(),
2446 None,
2447 )
2448 .unwrap_err();
2449 assert!(matches!(error, ProbeFailure::Io(_)));
2450 assert!(error.to_string().contains("I/O error"));
2451 assert!(std::error::Error::source(&error).is_some());
2452 }
2453
2454 #[test]
2455 fn invalid_metadata_reports_the_json_error() {
2456 let error = parse_metadata(b"not json").unwrap_err();
2457 assert!(matches!(error, ProbeFailure::Json(_)));
2458 assert!(error.to_string().contains("invalid cargo metadata JSON"));
2459 assert!(std::error::Error::source(&error).is_some());
2460 }
2461
2462 #[test]
2463 fn metadata_requires_string_roots() {
2464 for (metadata, field) in [
2465 (r#"{}"#, "workspace_root"),
2466 (r#"{"workspace_root":3}"#, "workspace_root"),
2467 (r#"{"workspace_root":"/project"}"#, "target_directory"),
2468 (
2469 r#"{"workspace_root":"/project","target_directory":null}"#,
2470 "target_directory",
2471 ),
2472 ] {
2473 let error = parse_metadata(metadata.as_bytes()).unwrap_err();
2474 assert!(matches!(error, ProbeFailure::Schema(name) if name == field));
2475 assert!(
2476 error
2477 .to_string()
2478 .contains(&format!("{field} missing or not a string"))
2479 );
2480 }
2481 }
2482
2483 #[test]
2484 fn stderr_tail_is_bounded_and_skips_empty_lines() {
2485 assert_eq!(
2486 stderr_tail(b"one\n\ntwo\nthree\nfour\n \nfive\nsix\n"),
2487 "two\nthree\nfour\nfive\nsix"
2488 );
2489 let text = "é".repeat(2000);
2490 let tail = stderr_tail(text.as_bytes());
2491 assert!(tail.len() <= 1024);
2492 assert!(text.ends_with(&tail));
2493 assert_eq!(stderr_tail(b"\n \n"), "");
2494 assert!(stderr_tail(b"invalid \xff").contains('�'));
2495 }
2496
2497 #[test]
2498 #[cfg(unix)]
2499 fn unsuccessful_cargo_reports_status_and_stderr_without_caching_failure() {
2500 use std::os::unix::fs::PermissionsExt as _;
2501 let directory = cargo_fixture();
2502 let root = directory.path();
2503 let cache = tempfile::tempdir().unwrap();
2504 let script = root.join("cargo");
2505 let log = root.join("probes");
2506 std::fs::write(&script, format!("#!/bin/sh\necho metadata >> '{}'\nprintf 'old\\n1\\n2\\n3\\n4\\n\\ncomponent download failed\\n' >&2\nexit 17\n", log.display())).unwrap();
2507 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2508 for _ in 0..2 {
2509 let error = try_resolve_reported_from_home(
2510 Some(cache.path()),
2511 None,
2512 script.as_os_str(),
2513 &["build".into()],
2514 root,
2515 None,
2516 )
2517 .unwrap_err();
2518 assert!(matches!(error, ProbeFailure::Exit { .. }));
2519 let message = error.to_string();
2520 assert!(message.contains("exit status: 17"), "{message}");
2521 assert!(message.contains("component download failed"), "{message}");
2522 assert!(!message.contains("old"), "{message}");
2523 }
2524 assert_eq!(probes(&log), 2);
2525 assert!(!cache.path().join("cargo-roots").exists());
2526 }
2527
2528 fn git(directory: &Path, arguments: &[&str]) {
2529 let status = Command::new("git")
2530 .arg("-C")
2531 .arg(directory)
2532 .args(["-c", "commit.gpgsign=false", "-c", "user.name=t"])
2533 .args(["-c", "user.email=t@example.com"])
2534 .args(arguments)
2535 .stdout(std::process::Stdio::null())
2536 .stderr(std::process::Stdio::null())
2537 .status()
2538 .unwrap();
2539 assert!(status.success(), "git {arguments:?} failed");
2540 }
2541
2542 #[test]
2543 fn earlier_lockfile_states_are_offered_newest_first() {
2544 let directory = tempfile::tempdir().unwrap();
2545 let root = directory.path();
2546 let command = Vec::new();
2547 git(root, &["init", "-q"]);
2548 let mut committed = Vec::new();
2549 for state in ["one", "two", "three"] {
2550 std::fs::write(root.join("Cargo.lock"), state).unwrap();
2551 committed.push(build_identity(root, &command));
2552 git(root, &["add", "Cargo.lock"]);
2553 git(root, &["commit", "-q", "-m", state]);
2554 }
2555 committed.reverse();
2556
2557 std::fs::write(root.join("Cargo.lock"), "four").unwrap();
2559 assert!(!committed.contains(&build_identity(root, &command)));
2560 assert_eq!(previous_build_identities(root), committed);
2561
2562 git(root, &["commit", "-q", "-am", "four"]);
2565 assert_eq!(previous_build_identities(root), committed);
2566 }
2567
2568 #[test]
2569 fn a_crlf_checkout_matches_the_lf_states_in_history() {
2570 let directory = tempfile::tempdir().unwrap();
2571 let root = directory.path();
2572 let command = Vec::new();
2573 git(root, &["init", "-q"]);
2574 let mut committed = Vec::new();
2575 for state in ["one\n", "two\n"] {
2576 std::fs::write(root.join("Cargo.lock"), state).unwrap();
2577 committed.push(build_identity(root, &command));
2578 git(root, &["add", "Cargo.lock"]);
2579 git(root, &["commit", "-q", "-m", state]);
2580 }
2581 std::fs::write(root.join("Cargo.lock"), "two\r\n").unwrap();
2583 assert_eq!(build_identity(root, &command), committed[1]);
2584 let raw = |state: &[u8]| identity_for_workspace(&CacheDigest::blake3(state).hash);
2588 assert_eq!(
2589 previous_build_identities(root),
2590 vec![raw(b"two\r\n"), committed[0].clone(), raw(b"one\r\n")]
2591 );
2592 }
2593
2594 #[test]
2595 fn crlf_states_in_history_also_offer_their_earlier_identity() {
2596 let directory = tempfile::tempdir().unwrap();
2597 let root = directory.path();
2598 let command = Vec::new();
2599 git(root, &["init", "-q"]);
2600 git(root, &["config", "core.autocrlf", "false"]);
2601 std::fs::write(root.join(".gitattributes"), "Cargo.lock -text\n").unwrap();
2603 for state in ["one\r\n", "two\r\n"] {
2604 std::fs::write(root.join("Cargo.lock"), state).unwrap();
2605 git(root, &["add", "Cargo.lock"]);
2606 git(root, &["commit", "-q", "-m", state]);
2607 }
2608 let raw = |state: &[u8]| identity_for_workspace(&CacheDigest::blake3(state).hash);
2609 std::fs::write(root.join("Cargo.lock"), "one\n").unwrap();
2612 let one = build_identity(root, &command);
2613 std::fs::write(root.join("Cargo.lock"), "two\r\n").unwrap();
2614 assert_eq!(
2615 previous_build_identities(root),
2616 vec![raw(b"two\r\n"), one, raw(b"one\r\n")]
2617 );
2618 }
2619
2620 #[test]
2621 fn lockfile_markers_ignore_only_crlf_line_endings() {
2622 assert_eq!(lockfile_marker(b"a\r\nb\r\n"), lockfile_marker(b"a\nb\n"));
2623 assert_ne!(lockfile_marker(b"a\rb"), lockfile_marker(b"ab"));
2624 assert_eq!(lockfile_marker(b"a\nb"), CacheDigest::blake3(b"a\nb").hash);
2625 assert_eq!(crlf_checkout(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n");
2626 }
2627
2628 #[test]
2629 fn a_shallow_clone_offers_the_states_it_can_reach() {
2630 let origin = tempfile::tempdir().unwrap();
2631 let root = origin.path();
2632 let command = Vec::new();
2633 git(root, &["init", "-q"]);
2634 let mut committed = Vec::new();
2635 for state in ["one", "two", "three", "four"] {
2636 std::fs::write(root.join("Cargo.lock"), state).unwrap();
2637 committed.push(build_identity(root, &command));
2638 git(root, &["add", "Cargo.lock"]);
2639 git(root, &["commit", "-q", "-m", state]);
2640 }
2641 let clones = tempfile::tempdir().unwrap();
2642 let shallow = clones.path().join("shallow");
2643 git(
2644 clones.path(),
2645 &[
2646 "clone",
2647 "-q",
2648 "--depth",
2649 "2",
2650 &format!("file://{}", root.display()),
2651 shallow.to_str().unwrap(),
2652 ],
2653 );
2654 assert_eq!(
2656 previous_build_identities(&shallow),
2657 vec![committed[2].clone()]
2658 );
2659 }
2660
2661 #[test]
2662 fn an_untracked_lockfile_has_no_history_to_borrow_from() {
2663 let directory = tempfile::tempdir().unwrap();
2664 let root = directory.path();
2665 git(root, &["init", "-q"]);
2666 std::fs::write(root.join("Cargo.lock"), "tracked").unwrap();
2667 git(root, &["add", "Cargo.lock"]);
2668 git(root, &["commit", "-q", "-m", "tracked"]);
2669 git(root, &["rm", "-q", "Cargo.lock"]);
2670 git(root, &["commit", "-q", "-m", "removed"]);
2671 std::fs::write(root.join("Cargo.lock"), "generated").unwrap();
2673 assert!(previous_build_identities(root).is_empty());
2674 }
2675
2676 #[test]
2677 fn a_workspace_outside_version_control_has_no_earlier_states() {
2678 let directory = cargo_fixture();
2679 std::fs::write(directory.path().join("Cargo.lock"), "one").unwrap();
2680 assert!(previous_build_identities(directory.path()).is_empty());
2681 std::fs::write(directory.path().join("Cargo.lock"), "one\r\n").unwrap();
2683 assert_eq!(
2684 previous_build_identities(directory.path()),
2685 vec![identity_for_workspace(
2686 &CacheDigest::blake3(b"one\r\n").hash
2687 )]
2688 );
2689 }
2690}