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::Serialize;
9use std::ffi::{OsStr, OsString};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13const CARGO_TARGET_DIR_ENV: &str = "CARGO_TARGET_DIR";
14const PROBE_GLOBAL_FLAGS: [&str; 3] = ["-C", "--config", "-Z"];
15const PROBE_MANIFEST_TOGGLES: [&str; 3] = ["--offline", "--frozen", "--locked"];
16
17/// Cargo-resolved roots and the stable prediction-manifest identity for one invocation.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CargoInvocation {
20    /// Cargo's resolved workspace root.
21    pub workspace_root: PathBuf,
22    /// Cargo's resolved output directory.
23    pub target_dir: PathBuf,
24    /// Whether a flag, environment value, or Cargo configuration explicitly selected the target.
25    pub target_dir_requested: bool,
26    /// Cross-checkout identity used to select the action-prediction manifest.
27    pub build_identity: String,
28}
29
30/// Resolve one Cargo invocation exactly enough for a cache session.
31pub fn resolve(
32    cargo: &OsStr,
33    arguments: &[String],
34    working_dir: &Path,
35    target_dir_env: Option<OsString>,
36) -> CargoInvocation {
37    let cargo_args = cargo_arguments(arguments);
38    let reported = cargo_roots(cargo, cargo_args, target_dir_env.as_deref());
39    resolve_with_reported(arguments, working_dir, target_dir_env, reported)
40}
41
42/// Resolve one Cargo invocation only when Cargo successfully reports its roots.
43///
44/// Persistent Cargo shims use this form to distinguish a usable build from an
45/// invocation they should pass through, while retaining the successful
46/// metadata result for the session instead of probing twice.
47pub fn resolve_reported(
48    cargo: &OsStr,
49    arguments: &[String],
50    working_dir: &Path,
51    target_dir_env: Option<OsString>,
52) -> Option<CargoInvocation> {
53    let reported = cargo_roots(cargo, cargo_arguments(arguments), target_dir_env.as_deref())?;
54    Some(resolve_with_reported(
55        arguments,
56        working_dir,
57        target_dir_env,
58        Some(reported),
59    ))
60}
61
62fn resolve_with_reported(
63    arguments: &[String],
64    working_dir: &Path,
65    target_dir_env: Option<OsString>,
66    reported: Option<(PathBuf, PathBuf)>,
67) -> CargoInvocation {
68    let cargo_args = cargo_arguments(arguments);
69    let invocation_dir = invocation_dir(cargo_args, working_dir);
70    let workspace_root = reported
71        .as_ref()
72        .map(|roots| roots.0.clone())
73        .unwrap_or_else(|| workspace_root(&invocation_dir));
74    let flagged = target_dir_argument(cargo_args);
75    let target_dir_requested = flagged.is_some()
76        || target_dir_env
77            .as_ref()
78            .is_some_and(|value| !value.is_empty())
79        || cargo_config_may_set_target_dir(cargo_args, &invocation_dir);
80    let target_dir = flagged
81        .map(|value| absolute(&invocation_dir, value))
82        .or_else(|| reported.map(|roots| roots.1))
83        .or_else(|| {
84            target_dir_env
85                .filter(|value| !value.is_empty())
86                .map(PathBuf::from)
87                .map(|value| absolute(&invocation_dir, &value.to_string_lossy()))
88        })
89        .unwrap_or_else(|| workspace_root.join("target"));
90    let build_identity = build_identity(&workspace_root, arguments);
91    CargoInvocation {
92        workspace_root,
93        target_dir,
94        target_dir_requested,
95        build_identity,
96    }
97}
98
99#[derive(Serialize)]
100struct ActionIdentity<'a> {
101    version: u8,
102    workspace: &'a str,
103    os: &'static str,
104    arch: &'static str,
105}
106
107/// Derive the prediction-manifest identity used by mbx for a Cargo command.
108///
109/// The command is accepted for API compatibility but deliberately does not
110/// enter the identity. rustc invocation digests distinguish profiles,
111/// features, targets, and compiler toolchains; keeping the surrounding Cargo
112/// command here would prevent equivalent dependency compilations from sharing
113/// predictions across `build`, `test`, and Clippy.
114pub fn build_identity(workspace_root: &Path, _command: &[String]) -> String {
115    let workspace = workspace_marker(workspace_root);
116    let bytes = canonical_json(&ActionIdentity {
117        version: 3,
118        workspace: &workspace,
119        os: std::env::consts::OS,
120        arch: std::env::consts::ARCH,
121    })
122    .expect("Cargo build identity must serialize");
123    CacheDigest::blake3(&bytes).hash
124}
125
126fn workspace_marker(workspace_root: &Path) -> String {
127    std::fs::read(workspace_root.join("Cargo.lock")).map_or_else(
128        |_| {
129            workspace_root
130                .file_name()
131                .unwrap_or_default()
132                .to_string_lossy()
133                .into_owned()
134        },
135        |lock| CacheDigest::blake3(&lock).hash,
136    )
137}
138
139/// Resolve the shared mbx cache root from the environment, machine config, or platform default.
140pub fn cache_root() -> Option<PathBuf> {
141    if let Some(root) = std::env::var_os("MBX_CACHE_DIR").filter(|value| !value.is_empty()) {
142        return Some(PathBuf::from(root));
143    }
144    if let Some(config) = dirs::config_dir().map(|root| root.join("mbx/config.toml"))
145        && let Ok(contents) = std::fs::read_to_string(config)
146        && let Ok(document) = toml::from_str::<toml::Value>(&contents)
147        && let Some(root) = document.get("cache_dir").and_then(toml::Value::as_str)
148    {
149        return Some(PathBuf::from(root));
150    }
151    dirs::cache_dir().map(|root| root.join("mbx"))
152}
153
154fn cargo_arguments(arguments: &[String]) -> &[String] {
155    &arguments[..arguments
156        .iter()
157        .position(|argument| argument == "--")
158        .unwrap_or(arguments.len())]
159}
160
161fn absolute(working_dir: &Path, value: &str) -> PathBuf {
162    let path = PathBuf::from(value);
163    if path.is_absolute() {
164        path
165    } else {
166        working_dir.join(path)
167    }
168}
169
170fn forwarded_flags(arguments: &[String], flags: &[&str]) -> Vec<String> {
171    let mut forwarded = Vec::new();
172    let mut remaining = arguments.iter();
173    while let Some(argument) = remaining.next() {
174        if let Some((flag, value)) = argument
175            .split_once('=')
176            .filter(|(flag, _)| flags.contains(flag))
177        {
178            forwarded.extend([flag.to_string(), value.to_string()]);
179        } else if flags.contains(&argument.as_str()) {
180            if let Some(value) = remaining.next() {
181                forwarded.extend([argument.clone(), value.clone()]);
182            }
183        } else if flags
184            .iter()
185            .any(|flag| flag.len() == 2 && argument.len() > 2 && argument.starts_with(flag))
186        {
187            forwarded.push(argument.clone());
188        }
189    }
190    forwarded
191}
192
193fn invocation_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
194    flag_value(arguments, "-C")
195        .map(|value| absolute(working_dir, value))
196        .unwrap_or_else(|| working_dir.to_path_buf())
197}
198
199fn cargo_roots(
200    cargo: &OsStr,
201    arguments: &[String],
202    target_dir_env: Option<&OsStr>,
203) -> Option<(PathBuf, PathBuf)> {
204    let mut command = Command::new(cargo);
205    match target_dir_env {
206        Some(value) => command.env(CARGO_TARGET_DIR_ENV, value),
207        None => command.env_remove(CARGO_TARGET_DIR_ENV),
208    };
209    command.args(forwarded_flags(arguments, &PROBE_GLOBAL_FLAGS));
210    command.args(["metadata", "--no-deps", "--format-version", "1"]);
211    if let Some(manifest) = flag_value(arguments, "--manifest-path") {
212        command.args(["--manifest-path", manifest]);
213    }
214    command.args(
215        arguments
216            .iter()
217            .filter(|argument| PROBE_MANIFEST_TOGGLES.contains(&argument.as_str())),
218    );
219    let output = command.output().ok()?;
220    if !output.status.success() {
221        return None;
222    }
223    let metadata: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
224    Some((
225        PathBuf::from(metadata.get("workspace_root")?.as_str()?),
226        PathBuf::from(metadata.get("target_directory")?.as_str()?),
227    ))
228}
229
230fn target_dir_argument(arguments: &[String]) -> Option<&str> {
231    flag_value(arguments, "--target-dir")
232}
233
234fn cargo_config_may_set_target_dir(arguments: &[String], invocation_dir: &Path) -> bool {
235    if std::env::var_os("CARGO_BUILD_TARGET_DIR").is_some_and(|value| !value.is_empty()) {
236        return true;
237    }
238    if config_arguments(arguments).any(|value| {
239        value
240            .split_once('=')
241            .is_none_or(|(key, _)| matches!(key.trim(), "build.target-dir" | "include"))
242    }) {
243        return true;
244    }
245    let project = invocation_dir.ancestors().flat_map(|directory| {
246        let cargo = directory.join(".cargo");
247        [cargo.join("config.toml"), cargo.join("config")]
248    });
249    let home = std::env::var_os("CARGO_HOME")
250        .map(PathBuf::from)
251        .or_else(|| dirs::home_dir().map(|root| root.join(".cargo")))
252        .into_iter()
253        .flat_map(|cargo| [cargo.join("config.toml"), cargo.join("config")]);
254    project.chain(home).any(|path| config_may_set_target(&path))
255}
256
257fn config_arguments(arguments: &[String]) -> impl Iterator<Item = &str> {
258    let mut values = Vec::new();
259    let mut remaining = arguments.iter();
260    while let Some(argument) = remaining.next() {
261        if let Some(value) = argument.strip_prefix("--config=") {
262            values.push(value);
263        } else if argument == "--config"
264            && let Some(value) = remaining.next()
265        {
266            values.push(value);
267        }
268    }
269    values.into_iter()
270}
271
272fn config_may_set_target(path: &Path) -> bool {
273    let contents = match std::fs::read_to_string(path) {
274        Ok(contents) => contents,
275        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
276        Err(_) => return true,
277    };
278    let Ok(config) = toml::from_str::<toml::Value>(&contents) else {
279        return true;
280    };
281    config
282        .get("build")
283        .and_then(|build| build.get("target-dir"))
284        .is_some()
285        || config.get("include").is_some()
286}
287
288fn flag_value<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
289    let joined = format!("{flag}=");
290    let mut arguments = arguments.iter();
291    while let Some(argument) = arguments.next() {
292        if let Some(value) = argument.strip_prefix(&joined) {
293            return Some(value);
294        }
295        if argument == flag {
296            return arguments.next().map(String::as_str);
297        }
298    }
299    None
300}
301
302fn workspace_root(start: &Path) -> PathBuf {
303    start
304        .ancestors()
305        .find(|directory| directory.join("Cargo.toml").is_file())
306        .unwrap_or(start)
307        .to_path_buf()
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn cargo_fixture() -> tempfile::TempDir {
315        let directory = tempfile::tempdir().unwrap();
316        std::fs::write(
317            directory.path().join("Cargo.toml"),
318            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
319        )
320        .unwrap();
321        std::fs::create_dir_all(directory.path().join("src")).unwrap();
322        std::fs::write(directory.path().join("src/lib.rs"), "").unwrap();
323        directory
324    }
325
326    #[test]
327    fn lockfile_makes_identity_independent_of_checkout_path() {
328        let left = tempfile::tempdir().unwrap();
329        let right = tempfile::tempdir().unwrap();
330        std::fs::write(left.path().join("Cargo.lock"), "same").unwrap();
331        std::fs::write(right.path().join("Cargo.lock"), "same").unwrap();
332        let command = vec!["build".to_string(), "--workspace".to_string()];
333        assert_eq!(
334            build_identity(left.path(), &command),
335            build_identity(right.path(), &command)
336        );
337    }
338
339    #[test]
340    fn cargo_commands_and_toolchain_selectors_share_predictions() {
341        let project = tempfile::tempdir().unwrap();
342        std::fs::write(project.path().join("Cargo.lock"), "same").unwrap();
343
344        let build = vec!["build".to_string()];
345        let test = vec!["test".to_string(), "--workspace".to_string()];
346        let msrv = vec!["+1.91".to_string(), "check".to_string()];
347
348        assert_eq!(
349            build_identity(project.path(), &build),
350            build_identity(project.path(), &test),
351        );
352        assert_eq!(
353            build_identity(project.path(), &build),
354            build_identity(project.path(), &msrv),
355        );
356    }
357
358    #[test]
359    fn rustc_flags_after_separator_are_not_cargo_globals() {
360        let args = vec![
361            "rustc".into(),
362            "--".into(),
363            "-C".into(),
364            "opt-level=3".into(),
365        ];
366        assert_eq!(
367            invocation_dir(cargo_arguments(&args), Path::new("/work")),
368            PathBuf::from("/work")
369        );
370    }
371
372    #[test]
373    fn target_directory_flags_and_environment_are_recorded_as_explicit() {
374        let directory = cargo_fixture();
375        let root = directory.path();
376        let cargo = OsStr::new("cargo-that-does-not-exist");
377        let plain = ["build".to_string()];
378
379        let default = resolve(cargo, &plain, root, None);
380        assert_eq!(default.target_dir, root.join("target"));
381        assert!(!default.target_dir_requested);
382
383        for arguments in [
384            vec!["build".into(), "--target-dir=target".into()],
385            vec!["build".into(), "--target-dir".into(), "target".into()],
386        ] {
387            let resolved = resolve(cargo, &arguments, root, None);
388            assert_eq!(resolved.target_dir, root.join("target"));
389            assert!(resolved.target_dir_requested);
390        }
391
392        let from_environment = resolve(cargo, &plain, root, Some("elsewhere".into()));
393        assert_eq!(from_environment.target_dir, root.join("elsewhere"));
394        assert!(from_environment.target_dir_requested);
395
396        let empty_environment = resolve(cargo, &plain, root, Some("".into()));
397        assert!(!empty_environment.target_dir_requested);
398
399        let dangling = ["build".into(), "--target-dir".into()];
400        assert!(!resolve(cargo, &dangling, root, None).target_dir_requested);
401    }
402
403    #[test]
404    fn project_config_that_names_the_default_target_is_still_explicit() {
405        let directory = cargo_fixture();
406        let root = directory.path();
407        std::fs::create_dir_all(root.join(".cargo")).unwrap();
408        std::fs::write(
409            root.join(".cargo/config.toml"),
410            "[build]\ntarget-dir = \"target\"\n",
411        )
412        .unwrap();
413
414        let resolved = resolve(
415            OsStr::new("cargo-that-does-not-exist"),
416            &["build".into()],
417            root,
418            None,
419        );
420
421        assert_eq!(resolved.target_dir, root.join("target"));
422        assert!(resolved.target_dir_requested);
423    }
424
425    #[test]
426    fn command_line_config_include_may_set_the_target_directory() {
427        let directory = cargo_fixture();
428        let arguments = [
429            "build".into(),
430            "--config".into(),
431            "include='target-config.toml'".into(),
432        ];
433
434        let resolved = resolve(
435            OsStr::new("cargo-that-does-not-exist"),
436            &arguments,
437            directory.path(),
438            None,
439        );
440
441        assert!(resolved.target_dir_requested);
442    }
443
444    #[test]
445    fn command_line_target_config_reaches_the_cargo_probe() {
446        let directory = cargo_fixture();
447        let root = directory.path();
448        let configured = root.join("configured-target");
449        let arguments = [
450            "build".into(),
451            "--offline".into(),
452            "--manifest-path".into(),
453            root.join("Cargo.toml").display().to_string(),
454            "--config".into(),
455            format!("build.target-dir='{}'", configured.display()),
456        ];
457
458        let resolved = resolve_reported(OsStr::new("cargo"), &arguments, root, None).unwrap();
459
460        assert_eq!(resolved.target_dir, configured);
461        assert!(resolved.target_dir_requested);
462    }
463
464    #[test]
465    fn reported_resolution_requires_a_successful_metadata_probe() {
466        let directory = cargo_fixture();
467        assert!(
468            resolve_reported(
469                OsStr::new("cargo-that-does-not-exist"),
470                &["build".into()],
471                directory.path(),
472                None,
473            )
474            .is_none()
475        );
476    }
477}