Skip to main content

mbx_cache_cargo/
lib.rs

1//! Cargo invocation resolution shared by mbx and embedded cache clients.
2//!
3//! This crate is intentionally unstable while the first embedders converge.
4//! Breaking changes are made in a new pre-1.0 minor release.
5#![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/// Cargo-resolved roots and the stable prediction-manifest identity for one invocation.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CargoInvocation {
22    /// Cargo's resolved workspace root.
23    pub workspace_root: PathBuf,
24    /// Cargo's resolved output directory.
25    pub target_dir: PathBuf,
26    /// Cargo's intermediate build directory, when reported by this Cargo version.
27    pub build_dir: Option<PathBuf>,
28    /// Whether a flag, environment value, or Cargo configuration explicitly selected the target.
29    pub target_dir_requested: bool,
30    /// Cross-checkout identity used to select the action-prediction manifest.
31    pub build_identity: String,
32}
33
34/// Resolve one Cargo invocation exactly enough for a cache session.
35pub 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
69/// Resolve one Cargo invocation only when Cargo successfully reports its roots.
70///
71/// Persistent Cargo shims use this form to distinguish a usable build from an
72/// invocation they should pass through, while retaining the successful
73/// metadata result for the session instead of probing twice.
74pub 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
106/// The directory Cargo reads its home configuration from: `CARGO_HOME`, or
107/// `.cargo` under the home directory when it is unset.
108fn 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
194/// Derive the prediction-manifest identity used by mbx for a Cargo command.
195///
196/// The command is accepted for API compatibility but deliberately does not
197/// enter the identity. rustc invocation digests distinguish profiles,
198/// features, targets, and compiler toolchains; keeping the surrounding Cargo
199/// command here would prevent equivalent dependency compilations from sharing
200/// predictions across `build`, `test`, and Clippy.
201pub fn build_identity(workspace_root: &Path, _command: &[String]) -> String {
202    identity_for_workspace(&workspace_marker(workspace_root))
203}
204
205/// The identities recorded for earlier states of this workspace's
206/// `Cargo.lock`, newest first.
207///
208/// The identity is the lockfile's digest, so a dependency bump starts a
209/// manifest with nothing in it although most of the graph is unchanged and
210/// its results are already cached. Version control remembers what the lockfile
211/// was before: the committed copy first, for an edit that has not been
212/// committed yet, then the copy in `HEAD`'s first parent, which on a pull
213/// request's merge commit is the base branch, then the copy each commit that
214/// touched the file replaced. A shallow clone offers what it can reach, which
215/// on a `fetch-depth: 1` checkout is nothing beyond `HEAD` itself. A checkout
216/// that is not under Git, or a lockfile it does not track, yields nothing.
217/// Every prediction a manifest holds is rehashed before it is trusted, so an
218/// inherited one can only fail to match, never restore the wrong result.
219pub 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    // History belongs to the tracked file. A lockfile Cargo generated after a
224    // tracked one was deleted has none, whatever the deleted one's was.
225    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    // A commit's own copy of the lockfile is what its successor replaced, so
241    // the parent of each commit that touched the file is one state further
242    // back; a root commit has no parent and is skipped.
243    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
269/// How many earlier lockfile states are offered as prediction sources.
270///
271/// Each one costs a manifest lookup when it is consulted, which only happens
272/// once nothing was recorded under the current identity, and a run of
273/// dependency bumps rarely goes deeper than this before a build of the trunk
274/// records the newest state.
275const PREVIOUS_LOCKFILE_STATES: usize = 8;
276
277/// Git's standard output for a command run in `workspace_root`, or nothing
278/// when Git is absent or the command fails.
279fn 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
291/// The manifest identity for one workspace marker on this platform.
292fn 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
316/// Resolve the shared mbx cache root from the environment, machine config, or platform default.
317pub 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
376// Path installs change the probe's cwd, and manifest discovery starts from
377// wherever Cargo was pointed, so resolve every directory-option spelling.
378fn 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
390/// Whether Cargo has a manifest to work from here.
391///
392/// Without one there is no package to compile, no target directory to place,
393/// and nothing for a probe to report, so a metadata failure means only that
394/// Cargo was run outside a project. Cargo's own diagnostic is then the useful
395/// one, and the caller can hand the invocation straight to it.
396pub 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    // `install --path` compiles the manifest in the directory it names, which
400    // it has even where the invocation directory has none, and does not walk
401    // up to a surrounding workspace for it. Only a real install gives `--path`
402    // that meaning: other subcommands use the same flag for their own
403    // purposes, such as the local template `cargo generate --path` reads.
404    // Callers expand aliases first, so an aliased install arrives spelled out.
405    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
416// Only a real install subcommand gives --path this meaning; option values
417// and arguments after -- must not make an unrelated command look like one.
418fn 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
441// Inline includes use cwd, unlike includes inside a config file. Rewrite the
442// override itself: appending another include would concatenate array entries.
443fn 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
472/// The roots a `cargo metadata` probe reports, remembered under `cache`.
473///
474/// The probe is a Cargo process per build, and it costs more than the shim
475/// work around a hot compile once the rest has been trimmed. Its answer is a
476/// function of things this crate can watch: the Cargo binary, the manifests
477/// and configuration files Cargo reads on the way from the invocation
478/// directory to the root, and the environment that selects a target
479/// directory. A record stands while every one of those is as the probing
480/// run saw it, including the ones that were absent; anything else, or no
481/// cache to remember in, runs the probe.
482fn 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        // `install --path` reads configuration from the source directory,
492        // whereas metadata normally reads it from the caller's directory.
493        // Probe there, retaining caller-relative CLI paths and target overrides.
494        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            // This environment setting outranks files but is itself overridden
500            // by --config; insert it before the caller's explicit overrides.
501            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            // Inline target paths also stay relative to the caller. Preserve
521            // any other settings in this override and the order of overrides.
522            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/// Everything a probe's answer was a function of, and the answer.
582#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
583struct ProbeRecord {
584    version: u8,
585    key: ProbeKey,
586    /// The files Cargo consulted, present or absent, as they were when the
587    /// probe ran. The root manifest joins them once the probe has named it.
588    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/// The inputs that select a record: an identical key with intact pins is
597/// the same question, so it gets the same answer.
598#[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/// A file as the probe found it: absent, or present with a length and
611/// modification time. Length alone would miss an edit that kept the size.
612#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
613struct Pin {
614    path: PathBuf,
615    state: Option<(u64, u64, u32)>,
616}
617
618impl Pin {
619    /// Describe `path`, or nothing when the filesystem cannot say enough
620    /// about it to notice a change.
621    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    /// Describe the probe about to run, or nothing when some input cannot be
644    /// pinned, in which case the probe runs and is not remembered.
645    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            // The directory itself rather than the variable: with the
665            // variable unset it follows the home directory, and a record
666            // made under one home must not answer under another.
667            cargo_home: cargo_home.map(|home| home.to_string_lossy().into_owned()),
668        };
669        let invocation_dir = invocation_dir(arguments, working_dir);
670        // Cargo finds the manifest nearest the invocation directory, or the
671        // one named outright, and walks up from there for the workspace. Its
672        // configuration it reads from every `.cargo` above the invocation
673        // directory itself, wherever the manifest is, and from home last.
674        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        // A configuration file may `include` others this list does not
699        // name, and a command-line `include=` does the same. Their target
700        // directory cannot be pinned, so it is not remembered. A `--config`
701        // naming a file is on the list above and read like the rest.
702        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    /// The answer an earlier probe left for this key, if its pins all hold.
735    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    /// Leave the answer behind for the next invocation. Best-effort: a
752    /// record that cannot be written costs the next build a probe.
753    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
784/// Whether a Cargo configuration file names others through `include`.
785///
786/// A file that cannot be read or parsed is treated as though it did: the
787/// question is whether the probe can be pinned, and a file this cannot see
788/// into is one it cannot pin.
789fn 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
799/// The probe command's arguments, in the order it runs them: Cargo's global
800/// flags, then `metadata`, then the options that pick the manifest.
801fn 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
821/// Where `program` is, the way `Command::new` would find it.
822fn 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        // Cargo omits Windows verbatim prefixes; Unix may have aliases (/var).
971        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        // Cargo walks up for the nearest manifest, so a subdirectory of a
996        // package is inside it.
997        assert!(manifest_in_scope(&arguments("binstall"), &nested));
998
999        // A directory option moves the search, either way.
1000        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        // `--manifest-path` names the manifest outright rather than starting
1018        // a search, so a missing one is out of scope even inside a package.
1019        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        // `install --path` names a manifest the invocation directory does not
1033        // have. Callers expand aliases before asking, so only a real install
1034        // gives `--path` that meaning here; other subcommands spend the same
1035        // flag on their own arguments and must not be read as path installs.
1036        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        // Arguments after `--` belong to the built program, not to Cargo.
1061        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        // A warm metadata cache must watch the installed source's config.
1270        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        // Keep unsupported Cargo values invalid instead of repairing them.
1368        assert!(resolve(&["include=42"]).is_none());
1369    }
1370
1371    /// A stand-in Cargo that answers `metadata` and logs every call.
1372    #[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    /// A stand-in Cargo whose reported directories follow what it is given,
1390    /// so a record recalled for the wrong inputs is visibly wrong rather than
1391    /// accidentally right.
1392    #[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    /// The environment variable that names the child run of the test below.
1450    #[cfg(unix)]
1451    const BUILD_DIR_PROBE_CHILD: &str = "MBX_CARGO_BUILD_DIR_PROBE_CHILD";
1452
1453    /// Take a reported build directory through the whole probe path: Cargo
1454    /// reports it, resolution carries it, and the record answers for it. The
1455    /// sibling test above hands the roots in by hand, so it cannot tell
1456    /// whether `cargo metadata` output is read at all, nor whether the
1457    /// inputs that select a build directory select a record. Here the
1458    /// stand-in Cargo derives what it reports from `CARGO_BUILD_BUILD_DIR`
1459    /// and `CARGO_TARGET_DIR`, so a record recalled for the wrong inputs
1460    /// reports the wrong directory instead of the right one by chance. The
1461    /// body runs in a child copy of the test binary because it sets process
1462    /// environment, which is not safe beside tests reading it in parallel.
1463    #[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        // What Cargo reported is what resolution carries.
1504        let first = resolve(&build);
1505        assert_eq!(first.build_dir, Some(root.join("target/build-dir")));
1506        assert_eq!(probes(&log), 1);
1507
1508        // The same question again is answered from the record, not Cargo.
1509        assert_eq!(resolve(&build).build_dir, first.build_dir);
1510        assert_eq!(probes(&log), 1);
1511
1512        // The environment that selects a build directory is part of the key.
1513        let elsewhere = root.join("elsewhere");
1514        // SAFETY: this child process runs this test and no other.
1515        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        // SAFETY: as above.
1519        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        // So is a target directory named on the command line, which the
1524        // probe never sees among its own arguments.
1525        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        // The same question again is answered from the record.
1565        assert_eq!(resolve(None), first);
1566        assert_eq!(probes(&log), 1);
1567
1568        // An edit to a manifest Cargo read runs the probe again.
1569        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        // So does a configuration file appearing where Cargo would look.
1580        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        // The environment that selects a target directory is part of the key.
1586        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        // So is the Cargo home, which follows HOME when CARGO_HOME is unset,
1592        // and whose configuration is watched like the project's.
1593        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        // A configuration that includes files this cannot see is never
1621        // remembered: every build probes.
1622        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        // The file is watched like any other configuration.
1662        std::fs::write(&extra, "[build]\njobs = 3\n").unwrap();
1663        assert_eq!(resolve(), first);
1664        assert_eq!(probes(&log), 2);
1665        // Until it includes something that cannot be.
1666        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        // An uncommitted edit: the committed copy is the nearest earlier state.
1741        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        // Once committed, the working copy matches HEAD and is not offered as
1746        // its own fallback.
1747        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        // HEAD holds "four"; its parent "three" was fetched and "two" was not.
1778        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        // Cargo generated a replacement nobody tracks.
1795        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}