Skip to main content

mbx_cache_rustc/
dep_info.rs

1use super::{
2    ActionContext, ActionInput, Argument, BypassReason, MAX_NATIVE_INPUT_BYTES,
3    MAX_PREDICTED_INPUTS, PathMapping, RustcInvocation, normalize_components,
4};
5use mbx_cache_core::CacheDigest;
6use std::collections::{BTreeMap, BTreeSet};
7use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9use std::time::SystemTime;
10
11/// A side-effect-minimized rustc invocation that emits only dependency data.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct DepInfoCommand {
14    arguments: Vec<OsString>,
15    output: PathBuf,
16}
17
18impl DepInfoCommand {
19    /// Arguments for the real compiler, excluding the compiler executable.
20    pub fn arguments(&self) -> &[OsString] {
21        &self.arguments
22    }
23
24    /// Exact file the compiler must populate with dep-info.
25    pub fn output(&self) -> &Path {
26        &self.output
27    }
28}
29
30/// The source and environment inputs reported by rustc's dep-info output.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct RustcDepInfo {
33    /// Source paths listed in the first dep-info dependency rule.
34    pub files: Vec<PathBuf>,
35    /// Environment inputs recorded by rustc `# env-dep:` lines.
36    pub environment: BTreeMap<String, Option<String>>,
37}
38
39impl RustcDepInfo {
40    /// Read and parse a dep-info file, treating missing or non-UTF-8 output as
41    /// an explicit cache bypass.
42    pub fn read(path: &Path) -> Result<Self, BypassReason> {
43        let contents =
44            std::fs::read_to_string(path).map_err(|error| BypassReason::DepInfoRead {
45                path: path.to_path_buf(),
46                message: error.to_string(),
47            })?;
48        Self::parse(&contents)
49    }
50
51    /// Parse rustc's Makefile-style dep-info format.
52    ///
53    /// This intentionally follows Cargo's parser contract: the first target
54    /// rule contains all source dependencies, spaces are escaped with a
55    /// trailing backslash on each token fragment, and `# env-dep:` records
56    /// contain the environment observed by `env!` and `option_env!`.
57    pub fn parse(contents: &str) -> Result<Self, BypassReason> {
58        let mut files = BTreeSet::new();
59        let mut environment = BTreeMap::new();
60        let mut found_dependencies = false;
61
62        for line in contents.lines() {
63            if let Some(record) = line.strip_prefix("# env-dep:") {
64                let (name, value) = record
65                    .split_once('=')
66                    .map_or((record, None), |(name, value)| (name, Some(value)));
67                let name = unescape_environment(name)?;
68                if name.is_empty() {
69                    return Err(BypassReason::MalformedDepInfo(
70                        "environment input has an empty name".into(),
71                    ));
72                }
73                let value = value.map(unescape_environment).transpose()?;
74                if environment
75                    .insert(name.clone(), value.clone())
76                    .is_some_and(|previous| previous != value)
77                {
78                    return Err(BypassReason::ConflictingEnvironment(name));
79                }
80                continue;
81            }
82
83            let Some(separator) = line.find(": ") else {
84                continue;
85            };
86            if found_dependencies {
87                continue;
88            }
89            found_dependencies = true;
90            let mut fragments = line[separator + 2..].split_whitespace();
91            while let Some(fragment) = fragments.next() {
92                let mut file = fragment.to_string();
93                while file.ends_with('\\') {
94                    file.pop();
95                    let continuation = fragments.next().ok_or_else(|| {
96                        BypassReason::MalformedDepInfo(
97                            "dependency path ends with an unterminated escape".into(),
98                        )
99                    })?;
100                    file.push(' ');
101                    file.push_str(continuation);
102                }
103                if file.is_empty() {
104                    return Err(BypassReason::MalformedDepInfo(
105                        "dependency path is empty".into(),
106                    ));
107                }
108                files.insert(PathBuf::from(file));
109            }
110        }
111
112        if !found_dependencies {
113            return Err(BypassReason::MalformedDepInfo(
114                "dependency rule is missing".into(),
115            ));
116        }
117        if files.is_empty() {
118            return Err(BypassReason::MalformedDepInfo(
119                "dependency rule contains no inputs".into(),
120            ));
121        }
122        Ok(Self {
123            files: files.into_iter().collect(),
124            environment,
125        })
126    }
127}
128
129/// A complete, content-addressed compiler input manifest.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct DiscoveredInputs {
132    working_dir: PathBuf,
133    /// Content-addressed compiler input files.
134    pub inputs: Vec<ActionInput>,
135    /// Environment inputs captured from dep-info.
136    pub environment: BTreeMap<String, Option<String>>,
137}
138
139impl DiscoveredInputs {
140    pub(crate) fn from_paths(
141        working_dir: &Path,
142        paths: BTreeSet<PathBuf>,
143        environment: BTreeMap<String, Option<String>>,
144    ) -> Result<Self, BypassReason> {
145        if !working_dir.is_absolute() {
146            return Err(BypassReason::RelativeWorkingDirectory(
147                working_dir.to_path_buf(),
148            ));
149        }
150        let working_dir = normalize_components(working_dir);
151        let mut inputs = Vec::with_capacity(paths.len());
152        for path in paths {
153            let metadata = std::fs::metadata(&path).map_err(|error| BypassReason::InputRead {
154                path: path.clone(),
155                message: error.to_string(),
156            })?;
157            if !metadata.is_file() {
158                return Err(BypassReason::InputRead {
159                    path,
160                    message: "input is not a regular file".into(),
161                });
162            }
163            let digest =
164                CacheDigest::blake3_file(&path).map_err(|error| BypassReason::InputRead {
165                    path: path.clone(),
166                    message: error.to_string(),
167                })?;
168            inputs.push(ActionInput { path, digest });
169        }
170        Ok(Self {
171            working_dir,
172            inputs,
173            environment,
174        })
175    }
176
177    /// Reject inputs whose modification time overlaps the compiler invocation.
178    ///
179    /// Input contents are first hashed after rustc reports their paths. This
180    /// timestamp barrier prevents a post-compile write from being mistaken for
181    /// the contents that produced the artifact. `verify` closes the remaining
182    /// race after hashing.
183    pub fn verify_not_modified_since(&self, started_at: SystemTime) -> Result<(), BypassReason> {
184        for input in &self.inputs {
185            let modified = std::fs::metadata(&input.path)
186                .and_then(|metadata| metadata.modified())
187                .map_err(|error| BypassReason::InputRead {
188                    path: input.path.clone(),
189                    message: error.to_string(),
190                })?;
191            if modified >= started_at {
192                return Err(BypassReason::InputModifiedDuringCompilation(
193                    input.path.clone(),
194                ));
195            }
196        }
197        Ok(())
198    }
199
200    /// Rehash every discovered file after compilation and before publication.
201    /// This closes the discovery/compile race by degrading changed inputs to a
202    /// cache miss rather than storing outputs beneath a stale action key.
203    pub fn verify(&self) -> Result<(), BypassReason> {
204        for input in &self.inputs {
205            let matches = input.digest.matches_file(&input.path).map_err(|error| {
206                BypassReason::InputRead {
207                    path: input.path.clone(),
208                    message: error.to_string(),
209                }
210            })?;
211            if !matches {
212                return Err(BypassReason::InputChanged(input.path.clone()));
213            }
214        }
215        Ok(())
216    }
217
218    /// Merge the manifest into an action context after verifying that both use
219    /// the same compiler working directory.
220    pub fn apply_to(self, context: &mut ActionContext) -> Result<(), BypassReason> {
221        if normalize_components(&context.working_dir) != self.working_dir {
222            return Err(BypassReason::DiscoveryWorkingDirectory);
223        }
224        for (name, value) in &self.environment {
225            if context
226                .environment
227                .get(name)
228                .is_some_and(|previous| previous != value)
229            {
230                return Err(BypassReason::ConflictingEnvironment(name.clone()));
231            }
232        }
233        context.environment.extend(self.environment);
234        context.inputs.extend(self.inputs);
235        Ok(())
236    }
237}
238
239impl RustcInvocation {
240    /// Replace the original output flags with a single explicit dep-info file.
241    pub fn dep_info_command(&self, output: &Path) -> Result<DepInfoCommand, BypassReason> {
242        if !output.is_absolute() {
243            return Err(BypassReason::RelativeDepInfoPath(output.to_path_buf()));
244        }
245        let output_text = output
246            .to_str()
247            .ok_or_else(|| BypassReason::NonUtf8Path(output.to_path_buf()))?;
248        if output_text.contains(',') {
249            return Err(BypassReason::UnsafeDepInfoPath(output.to_path_buf()));
250        }
251
252        let mut arguments = Vec::new();
253        for argument in &self.arguments {
254            match argument {
255                Argument::Emit(_) => {}
256                Argument::Path { flag, .. } if flag == "--out-dir" || flag == "-o" => {}
257                argument => arguments.push(render_argument(argument)?),
258            }
259        }
260        arguments.push(format!("--emit=dep-info={output_text}").into());
261        arguments.push(self.source.clone().into_os_string());
262        Ok(DepInfoCommand {
263            arguments,
264            output: output.to_path_buf(),
265        })
266    }
267
268    /// Hash dep-info sources plus every direct compiler input already modeled
269    /// by the invocation (`--extern` artifacts and custom target specs).
270    pub fn discover_inputs(
271        &self,
272        dep_info: &RustcDepInfo,
273        working_dir: &Path,
274    ) -> Result<DiscoveredInputs, BypassReason> {
275        self.discover_inputs_with_mappings(dep_info, working_dir, &[])
276    }
277
278    /// Hash dep-info sources plus modeled compiler inputs, allowing native
279    /// search directories beneath the working directory or a mapped root.
280    pub fn discover_inputs_with_mappings(
281        &self,
282        dep_info: &RustcDepInfo,
283        working_dir: &Path,
284        path_mappings: &[PathMapping],
285    ) -> Result<DiscoveredInputs, BypassReason> {
286        if !working_dir.is_absolute() {
287            return Err(BypassReason::RelativeWorkingDirectory(
288                working_dir.to_path_buf(),
289            ));
290        }
291        let working_dir = normalize_components(working_dir);
292        let mut paths = dep_info
293            .files
294            .iter()
295            .chain(&self.required_inputs)
296            .map(|path| {
297                let absolute = if path.is_absolute() {
298                    path.to_path_buf()
299                } else {
300                    working_dir.join(path)
301                };
302                normalize_components(&absolute)
303            })
304            .collect::<BTreeSet<_>>();
305        let admitted_roots = native_input_roots(&working_dir, path_mappings);
306        let mut native_bytes = 0_u64;
307        for argument in &self.arguments {
308            if let Argument::SearchPath { kind, path } = argument
309                && kind == "native"
310            {
311                let directory = if path.is_absolute() {
312                    path.clone()
313                } else {
314                    working_dir.join(path)
315                };
316                // An inert directory outside every mapped root enters the key
317                // by its literal path in the arguments, not by its contents --
318                // predictions skip it under the same rule, so both discovery
319                // paths agree on the action key.
320                if self.native_search_is_inert()
321                    && matches!(
322                        super::normalize_mapped_path(&directory, &working_dir, path_mappings),
323                        Err(BypassReason::UnmappedAbsolutePath(_))
324                    )
325                {
326                    continue;
327                }
328                collect_native_directory(
329                    &directory,
330                    &admitted_roots,
331                    &mut paths,
332                    &mut native_bytes,
333                )?;
334            }
335        }
336        DiscoveredInputs::from_paths(&working_dir, paths, dep_info.environment.clone())
337    }
338}
339
340/// Return normalized roots whose native search directories can be tracked.
341pub(super) fn native_input_roots(
342    working_dir: &Path,
343    path_mappings: &[PathMapping],
344) -> Vec<PathBuf> {
345    std::iter::once(working_dir)
346        .chain(path_mappings.iter().map(|mapping| mapping.root.as_path()))
347        .map(normalize_components)
348        .collect()
349}
350
351/// Add regular files beneath an admitted native search directory, enforcing
352/// the prediction input count and the caller's cumulative native byte budget.
353pub(super) fn collect_native_directory(
354    directory: &Path,
355    admitted_roots: &[PathBuf],
356    paths: &mut BTreeSet<PathBuf>,
357    native_bytes: &mut u64,
358) -> Result<(), BypassReason> {
359    let directory = normalize_components(directory);
360    if !admitted_roots
361        .iter()
362        .any(|root| directory.starts_with(root))
363    {
364        return Err(BypassReason::UnsupportedSearchPath("native".into()));
365    }
366
367    let mut pending = vec![directory];
368    while let Some(directory) = pending.pop() {
369        let entries = match std::fs::read_dir(&directory) {
370            Ok(entries) => entries,
371            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
372            Err(error) => {
373                return Err(BypassReason::InputRead {
374                    path: directory,
375                    message: error.to_string(),
376                });
377            }
378        };
379        for entry in entries {
380            let entry = entry.map_err(|error| BypassReason::InputRead {
381                path: directory.clone(),
382                message: error.to_string(),
383            })?;
384            let path = entry.path();
385            let file_type = entry.file_type().map_err(|error| BypassReason::InputRead {
386                path: path.clone(),
387                message: error.to_string(),
388            })?;
389            if file_type.is_dir() {
390                pending.push(path);
391            } else if file_type.is_file() {
392                *native_bytes = native_bytes
393                    .checked_add(
394                        entry
395                            .metadata()
396                            .map_err(|error| BypassReason::InputRead {
397                                path: path.clone(),
398                                message: error.to_string(),
399                            })?
400                            .len(),
401                    )
402                    .ok_or_else(|| BypassReason::UnsupportedSearchPath("native".into()))?;
403                paths.insert(path);
404            } else {
405                return Err(BypassReason::UnsupportedSearchPath("native".into()));
406            }
407            if paths.len() > MAX_PREDICTED_INPUTS || *native_bytes > MAX_NATIVE_INPUT_BYTES {
408                return Err(BypassReason::UnsupportedSearchPath("native".into()));
409            }
410        }
411    }
412    Ok(())
413}
414
415fn render_argument(argument: &Argument) -> Result<OsString, BypassReason> {
416    let rendered = match argument {
417        Argument::Plain(value) => value.clone(),
418        Argument::Path { flag, path } => format!(
419            "{flag}={}",
420            path.to_str()
421                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
422        ),
423        Argument::SearchPath { kind, path } => format!(
424            "-L{kind}={}",
425            path.to_str()
426                .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
427        ),
428        Argument::Extern { name, path } => match path {
429            Some(path) => format!(
430                "--extern={name}={}",
431                path.to_str()
432                    .ok_or_else(|| BypassReason::NonUtf8Path(path.clone()))?
433            ),
434            None => format!("--extern={name}"),
435        },
436        Argument::Emit(_) => unreachable!("emit arguments are removed before rendering"),
437        Argument::RemapPath { from, to } => format!(
438            "--remap-path-prefix={}={to}",
439            from.to_str()
440                .ok_or_else(|| BypassReason::NonUtf8Path(from.clone()))?
441        ),
442    };
443    Ok(rendered.into())
444}
445
446fn unescape_environment(value: &str) -> Result<String, BypassReason> {
447    let mut output = String::with_capacity(value.len());
448    let mut characters = value.chars();
449    while let Some(character) = characters.next() {
450        if character != '\\' {
451            output.push(character);
452            continue;
453        }
454        match characters.next() {
455            Some('\\') => output.push('\\'),
456            Some('n') => output.push('\n'),
457            Some('r') => output.push('\r'),
458            Some(character) => {
459                return Err(BypassReason::MalformedDepInfo(format!(
460                    "unknown environment escape \\{character}"
461                )));
462            }
463            None => {
464                return Err(BypassReason::MalformedDepInfo(
465                    "environment input ends with an unterminated escape".into(),
466                ));
467            }
468        }
469    }
470    Ok(output)
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use std::process::Command;
477
478    fn args(values: &[&str]) -> Vec<OsString> {
479        values.iter().map(OsString::from).collect()
480    }
481
482    #[test]
483    fn parses_files_spaces_and_environment_records() {
484        let parsed = RustcDepInfo::parse(
485            "target/lib.rlib: src/lib.rs src/a\\ file.rs generated.rs\n\
486             src/lib.rs:\n\
487             # env-dep:SET=value\\nnext\n\
488             # env-dep:UNSET\n\
489             # env-dep:SLASH=a\\\\b\n",
490        )
491        .unwrap();
492        assert_eq!(
493            parsed.files,
494            vec![
495                PathBuf::from("generated.rs"),
496                PathBuf::from("src/a file.rs"),
497                PathBuf::from("src/lib.rs"),
498            ]
499        );
500        assert_eq!(parsed.environment["SET"], Some("value\nnext".into()));
501        assert_eq!(parsed.environment["UNSET"], None);
502        assert_eq!(parsed.environment["SLASH"], Some(r"a\b".into()));
503    }
504
505    #[test]
506    fn malformed_dep_info_bypasses_caching() {
507        for contents in [
508            "",
509            "target: ",
510            "target: src/trailing\\\n",
511            "target: src/lib.rs\n# env-dep:NAME=bad\\q\n",
512        ] {
513            assert!(RustcDepInfo::parse(contents).is_err(), "{contents:?}");
514        }
515    }
516
517    #[test]
518    fn native_directory_byte_limit_is_cumulative() {
519        let directory = tempfile::tempdir().unwrap();
520        let native = directory.path().join("native");
521        std::fs::create_dir_all(&native).unwrap();
522        std::fs::write(native.join("input.lib"), b"xx").unwrap();
523        let roots = native_input_roots(directory.path(), &[]);
524        let mut paths = BTreeSet::new();
525        let mut native_bytes = MAX_NATIVE_INPUT_BYTES - 1;
526
527        assert_eq!(
528            collect_native_directory(&native, &roots, &mut paths, &mut native_bytes),
529            Err(BypassReason::UnsupportedSearchPath("native".into()))
530        );
531    }
532
533    #[test]
534    fn discovery_command_removes_real_outputs() {
535        let invocation = RustcInvocation::parse(&args(&[
536            "--crate-name=widget",
537            "--crate-type=lib",
538            "--emit=dep-info,metadata,link",
539            "--out-dir=target/debug/deps",
540            "-o",
541            "target/debug/libwidget.rlib",
542            "src/lib.rs",
543        ]))
544        .unwrap();
545        let output = if cfg!(windows) {
546            PathBuf::from(r"C:\tmp\mbx cache\inputs.d")
547        } else {
548            PathBuf::from("/tmp/mbx cache/inputs.d")
549        };
550        let command = invocation.dep_info_command(&output).unwrap();
551        let arguments = command
552            .arguments()
553            .iter()
554            .map(|value| value.to_string_lossy())
555            .collect::<Vec<_>>();
556        assert_eq!(
557            arguments,
558            vec![
559                "--crate-name=widget",
560                "--crate-type=lib",
561                &format!("--emit=dep-info={}", output.display()),
562                "src/lib.rs",
563            ]
564        );
565    }
566
567    #[test]
568    fn discovery_hashes_externs_and_custom_targets() {
569        let directory = tempfile::tempdir().unwrap();
570        let root = directory.path();
571        let source = root.join("src/lib.rs");
572        let external = root.join("target/libdependency.rlib");
573        let target = root.join("targets/custom.json");
574        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
575        std::fs::create_dir_all(external.parent().unwrap()).unwrap();
576        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
577        std::fs::write(&source, "pub fn library() {}\n").unwrap();
578        std::fs::write(&external, "dependency artifact\n").unwrap();
579        std::fs::write(&target, "{}\n").unwrap();
580
581        let invocation = RustcInvocation::parse(&[
582            "--crate-name=widget".into(),
583            "--crate-type=lib".into(),
584            "--emit=metadata".into(),
585            format!("--extern=dependency={}", external.display()).into(),
586            format!("--target={}", target.display()).into(),
587            source.clone().into_os_string(),
588        ])
589        .unwrap();
590        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
591        let discovered = invocation.discover_inputs(&dep_info, root).unwrap();
592        assert_eq!(discovered.inputs.len(), 3);
593
594        std::fs::remove_file(&external).unwrap();
595        assert!(matches!(
596            invocation.discover_inputs(&dep_info, root),
597            Err(BypassReason::InputRead { path, .. }) if path == external
598        ));
599    }
600
601    #[test]
602    fn discovery_rejects_inputs_modified_during_compilation() {
603        let directory = tempfile::tempdir().unwrap();
604        let source = directory.path().join("lib.rs");
605        std::fs::write(&source, "pub fn library() {}\n").unwrap();
606        let invocation = RustcInvocation::parse(&[
607            "--crate-name=widget".into(),
608            "--crate-type=lib".into(),
609            "--emit=dep-info,metadata".into(),
610            source.clone().into_os_string(),
611        ])
612        .unwrap();
613        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
614        let discovered = invocation
615            .discover_inputs(&dep_info, directory.path())
616            .unwrap();
617        let modified = std::fs::metadata(&source).unwrap().modified().unwrap();
618
619        assert_eq!(
620            discovered.verify_not_modified_since(modified),
621            Err(BypassReason::InputModifiedDuringCompilation(source))
622        );
623    }
624
625    /// The MSVC toolset directories `cc`-built dependencies hand to every
626    /// downstream compile on Windows: absolute, version-stamped, and outside
627    /// every mapped root.
628    fn toolchain_native_directory(version: &str) -> PathBuf {
629        if cfg!(windows) {
630            PathBuf::from(format!(r"C:\Program Files\MSVC\{version}\lib\x64"))
631        } else {
632            PathBuf::from(format!("/opt/msvc/{version}/lib/x64"))
633        }
634    }
635
636    fn library_with_native_search(source: &Path, directory: &Path) -> RustcInvocation {
637        RustcInvocation::parse(&[
638            "--crate-name=widget".into(),
639            "--crate-type=lib".into(),
640            "--emit=metadata,link".into(),
641            format!("-Lnative={}", directory.display()).into(),
642            source.to_path_buf().into_os_string(),
643        ])
644        .unwrap()
645    }
646
647    fn library_context(root: &Path, mappings: Vec<PathMapping>) -> ActionContext {
648        ActionContext {
649            compiler: crate::CompilerIdentity {
650                toolchain: "core:rust@test".into(),
651                rustc_version: "test".into(),
652                host: std::env::consts::ARCH.into(),
653            },
654            working_dir: root.to_path_buf(),
655            path_mappings: mappings,
656            environment: BTreeMap::new(),
657            portable_environment: BTreeSet::new(),
658            inputs: Vec::new(),
659        }
660    }
661
662    #[test]
663    fn unmapped_native_directory_is_keyed_by_path_for_library_emits() {
664        let directory = tempfile::tempdir().unwrap();
665        let root = directory.path();
666        let source = root.join("lib.rs");
667        std::fs::write(&source, "pub fn library() {}\n").unwrap();
668        let toolchain = toolchain_native_directory("14.51.36231");
669        let invocation = library_with_native_search(&source, &toolchain);
670        let mappings = vec![PathMapping::new(root, "workspace")];
671        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
672
673        // The directory does not even exist: its contents are not inputs.
674        let discovered = invocation
675            .discover_inputs_with_mappings(&dep_info, root, &mappings)
676            .unwrap();
677        assert_eq!(discovered.inputs.len(), 1);
678        assert_eq!(discovered.inputs[0].path, source);
679
680        // The literal path is key material, so a toolset update misses.
681        let context = library_context(root, mappings.clone());
682        let digest = invocation.invocation_digest(&context).unwrap();
683        let updated = library_with_native_search(&source, &toolchain_native_directory("14.52.0"));
684        assert_ne!(digest, updated.invocation_digest(&context).unwrap());
685
686        // The prediction skips the directory the same way discovery does, so a
687        // build that replays it derives the action key dep-info would have.
688        let mut recorded = context.clone();
689        discovered.clone().apply_to(&mut recorded).unwrap();
690        let action = invocation.action(recorded).unwrap();
691        let prediction = invocation.prediction(&context, &discovered).unwrap();
692        let replayed = prediction.discover(root, &context.path_mappings).unwrap();
693        let mut replay_context = context.clone();
694        replayed.apply_to(&mut replay_context).unwrap();
695        assert_eq!(
696            invocation.action(replay_context).unwrap().digest,
697            action.digest
698        );
699    }
700
701    #[test]
702    fn unmapped_native_directory_still_refuses_a_native_link() {
703        let directory = tempfile::tempdir().unwrap();
704        let root = directory.path();
705        let source = root.join("main.rs");
706        std::fs::write(&source, "fn main() {}\n").unwrap();
707        let toolchain = toolchain_native_directory("14.51.36231");
708        let invocation = RustcInvocation::parse_with(
709            &[
710                "--crate-name=app".into(),
711                "--crate-type=bin".into(),
712                "--emit=link".into(),
713                format!("-Lnative={}", toolchain.display()).into(),
714                source.clone().into_os_string(),
715            ],
716            crate::ParseOptions::caching_native_links(true),
717        )
718        .unwrap();
719        let mappings = vec![PathMapping::new(root, "workspace")];
720
721        // A linker reads those directories, so their contents stay inputs the
722        // key must account for, and an unmapped one stays a bypass.
723        let context = library_context(root, mappings.clone());
724        assert!(matches!(
725            invocation.invocation_digest(&context),
726            Err(BypassReason::UnmappedAbsolutePath(_))
727        ));
728        let dep_info = RustcDepInfo::parse(&format!("output: {}\n", source.display())).unwrap();
729        assert_eq!(
730            invocation.discover_inputs_with_mappings(&dep_info, root, &mappings),
731            Err(BypassReason::UnsupportedSearchPath("native".into()))
732        );
733    }
734
735    #[test]
736    fn discovery_resolves_parent_components_against_the_working_directory() {
737        let directory = tempfile::tempdir().unwrap();
738        let root = directory.path().join("project");
739        let shared = directory.path().join("shared.rs");
740        std::fs::create_dir(&root).unwrap();
741        std::fs::write(&shared, "pub fn shared() {}\n").unwrap();
742
743        let invocation = RustcInvocation::parse(&args(&[
744            "--crate-name=widget",
745            "--crate-type=lib",
746            "--emit=metadata",
747            "../shared.rs",
748        ]))
749        .unwrap();
750        let dep_info = RustcDepInfo::parse("output: ../shared.rs\n").unwrap();
751        let discovered = invocation.discover_inputs(&dep_info, &root).unwrap();
752
753        assert_eq!(discovered.inputs.len(), 1);
754        assert_eq!(discovered.inputs[0].path, shared);
755    }
756
757    #[test]
758    fn rustc_dep_info_round_trip_discovers_real_inputs() {
759        let directory = tempfile::tempdir().unwrap();
760        let root = directory.path();
761        std::fs::write(
762            root.join("lib.rs"),
763            "mod child; const _: &str = include_str!(\"data file.txt\"); \
764             const _: &str = env!(\"MBX_DISCOVERY_TEST\"); \
765             const _: Option<&str> = option_env!(\"MBX_DISCOVERY_UNSET\");",
766        )
767        .unwrap();
768        std::fs::write(root.join("child.rs"), "pub fn child() {}\n").unwrap();
769        std::fs::write(root.join("data file.txt"), "included\n").unwrap();
770
771        let invocation = RustcInvocation::parse(&args(&[
772            "--crate-name=mbx_cache_discovery_test",
773            "--crate-type=lib",
774            "--emit=metadata,link",
775            "lib.rs",
776        ]))
777        .unwrap();
778        let dep_info_path = root.join("discovery inputs.d");
779        let discovery_command = invocation.dep_info_command(&dep_info_path).unwrap();
780        let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
781        let output = match Command::new(rustc)
782            .args(discovery_command.arguments())
783            .current_dir(root)
784            .env("MBX_DISCOVERY_TEST", "observed")
785            .env_remove("MBX_DISCOVERY_UNSET")
786            .output()
787        {
788            Ok(output) => output,
789            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
790            Err(error) => panic!("failed to execute rustc: {error}"),
791        };
792        assert!(
793            output.status.success(),
794            "{}",
795            String::from_utf8_lossy(&output.stderr)
796        );
797        let parsed = RustcDepInfo::read(&dep_info_path).unwrap();
798        let discovered = invocation.discover_inputs(&parsed, root).unwrap();
799        assert_eq!(
800            discovered.environment["MBX_DISCOVERY_TEST"],
801            Some("observed".into())
802        );
803        assert_eq!(discovered.environment["MBX_DISCOVERY_UNSET"], None);
804        assert_eq!(discovered.inputs.len(), 3);
805        assert!(
806            discovered
807                .inputs
808                .iter()
809                .all(|input| input.digest.algorithm == "blake3")
810        );
811        let mut context = ActionContext {
812            compiler: crate::CompilerIdentity {
813                toolchain: "core:rust@test".into(),
814                rustc_version: "test".into(),
815                host: std::env::consts::ARCH.into(),
816            },
817            working_dir: root.to_path_buf(),
818            path_mappings: vec![crate::PathMapping::new(root, "workspace")],
819            environment: BTreeMap::new(),
820            portable_environment: BTreeSet::new(),
821            inputs: Vec::new(),
822        };
823        discovered.clone().apply_to(&mut context).unwrap();
824        let action = invocation.action(context).unwrap();
825        assert!(
826            String::from_utf8(action.bytes)
827                .unwrap()
828                .contains(r#""MBX_DISCOVERY_TEST":"observed""#)
829        );
830        discovered.verify().unwrap();
831        std::fs::write(root.join("child.rs"), "pub fn changed() {}\n").unwrap();
832        assert_eq!(
833            discovered.verify(),
834            Err(BypassReason::InputChanged(root.join("child.rs")))
835        );
836    }
837}