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    /// Whether a flag, environment value, or Cargo configuration explicitly selected the target.
27    pub target_dir_requested: bool,
28    /// Cross-checkout identity used to select the action-prediction manifest.
29    pub build_identity: String,
30}
31
32/// Resolve one Cargo invocation exactly enough for a cache session.
33pub fn resolve(
34    cargo: &OsStr,
35    arguments: &[String],
36    working_dir: &Path,
37    target_dir_env: Option<OsString>,
38) -> CargoInvocation {
39    resolve_in(
40        cache_root().as_deref(),
41        cargo,
42        arguments,
43        working_dir,
44        target_dir_env,
45    )
46}
47
48fn resolve_in(
49    cache: Option<&Path>,
50    cargo: &OsStr,
51    arguments: &[String],
52    working_dir: &Path,
53    target_dir_env: Option<OsString>,
54) -> CargoInvocation {
55    let cargo_args = cargo_arguments(arguments);
56    let reported = recalled_cargo_roots(
57        cache,
58        effective_cargo_home().as_deref(),
59        cargo,
60        cargo_args,
61        working_dir,
62        target_dir_env.as_deref(),
63    );
64    resolve_with_reported(arguments, working_dir, target_dir_env, reported)
65}
66
67/// Resolve one Cargo invocation only when Cargo successfully reports its roots.
68///
69/// Persistent Cargo shims use this form to distinguish a usable build from an
70/// invocation they should pass through, while retaining the successful
71/// metadata result for the session instead of probing twice.
72pub fn resolve_reported(
73    cargo: &OsStr,
74    arguments: &[String],
75    working_dir: &Path,
76    target_dir_env: Option<OsString>,
77) -> Option<CargoInvocation> {
78    resolve_reported_in(
79        cache_root().as_deref(),
80        cargo,
81        arguments,
82        working_dir,
83        target_dir_env,
84    )
85}
86
87fn resolve_reported_in(
88    cache: Option<&Path>,
89    cargo: &OsStr,
90    arguments: &[String],
91    working_dir: &Path,
92    target_dir_env: Option<OsString>,
93) -> Option<CargoInvocation> {
94    resolve_reported_from_home(
95        cache,
96        effective_cargo_home().as_deref(),
97        cargo,
98        arguments,
99        working_dir,
100        target_dir_env,
101    )
102}
103
104/// The directory Cargo reads its home configuration from: `CARGO_HOME`, or
105/// `.cargo` under the home directory when it is unset.
106fn effective_cargo_home() -> Option<PathBuf> {
107    std::env::var_os("CARGO_HOME")
108        .filter(|value| !value.is_empty())
109        .map(PathBuf::from)
110        .or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))
111}
112
113fn resolve_reported_from_home(
114    cache: Option<&Path>,
115    cargo_home: Option<&Path>,
116    cargo: &OsStr,
117    arguments: &[String],
118    working_dir: &Path,
119    target_dir_env: Option<OsString>,
120) -> Option<CargoInvocation> {
121    let reported = recalled_cargo_roots(
122        cache,
123        cargo_home,
124        cargo,
125        cargo_arguments(arguments),
126        working_dir,
127        target_dir_env.as_deref(),
128    )?;
129    Some(resolve_with_reported(
130        arguments,
131        working_dir,
132        target_dir_env,
133        Some(reported),
134    ))
135}
136
137fn resolve_with_reported(
138    arguments: &[String],
139    working_dir: &Path,
140    target_dir_env: Option<OsString>,
141    reported: Option<(PathBuf, PathBuf)>,
142) -> CargoInvocation {
143    let cargo_args = cargo_arguments(arguments);
144    let invocation_dir = invocation_dir(cargo_args, working_dir);
145    let workspace_root = reported
146        .as_ref()
147        .map(|roots| roots.0.clone())
148        .unwrap_or_else(|| workspace_root(&invocation_dir));
149    let flagged = target_dir_argument(cargo_args);
150    let target_dir_requested = flagged.is_some()
151        || target_dir_env
152            .as_ref()
153            .is_some_and(|value| !value.is_empty())
154        || cargo_config_may_set_target_dir(cargo_args, &invocation_dir);
155    let target_dir = flagged
156        .map(|value| absolute(&invocation_dir, value))
157        .or_else(|| reported.map(|roots| roots.1))
158        .or_else(|| {
159            target_dir_env
160                .filter(|value| !value.is_empty())
161                .map(PathBuf::from)
162                .map(|value| absolute(&invocation_dir, &value.to_string_lossy()))
163        })
164        .unwrap_or_else(|| workspace_root.join("target"));
165    let build_identity = build_identity(&workspace_root, arguments);
166    CargoInvocation {
167        workspace_root,
168        target_dir,
169        target_dir_requested,
170        build_identity,
171    }
172}
173
174#[derive(Serialize)]
175struct ActionIdentity<'a> {
176    version: u8,
177    workspace: &'a str,
178    os: &'static str,
179    arch: &'static str,
180}
181
182/// Derive the prediction-manifest identity used by mbx for a Cargo command.
183///
184/// The command is accepted for API compatibility but deliberately does not
185/// enter the identity. rustc invocation digests distinguish profiles,
186/// features, targets, and compiler toolchains; keeping the surrounding Cargo
187/// command here would prevent equivalent dependency compilations from sharing
188/// predictions across `build`, `test`, and Clippy.
189pub fn build_identity(workspace_root: &Path, _command: &[String]) -> String {
190    identity_for_workspace(&workspace_marker(workspace_root))
191}
192
193/// The identities recorded for earlier states of this workspace's
194/// `Cargo.lock`, newest first.
195///
196/// The identity is the lockfile's digest, so a dependency bump starts a
197/// manifest with nothing in it although most of the graph is unchanged and
198/// its results are already cached. Version control remembers what the lockfile
199/// was before: the committed copy first, for an edit that has not been
200/// committed yet, then the copy in `HEAD`'s first parent, which on a pull
201/// request's merge commit is the base branch, then the copy each commit that
202/// touched the file replaced. A shallow clone offers what it can reach, which
203/// on a `fetch-depth: 1` checkout is nothing beyond `HEAD` itself. A checkout
204/// that is not under Git, or a lockfile it does not track, yields nothing.
205/// Every prediction a manifest holds is rehashed before it is trusted, so an
206/// inherited one can only fail to match, never restore the wrong result.
207pub fn previous_build_identities(workspace_root: &Path) -> Vec<String> {
208    let Ok(lock) = std::fs::read(workspace_root.join("Cargo.lock")) else {
209        return Vec::new();
210    };
211    // History belongs to the tracked file. A lockfile Cargo generated after a
212    // tracked one was deleted has none, whatever the deleted one's was.
213    if git_output(
214        workspace_root,
215        &["ls-files", "--error-unmatch", "--", "Cargo.lock"],
216    )
217    .is_none()
218    {
219        return Vec::new();
220    }
221    let limit = PREVIOUS_LOCKFILE_STATES.to_string();
222    let Some(revisions) = git_output(
223        workspace_root,
224        &["rev-list", "-n", &limit, "HEAD", "--", "Cargo.lock"],
225    ) else {
226        return Vec::new();
227    };
228    // A commit's own copy of the lockfile is what its successor replaced, so
229    // the parent of each commit that touched the file is one state further
230    // back; a root commit has no parent and is skipped.
231    let revisions = String::from_utf8_lossy(&revisions);
232    let candidates = ["HEAD", "HEAD^"].into_iter().map(str::to_string).chain(
233        revisions
234            .lines()
235            .map(|revision| format!("{}^", revision.trim())),
236    );
237    let mut seen = BTreeSet::from([CacheDigest::blake3(&lock).hash]);
238    let mut identities = Vec::new();
239    for revision in candidates {
240        let Some(lock) = git_output(
241            workspace_root,
242            &["show", &format!("{revision}:./Cargo.lock")],
243        ) else {
244            continue;
245        };
246        let marker = CacheDigest::blake3(&lock).hash;
247        if seen.insert(marker.clone()) {
248            identities.push(identity_for_workspace(&marker));
249        }
250        if identities.len() == PREVIOUS_LOCKFILE_STATES {
251            break;
252        }
253    }
254    identities
255}
256
257/// How many earlier lockfile states are offered as prediction sources.
258///
259/// Each one costs a manifest lookup when it is consulted, which only happens
260/// once nothing was recorded under the current identity, and a run of
261/// dependency bumps rarely goes deeper than this before a build of the trunk
262/// records the newest state.
263const PREVIOUS_LOCKFILE_STATES: usize = 8;
264
265/// Git's standard output for a command run in `workspace_root`, or nothing
266/// when Git is absent or the command fails.
267fn git_output(workspace_root: &Path, arguments: &[&str]) -> Option<Vec<u8>> {
268    let output = Command::new("git")
269        .arg("-C")
270        .arg(workspace_root)
271        .args(arguments)
272        .stdin(std::process::Stdio::null())
273        .stderr(std::process::Stdio::null())
274        .output()
275        .ok()?;
276    output.status.success().then_some(output.stdout)
277}
278
279/// The manifest identity for one workspace marker on this platform.
280fn identity_for_workspace(workspace: &str) -> String {
281    let bytes = canonical_json(&ActionIdentity {
282        version: 3,
283        workspace,
284        os: std::env::consts::OS,
285        arch: std::env::consts::ARCH,
286    })
287    .expect("Cargo build identity must serialize");
288    CacheDigest::blake3(&bytes).hash
289}
290
291fn workspace_marker(workspace_root: &Path) -> String {
292    std::fs::read(workspace_root.join("Cargo.lock")).map_or_else(
293        |_| {
294            workspace_root
295                .file_name()
296                .unwrap_or_default()
297                .to_string_lossy()
298                .into_owned()
299        },
300        |lock| CacheDigest::blake3(&lock).hash,
301    )
302}
303
304/// Resolve the shared mbx cache root from the environment, machine config, or platform default.
305pub fn cache_root() -> Option<PathBuf> {
306    if let Some(root) = std::env::var_os("MBX_CACHE_DIR").filter(|value| !value.is_empty()) {
307        return Some(PathBuf::from(root));
308    }
309    if let Some(config) = dirs::config_dir().map(|root| root.join("mbx/config.toml"))
310        && let Ok(contents) = std::fs::read_to_string(config)
311        && let Ok(document) = toml::from_str::<toml::Value>(&contents)
312        && let Some(root) = document.get("cache_dir").and_then(toml::Value::as_str)
313    {
314        return Some(PathBuf::from(root));
315    }
316    dirs::cache_dir().map(|root| root.join("mbx"))
317}
318
319fn cargo_arguments(arguments: &[String]) -> &[String] {
320    &arguments[..arguments
321        .iter()
322        .position(|argument| argument == "--")
323        .unwrap_or(arguments.len())]
324}
325
326fn absolute(working_dir: &Path, value: &str) -> PathBuf {
327    let path = PathBuf::from(value);
328    if path.is_absolute() {
329        path
330    } else {
331        working_dir.join(path)
332    }
333}
334
335fn forwarded_flags(arguments: &[String], flags: &[&str]) -> Vec<String> {
336    let mut forwarded = Vec::new();
337    let mut remaining = arguments.iter();
338    while let Some(argument) = remaining.next() {
339        if let Some((flag, value)) = argument
340            .split_once('=')
341            .filter(|(flag, _)| flags.contains(flag))
342        {
343            forwarded.extend([flag.to_string(), value.to_string()]);
344        } else if flags.contains(&argument.as_str()) {
345            if let Some(value) = remaining.next() {
346                forwarded.extend([argument.clone(), value.clone()]);
347            }
348        } else if flags
349            .iter()
350            .any(|flag| flag.len() == 2 && argument.len() > 2 && argument.starts_with(flag))
351        {
352            forwarded.push(argument.clone());
353        }
354    }
355    forwarded
356}
357
358fn invocation_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
359    flag_value(arguments, "-C")
360        .map(|value| absolute(working_dir, value))
361        .unwrap_or_else(|| working_dir.to_path_buf())
362}
363
364/// The roots a `cargo metadata` probe reports, remembered under `cache`.
365///
366/// The probe is a Cargo process per build, and it costs more than the shim
367/// work around a hot compile once the rest has been trimmed. Its answer is a
368/// function of things this crate can watch: the Cargo binary, the manifests
369/// and configuration files Cargo reads on the way from the invocation
370/// directory to the root, and the environment that selects a target
371/// directory. A record stands while every one of those is as the probing
372/// run saw it, including the ones that were absent; anything else, or no
373/// cache to remember in, runs the probe.
374fn recalled_cargo_roots(
375    cache: Option<&Path>,
376    cargo_home: Option<&Path>,
377    cargo: &OsStr,
378    arguments: &[String],
379    working_dir: &Path,
380    target_dir_env: Option<&OsStr>,
381) -> Option<(PathBuf, PathBuf)> {
382    let probe = cache.and_then(|cache| {
383        ProbeRecord::describe(
384            cache,
385            cargo_home,
386            cargo,
387            arguments,
388            working_dir,
389            target_dir_env,
390        )
391    });
392    if let Some(recalled) = probe.as_ref().and_then(ProbeRecord::recall) {
393        return Some(recalled);
394    }
395    let roots = cargo_roots(cargo, arguments, target_dir_env)?;
396    if let Some(probe) = probe {
397        probe.remember(&roots);
398    }
399    Some(roots)
400}
401
402const PROBE_RECORD_VERSION: u8 = 1;
403
404/// Everything a probe's answer was a function of, and the answer.
405#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
406struct ProbeRecord {
407    version: u8,
408    key: ProbeKey,
409    /// The files Cargo consulted, present or absent, as they were when the
410    /// probe ran. The root manifest joins them once the probe has named it.
411    pins: Vec<Pin>,
412    #[serde(skip)]
413    path: PathBuf,
414    workspace_root: PathBuf,
415    target_dir: PathBuf,
416}
417
418/// The inputs that select a record: an identical key with intact pins is
419/// the same question, so it gets the same answer.
420#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
421struct ProbeKey {
422    cargo: PathBuf,
423    arguments: Vec<String>,
424    working_dir: PathBuf,
425    target_dir_env: Option<String>,
426    build_target_dir_env: Option<String>,
427    cargo_home: Option<String>,
428}
429
430/// A file as the probe found it: absent, or present with a length and
431/// modification time. Length alone would miss an edit that kept the size.
432#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
433struct Pin {
434    path: PathBuf,
435    state: Option<(u64, u64, u32)>,
436}
437
438impl Pin {
439    /// Describe `path`, or nothing when the filesystem cannot say enough
440    /// about it to notice a change.
441    fn describe(path: PathBuf) -> Option<Self> {
442        let state = match std::fs::metadata(&path) {
443            Ok(metadata) => {
444                let modified = metadata
445                    .modified()
446                    .ok()?
447                    .duration_since(SystemTime::UNIX_EPOCH)
448                    .ok()?;
449                Some((metadata.len(), modified.as_secs(), modified.subsec_nanos()))
450            }
451            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
452            Err(_) => return None,
453        };
454        Some(Self { path, state })
455    }
456
457    fn holds(&self) -> bool {
458        Pin::describe(self.path.clone()).as_ref() == Some(self)
459    }
460}
461
462impl ProbeRecord {
463    /// Describe the probe about to run, or nothing when some input cannot be
464    /// pinned, in which case the probe runs and is not remembered.
465    fn describe(
466        cache: &Path,
467        cargo_home: Option<&Path>,
468        cargo: &OsStr,
469        arguments: &[String],
470        working_dir: &Path,
471        target_dir_env: Option<&OsStr>,
472    ) -> Option<Self> {
473        let cargo = resolve_program(cargo)?;
474        let env =
475            |name: &str| std::env::var_os(name).map(|value| value.to_string_lossy().into_owned());
476        let key = ProbeKey {
477            cargo: cargo.clone(),
478            arguments: probe_arguments(arguments),
479            working_dir: working_dir.to_path_buf(),
480            target_dir_env: target_dir_env.map(|value| value.to_string_lossy().into_owned()),
481            build_target_dir_env: env("CARGO_BUILD_TARGET_DIR"),
482            // The directory itself rather than the variable: with the
483            // variable unset it follows the home directory, and a record
484            // made under one home must not answer under another.
485            cargo_home: cargo_home.map(|home| home.to_string_lossy().into_owned()),
486        };
487        let invocation_dir = invocation_dir(arguments, working_dir);
488        // Cargo finds the manifest nearest the invocation directory, or the
489        // one named outright, and walks up from there for the workspace. Its
490        // configuration it reads from every `.cargo` above the invocation
491        // directory itself, wherever the manifest is, and from home last.
492        let manifest_start = flag_value(arguments, "--manifest-path")
493            .map(|manifest| absolute(&invocation_dir, manifest))
494            .and_then(|manifest| manifest.parent().map(Path::to_path_buf))
495            .unwrap_or_else(|| invocation_dir.clone());
496        let mut watched = vec![cargo];
497        watched.extend(
498            manifest_start
499                .ancestors()
500                .map(|directory| directory.join("Cargo.toml")),
501        );
502        for directory in invocation_dir.ancestors() {
503            let dot_cargo = directory.join(".cargo");
504            watched.push(dot_cargo.join("config.toml"));
505            watched.push(dot_cargo.join("config"));
506        }
507        if let Some(home) = cargo_home {
508            watched.push(home.join("config.toml"));
509            watched.push(home.join("config"));
510        }
511        watched.extend(
512            config_arguments(arguments)
513                .map(|value| absolute(&invocation_dir, value))
514                .filter(|path| path.is_file()),
515        );
516        // A configuration file may `include` others this list does not
517        // name, and a command-line `include=` does the same. Their target
518        // directory cannot be pinned, so it is not remembered. A `--config`
519        // naming a file is on the list above and read like the rest.
520        if config_arguments(arguments).any(|value| {
521            value
522                .split_once('=')
523                .is_some_and(|(key, _)| key.trim() == "include")
524        }) || watched
525            .iter()
526            .skip(1)
527            .filter(|path| path.file_name().is_some_and(|name| name != "Cargo.toml"))
528            .any(|path| config_includes_files(path))
529        {
530            return None;
531        }
532        let pins = watched
533            .into_iter()
534            .map(Pin::describe)
535            .collect::<Option<Vec<_>>>()?;
536        let selector = canonical_json(&key).ok()?;
537        let path = cache
538            .join("cargo-roots")
539            .join("v1")
540            .join(format!("{}.json", CacheDigest::blake3(&selector).hash));
541        Some(Self {
542            version: PROBE_RECORD_VERSION,
543            key,
544            pins,
545            path,
546            workspace_root: PathBuf::new(),
547            target_dir: PathBuf::new(),
548        })
549    }
550
551    /// The answer an earlier probe left for this key, if its pins all hold.
552    fn recall(&self) -> Option<(PathBuf, PathBuf)> {
553        let bytes = std::fs::read(&self.path).ok()?;
554        let recorded: ProbeRecord = serde_json::from_slice(&bytes).ok()?;
555        if recorded.version != PROBE_RECORD_VERSION
556            || recorded.key != self.key
557            || !recorded.pins.iter().all(Pin::holds)
558        {
559            return None;
560        }
561        Some((recorded.workspace_root, recorded.target_dir))
562    }
563
564    /// Leave the answer behind for the next invocation. Best-effort: a
565    /// record that cannot be written costs the next build a probe.
566    fn remember(mut self, roots: &(PathBuf, PathBuf)) {
567        let root_manifest = roots.0.join("Cargo.toml");
568        if !self.pins.iter().any(|pin| pin.path == root_manifest) {
569            let Some(pin) = Pin::describe(root_manifest) else {
570                return;
571            };
572            self.pins.push(pin);
573        }
574        self.workspace_root = roots.0.clone();
575        self.target_dir = roots.1.clone();
576        let Ok(bytes) = serde_json::to_vec(&self) else {
577            return;
578        };
579        let Some(directory) = self.path.parent() else {
580            return;
581        };
582        if std::fs::create_dir_all(directory).is_err() {
583            return;
584        }
585        let staged = directory.join(format!(
586            ".{}.{}",
587            self.path.file_name().unwrap_or_default().to_string_lossy(),
588            std::process::id()
589        ));
590        if std::fs::write(&staged, bytes).is_ok() && std::fs::rename(&staged, &self.path).is_err() {
591            let _ = std::fs::remove_file(&staged);
592        }
593    }
594}
595
596/// Whether a Cargo configuration file names others through `include`.
597///
598/// A file that cannot be read or parsed is treated as though it did: the
599/// question is whether the probe can be pinned, and a file this cannot see
600/// into is one it cannot pin.
601fn config_includes_files(path: &Path) -> bool {
602    let contents = match std::fs::read_to_string(path) {
603        Ok(contents) => contents,
604        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
605        Err(_) => return true,
606    };
607    toml::from_str::<toml::Value>(&contents).is_ok_and(|config| config.get("include").is_some())
608        || toml::from_str::<toml::Value>(&contents).is_err()
609}
610
611/// The probe command's arguments, in the order it runs them: Cargo's global
612/// flags, then `metadata`, then the options that pick the manifest.
613fn probe_arguments(arguments: &[String]) -> Vec<String> {
614    let mut probe = forwarded_flags(arguments, &PROBE_GLOBAL_FLAGS);
615    probe.extend(
616        ["metadata", "--no-deps", "--format-version", "1"]
617            .iter()
618            .map(|argument| (*argument).to_string()),
619    );
620    if let Some(manifest) = flag_value(arguments, "--manifest-path") {
621        probe.push("--manifest-path".into());
622        probe.push(manifest.into());
623    }
624    probe.extend(
625        arguments
626            .iter()
627            .filter(|argument| PROBE_MANIFEST_TOGGLES.contains(&argument.as_str()))
628            .cloned(),
629    );
630    probe
631}
632
633/// Where `program` is, the way `Command::new` would find it.
634fn resolve_program(program: &OsStr) -> Option<PathBuf> {
635    let candidate = Path::new(program);
636    if candidate.components().count() > 1 {
637        return candidate.is_file().then(|| candidate.to_path_buf());
638    }
639    let names = if cfg!(windows) {
640        vec![candidate.as_os_str().to_os_string(), {
641            let mut exe = candidate.as_os_str().to_os_string();
642            exe.push(".exe");
643            exe
644        }]
645    } else {
646        vec![candidate.as_os_str().to_os_string()]
647    };
648    std::env::split_paths(&std::env::var_os("PATH")?)
649        .flat_map(|directory| names.iter().map(move |name| directory.join(name)))
650        .find(|path| path.is_file())
651}
652
653fn cargo_roots(
654    cargo: &OsStr,
655    arguments: &[String],
656    target_dir_env: Option<&OsStr>,
657) -> Option<(PathBuf, PathBuf)> {
658    let mut command = Command::new(cargo);
659    match target_dir_env {
660        Some(value) => command.env(CARGO_TARGET_DIR_ENV, value),
661        None => command.env_remove(CARGO_TARGET_DIR_ENV),
662    };
663    command.args(probe_arguments(arguments));
664    let output = command.output().ok()?;
665    if !output.status.success() {
666        return None;
667    }
668    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
669    Some((
670        PathBuf::from(metadata.get("workspace_root")?.as_str()?),
671        PathBuf::from(metadata.get("target_directory")?.as_str()?),
672    ))
673}
674
675fn target_dir_argument(arguments: &[String]) -> Option<&str> {
676    flag_value(arguments, "--target-dir")
677}
678
679fn cargo_config_may_set_target_dir(arguments: &[String], invocation_dir: &Path) -> bool {
680    if std::env::var_os("CARGO_BUILD_TARGET_DIR").is_some_and(|value| !value.is_empty()) {
681        return true;
682    }
683    if config_arguments(arguments).any(|value| {
684        value
685            .split_once('=')
686            .is_none_or(|(key, _)| matches!(key.trim(), "build.target-dir" | "include"))
687    }) {
688        return true;
689    }
690    let project = invocation_dir.ancestors().flat_map(|directory| {
691        let cargo = directory.join(".cargo");
692        [cargo.join("config.toml"), cargo.join("config")]
693    });
694    let home = std::env::var_os("CARGO_HOME")
695        .map(PathBuf::from)
696        .or_else(|| dirs::home_dir().map(|root| root.join(".cargo")))
697        .into_iter()
698        .flat_map(|cargo| [cargo.join("config.toml"), cargo.join("config")]);
699    project.chain(home).any(|path| config_may_set_target(&path))
700}
701
702fn config_arguments(arguments: &[String]) -> impl Iterator<Item = &str> {
703    let mut values = Vec::new();
704    let mut remaining = arguments.iter();
705    while let Some(argument) = remaining.next() {
706        if let Some(value) = argument.strip_prefix("--config=") {
707            values.push(value);
708        } else if argument == "--config"
709            && let Some(value) = remaining.next()
710        {
711            values.push(value);
712        }
713    }
714    values.into_iter()
715}
716
717fn config_may_set_target(path: &Path) -> bool {
718    let contents = match std::fs::read_to_string(path) {
719        Ok(contents) => contents,
720        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
721        Err(_) => return true,
722    };
723    let Ok(config) = toml::from_str::<toml::Value>(&contents) else {
724        return true;
725    };
726    config
727        .get("build")
728        .and_then(|build| build.get("target-dir"))
729        .is_some()
730        || config.get("include").is_some()
731}
732
733fn flag_value<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
734    let joined = format!("{flag}=");
735    let mut arguments = arguments.iter();
736    while let Some(argument) = arguments.next() {
737        if let Some(value) = argument.strip_prefix(&joined) {
738            return Some(value);
739        }
740        if argument == flag {
741            return arguments.next().map(String::as_str);
742        }
743    }
744    None
745}
746
747fn workspace_root(start: &Path) -> PathBuf {
748    start
749        .ancestors()
750        .find(|directory| directory.join("Cargo.toml").is_file())
751        .unwrap_or(start)
752        .to_path_buf()
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    fn cargo_fixture() -> tempfile::TempDir {
760        let directory = tempfile::tempdir().unwrap();
761        std::fs::write(
762            directory.path().join("Cargo.toml"),
763            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
764        )
765        .unwrap();
766        std::fs::create_dir_all(directory.path().join("src")).unwrap();
767        std::fs::write(directory.path().join("src/lib.rs"), "").unwrap();
768        directory
769    }
770
771    #[test]
772    fn lockfile_makes_identity_independent_of_checkout_path() {
773        let left = tempfile::tempdir().unwrap();
774        let right = tempfile::tempdir().unwrap();
775        std::fs::write(left.path().join("Cargo.lock"), "same").unwrap();
776        std::fs::write(right.path().join("Cargo.lock"), "same").unwrap();
777        let command = vec!["build".to_string(), "--workspace".to_string()];
778        assert_eq!(
779            build_identity(left.path(), &command),
780            build_identity(right.path(), &command)
781        );
782    }
783
784    #[test]
785    fn cargo_commands_and_toolchain_selectors_share_predictions() {
786        let project = tempfile::tempdir().unwrap();
787        std::fs::write(project.path().join("Cargo.lock"), "same").unwrap();
788
789        let build = vec!["build".to_string()];
790        let test = vec!["test".to_string(), "--workspace".to_string()];
791        let msrv = vec!["+1.91".to_string(), "check".to_string()];
792
793        assert_eq!(
794            build_identity(project.path(), &build),
795            build_identity(project.path(), &test),
796        );
797        assert_eq!(
798            build_identity(project.path(), &build),
799            build_identity(project.path(), &msrv),
800        );
801    }
802
803    #[test]
804    fn rustc_flags_after_separator_are_not_cargo_globals() {
805        let args = vec![
806            "rustc".into(),
807            "--".into(),
808            "-C".into(),
809            "opt-level=3".into(),
810        ];
811        assert_eq!(
812            invocation_dir(cargo_arguments(&args), Path::new("/work")),
813            PathBuf::from("/work")
814        );
815    }
816
817    #[test]
818    fn target_directory_flags_and_environment_are_recorded_as_explicit() {
819        let directory = cargo_fixture();
820        let root = directory.path();
821        let cargo = OsStr::new("cargo-that-does-not-exist");
822        let plain = ["build".to_string()];
823
824        let default = resolve(cargo, &plain, root, None);
825        assert_eq!(default.target_dir, root.join("target"));
826        assert!(!default.target_dir_requested);
827
828        for arguments in [
829            vec!["build".into(), "--target-dir=target".into()],
830            vec!["build".into(), "--target-dir".into(), "target".into()],
831        ] {
832            let resolved = resolve(cargo, &arguments, root, None);
833            assert_eq!(resolved.target_dir, root.join("target"));
834            assert!(resolved.target_dir_requested);
835        }
836
837        let from_environment = resolve(cargo, &plain, root, Some("elsewhere".into()));
838        assert_eq!(from_environment.target_dir, root.join("elsewhere"));
839        assert!(from_environment.target_dir_requested);
840
841        let empty_environment = resolve(cargo, &plain, root, Some("".into()));
842        assert!(!empty_environment.target_dir_requested);
843
844        let dangling = ["build".into(), "--target-dir".into()];
845        assert!(!resolve(cargo, &dangling, root, None).target_dir_requested);
846    }
847
848    #[test]
849    fn project_config_that_names_the_default_target_is_still_explicit() {
850        let directory = cargo_fixture();
851        let root = directory.path();
852        std::fs::create_dir_all(root.join(".cargo")).unwrap();
853        std::fs::write(
854            root.join(".cargo/config.toml"),
855            "[build]\ntarget-dir = \"target\"\n",
856        )
857        .unwrap();
858
859        let resolved = resolve(
860            OsStr::new("cargo-that-does-not-exist"),
861            &["build".into()],
862            root,
863            None,
864        );
865
866        assert_eq!(resolved.target_dir, root.join("target"));
867        assert!(resolved.target_dir_requested);
868    }
869
870    #[test]
871    fn command_line_config_include_may_set_the_target_directory() {
872        let directory = cargo_fixture();
873        let arguments = [
874            "build".into(),
875            "--config".into(),
876            "include='target-config.toml'".into(),
877        ];
878
879        let resolved = resolve(
880            OsStr::new("cargo-that-does-not-exist"),
881            &arguments,
882            directory.path(),
883            None,
884        );
885
886        assert!(resolved.target_dir_requested);
887    }
888
889    #[test]
890    fn command_line_target_config_reaches_the_cargo_probe() {
891        let directory = cargo_fixture();
892        let root = directory.path();
893        let configured = root.join("configured-target");
894        let arguments = [
895            "build".into(),
896            "--offline".into(),
897            "--manifest-path".into(),
898            root.join("Cargo.toml").display().to_string(),
899            "--config".into(),
900            format!("build.target-dir='{}'", configured.display()),
901        ];
902
903        let resolved =
904            resolve_reported_in(None, OsStr::new("cargo"), &arguments, root, None).unwrap();
905
906        assert_eq!(resolved.target_dir, configured);
907        assert!(resolved.target_dir_requested);
908    }
909
910    /// A stand-in Cargo that answers `metadata` and logs every call.
911    #[cfg(unix)]
912    fn logging_cargo(directory: &Path, project: &Path, log: &Path) -> PathBuf {
913        use std::os::unix::fs::PermissionsExt as _;
914        let script = directory.join("cargo");
915        std::fs::write(
916            &script,
917            format!(
918                "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {log}\ncase \" $* \" in *' metadata '*) printf '{{\"workspace_root\":\"{root}\",\"target_directory\":\"{root}/target\",\"packages\":[]}}';; esac\n",
919                log = log.display(),
920                root = project.display(),
921            ),
922        )
923        .unwrap();
924        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
925        script
926    }
927
928    #[cfg(unix)]
929    fn probes(log: &Path) -> usize {
930        std::fs::read_to_string(log)
931            .unwrap_or_default()
932            .lines()
933            .filter(|line| line.contains("metadata"))
934            .count()
935    }
936
937    #[test]
938    #[cfg(unix)]
939    fn a_probe_is_remembered_while_what_cargo_read_stands() {
940        let directory = cargo_fixture();
941        let root = directory.path();
942        let cache = tempfile::tempdir().unwrap();
943        let log = root.join("cargo.log");
944        let cargo = logging_cargo(root, root, &log);
945        let arguments = ["build".to_string(), "--locked".to_string()];
946        let home = root.join("cargo-home");
947        let resolve = |target_dir_env: Option<&str>| {
948            resolve_reported_from_home(
949                Some(cache.path()),
950                Some(&home),
951                cargo.as_os_str(),
952                &arguments,
953                root,
954                target_dir_env.map(OsString::from),
955            )
956            .unwrap()
957        };
958
959        let first = resolve(None);
960        assert_eq!(first.workspace_root, root);
961        assert_eq!(probes(&log), 1);
962        // The same question again is answered from the record.
963        assert_eq!(resolve(None), first);
964        assert_eq!(probes(&log), 1);
965
966        // An edit to a manifest Cargo read runs the probe again.
967        std::fs::write(
968            root.join("Cargo.toml"),
969            "[package]\nname = \"fixture\"\nversion = \"0.2.0\"\nedition = \"2021\"\n",
970        )
971        .unwrap();
972        assert_eq!(resolve(None), first);
973        assert_eq!(probes(&log), 2);
974        assert_eq!(resolve(None), first);
975        assert_eq!(probes(&log), 2);
976
977        // So does a configuration file appearing where Cargo would look.
978        std::fs::create_dir_all(root.join(".cargo")).unwrap();
979        std::fs::write(root.join(".cargo/config.toml"), "[build]\njobs = 2\n").unwrap();
980        assert_eq!(resolve(None), first);
981        assert_eq!(probes(&log), 3);
982
983        // The environment that selects a target directory is part of the key.
984        assert!(resolve(Some("elsewhere")).target_dir_requested);
985        assert_eq!(probes(&log), 4);
986        assert_eq!(resolve(None), first);
987        assert_eq!(probes(&log), 4);
988
989        // So is the Cargo home, which follows HOME when CARGO_HOME is unset,
990        // and whose configuration is watched like the project's.
991        let other_home = root.join("other-home");
992        let from_other_home = resolve_reported_from_home(
993            Some(cache.path()),
994            Some(&other_home),
995            cargo.as_os_str(),
996            &arguments,
997            root,
998            None,
999        )
1000        .unwrap();
1001        assert_eq!(from_other_home.workspace_root, first.workspace_root);
1002        assert_eq!(probes(&log), 5);
1003        std::fs::create_dir_all(&other_home).unwrap();
1004        std::fs::write(other_home.join("config.toml"), "[build]\njobs = 4\n").unwrap();
1005        resolve_reported_from_home(
1006            Some(cache.path()),
1007            Some(&other_home),
1008            cargo.as_os_str(),
1009            &arguments,
1010            root,
1011            None,
1012        )
1013        .unwrap();
1014        assert_eq!(probes(&log), 6);
1015        assert_eq!(resolve(None), first);
1016        assert_eq!(probes(&log), 6);
1017
1018        // A configuration that includes files this cannot see is never
1019        // remembered: every build probes.
1020        std::fs::write(
1021            root.join(".cargo/config.toml"),
1022            "include = \"other.toml\"\n",
1023        )
1024        .unwrap();
1025        assert_eq!(resolve(None).workspace_root, first.workspace_root);
1026        assert_eq!(resolve(None).workspace_root, first.workspace_root);
1027        assert_eq!(probes(&log), 8);
1028    }
1029
1030    #[test]
1031    #[cfg(unix)]
1032    fn a_config_file_named_on_the_command_line_is_pinned() {
1033        let directory = cargo_fixture();
1034        let root = directory.path();
1035        let cache = tempfile::tempdir().unwrap();
1036        let log = root.join("cargo.log");
1037        let cargo = logging_cargo(root, root, &log);
1038        let extra = root.join("extra.toml");
1039        std::fs::write(&extra, "[build]\njobs = 2\n").unwrap();
1040        let arguments = [
1041            "build".to_string(),
1042            "--config".to_string(),
1043            extra.display().to_string(),
1044        ];
1045        let resolve = || {
1046            resolve_reported_in(
1047                Some(cache.path()),
1048                cargo.as_os_str(),
1049                &arguments,
1050                root,
1051                None,
1052            )
1053            .unwrap()
1054        };
1055
1056        let first = resolve();
1057        assert_eq!(resolve(), first);
1058        assert_eq!(probes(&log), 1);
1059        // The file is watched like any other configuration.
1060        std::fs::write(&extra, "[build]\njobs = 3\n").unwrap();
1061        assert_eq!(resolve(), first);
1062        assert_eq!(probes(&log), 2);
1063        // Until it includes something that cannot be.
1064        std::fs::write(&extra, "include = \"more.toml\"\n").unwrap();
1065        resolve();
1066        resolve();
1067        assert_eq!(probes(&log), 4);
1068    }
1069
1070    #[test]
1071    #[cfg(unix)]
1072    fn a_failed_probe_is_not_remembered() {
1073        use std::os::unix::fs::PermissionsExt as _;
1074        let directory = cargo_fixture();
1075        let root = directory.path();
1076        let cache = tempfile::tempdir().unwrap();
1077        let script = root.join("cargo");
1078        std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
1079        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1080        let arguments = ["build".to_string()];
1081
1082        assert!(
1083            resolve_reported_in(
1084                Some(cache.path()),
1085                script.as_os_str(),
1086                &arguments,
1087                root,
1088                None
1089            )
1090            .is_none()
1091        );
1092        assert!(!cache.path().join("cargo-roots").exists());
1093    }
1094
1095    #[test]
1096    fn reported_resolution_requires_a_successful_metadata_probe() {
1097        let directory = cargo_fixture();
1098        assert!(
1099            resolve_reported(
1100                OsStr::new("cargo-that-does-not-exist"),
1101                &["build".into()],
1102                directory.path(),
1103                None,
1104            )
1105            .is_none()
1106        );
1107    }
1108
1109    fn git(directory: &Path, arguments: &[&str]) {
1110        let status = Command::new("git")
1111            .arg("-C")
1112            .arg(directory)
1113            .args(["-c", "commit.gpgsign=false", "-c", "user.name=t"])
1114            .args(["-c", "user.email=t@example.com"])
1115            .args(arguments)
1116            .stdout(std::process::Stdio::null())
1117            .stderr(std::process::Stdio::null())
1118            .status()
1119            .unwrap();
1120        assert!(status.success(), "git {arguments:?} failed");
1121    }
1122
1123    #[test]
1124    fn earlier_lockfile_states_are_offered_newest_first() {
1125        let directory = tempfile::tempdir().unwrap();
1126        let root = directory.path();
1127        let command = Vec::new();
1128        git(root, &["init", "-q"]);
1129        let mut committed = Vec::new();
1130        for state in ["one", "two", "three"] {
1131            std::fs::write(root.join("Cargo.lock"), state).unwrap();
1132            committed.push(build_identity(root, &command));
1133            git(root, &["add", "Cargo.lock"]);
1134            git(root, &["commit", "-q", "-m", state]);
1135        }
1136        committed.reverse();
1137
1138        // An uncommitted edit: the committed copy is the nearest earlier state.
1139        std::fs::write(root.join("Cargo.lock"), "four").unwrap();
1140        assert!(!committed.contains(&build_identity(root, &command)));
1141        assert_eq!(previous_build_identities(root), committed);
1142
1143        // Once committed, the working copy matches HEAD and is not offered as
1144        // its own fallback.
1145        git(root, &["commit", "-q", "-am", "four"]);
1146        assert_eq!(previous_build_identities(root), committed);
1147    }
1148
1149    #[test]
1150    fn a_shallow_clone_offers_the_states_it_can_reach() {
1151        let origin = tempfile::tempdir().unwrap();
1152        let root = origin.path();
1153        let command = Vec::new();
1154        git(root, &["init", "-q"]);
1155        let mut committed = Vec::new();
1156        for state in ["one", "two", "three", "four"] {
1157            std::fs::write(root.join("Cargo.lock"), state).unwrap();
1158            committed.push(build_identity(root, &command));
1159            git(root, &["add", "Cargo.lock"]);
1160            git(root, &["commit", "-q", "-m", state]);
1161        }
1162        let clones = tempfile::tempdir().unwrap();
1163        let shallow = clones.path().join("shallow");
1164        git(
1165            clones.path(),
1166            &[
1167                "clone",
1168                "-q",
1169                "--depth",
1170                "2",
1171                &format!("file://{}", root.display()),
1172                shallow.to_str().unwrap(),
1173            ],
1174        );
1175        // HEAD holds "four"; its parent "three" was fetched and "two" was not.
1176        assert_eq!(
1177            previous_build_identities(&shallow),
1178            vec![committed[2].clone()]
1179        );
1180    }
1181
1182    #[test]
1183    fn an_untracked_lockfile_has_no_history_to_borrow_from() {
1184        let directory = tempfile::tempdir().unwrap();
1185        let root = directory.path();
1186        git(root, &["init", "-q"]);
1187        std::fs::write(root.join("Cargo.lock"), "tracked").unwrap();
1188        git(root, &["add", "Cargo.lock"]);
1189        git(root, &["commit", "-q", "-m", "tracked"]);
1190        git(root, &["rm", "-q", "Cargo.lock"]);
1191        git(root, &["commit", "-q", "-m", "removed"]);
1192        // Cargo generated a replacement nobody tracks.
1193        std::fs::write(root.join("Cargo.lock"), "generated").unwrap();
1194        assert!(previous_build_identities(root).is_empty());
1195    }
1196
1197    #[test]
1198    fn a_workspace_outside_version_control_has_no_earlier_states() {
1199        let directory = cargo_fixture();
1200        std::fs::write(directory.path().join("Cargo.lock"), "one").unwrap();
1201        assert!(previous_build_identities(directory.path()).is_empty());
1202    }
1203}