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#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct DepInfoCommand {
14 arguments: Vec<OsString>,
15 output: PathBuf,
16}
17
18impl DepInfoCommand {
19 pub fn arguments(&self) -> &[OsString] {
21 &self.arguments
22 }
23
24 pub fn output(&self) -> &Path {
26 &self.output
27 }
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct RustcDepInfo {
33 pub files: Vec<PathBuf>,
35 pub environment: BTreeMap<String, Option<String>>,
37}
38
39impl RustcDepInfo {
40 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 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#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct DiscoveredInputs {
132 working_dir: PathBuf,
133 pub inputs: Vec<ActionInput>,
135 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 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 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 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 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 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 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 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
340pub(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
351pub(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 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 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 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 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 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}