1use mbx_cache_core::{CacheDigest, canonical_json};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use std::ffi::OsString;
5use std::path::{Component, Path, PathBuf};
6use thiserror::Error;
7
8mod dep_info;
9
10pub use dep_info::{DepInfoCommand, DiscoveredInputs, RustcDepInfo};
11
12pub const ACTION_SCHEMA_VERSION: u8 = 1;
13pub const ADAPTER_VERSION: u8 = 1;
14
15impl BypassReason {
16 pub fn kind(&self) -> &'static str {
21 self.into()
22 }
23}
24
25const SUPPORTED_CODEGEN_OPTIONS: &[&str] = &[
26 "codegen-units",
27 "control-flow-guard",
28 "debug-assertions",
29 "debuginfo",
30 "default-linker-libraries",
31 "embed-bitcode",
32 "extra-filename",
33 "force-frame-pointers",
34 "force-unwind-tables",
35 "instrument-coverage",
36 "link-dead-code",
37 "link-self-contained",
38 "lto",
39 "metadata",
40 "no-prepopulate-passes",
41 "opt-level",
42 "overflow-checks",
43 "panic",
44 "prefer-dynamic",
45 "relocation-model",
46 "rpath",
47 "save-temps",
48 "soft-float",
49 "split-debuginfo",
50 "split-dwarf-kind",
51 "strip",
52 "symbol-mangling-version",
53 "target-cpu",
54 "target-feature",
55 "tls-model",
56];
57
58#[derive(Debug, Clone, PartialEq, Eq, Error, strum::IntoStaticStr)]
59#[strum(serialize_all = "kebab-case")]
60pub enum BypassReason {
61 #[error("rustc argument {index} is not valid UTF-8")]
62 NonUtf8Argument { index: usize },
63 #[error("rustc response files are not supported: {0}")]
64 ResponseFile(String),
65 #[error("rustc flag is not modeled by the cache adapter: {0}")]
66 UnknownFlag(String),
67 #[error("rustc codegen option is not modeled by the cache adapter: {0}")]
68 UnknownCodegenOption(String),
69 #[error("rustc flag requires a value: {0}")]
70 MissingValue(String),
71 #[error("rustc invocation is a compiler query, not a compilation")]
72 CompilerQuery,
73 #[error("rustc invocation reads source from standard input")]
74 StandardInput,
75 #[error("rustc invocation has no source input")]
76 MissingInput,
77 #[error("rustc invocation has multiple source inputs")]
78 MultipleInputs,
79 #[error("incremental compilation cannot be combined with action caching")]
80 Incremental,
81 #[error("rustc crate type is not cacheable yet: {0}")]
82 UnsupportedCrateType(String),
83 #[error("rustc output type is not cacheable yet: {0}")]
84 UnsupportedEmit(String),
85 #[error("rustc invocation does not emit an rlib or metadata artifact")]
86 NoCacheableOutput,
87 #[error("rustc invocation does not emit dependency information")]
88 NoDepInfo,
89 #[error("rustc output paths do not share one directory")]
90 SplitOutputDirectories,
91 #[error("rustc output path has no file name: {0}")]
92 InvalidOutputPath(PathBuf),
93 #[error("rustc -o with an emit that has no explicit path is not modeled: {0}")]
94 ImplicitEmitWithOutputFile(PathBuf),
95 #[error("native library lookup is not cacheable yet")]
96 NativeLibrary,
97 #[error("rustc search path kind is not cacheable yet: {0}")]
98 UnsupportedSearchPath(String),
99 #[error("rustc extern does not identify an input artifact: {0}")]
100 UnresolvedExtern(String),
101 #[error("absolute path has no stable cache mapping: {0}")]
102 UnmappedAbsolutePath(PathBuf),
103 #[error("cache key paths must be valid UTF-8: {0}")]
104 NonUtf8Path(PathBuf),
105 #[error("cache action working directory must be absolute: {0}")]
106 RelativeWorkingDirectory(PathBuf),
107 #[error("cache path mapping must use an absolute root: {0}")]
108 RelativePathMapping(PathBuf),
109 #[error("cache path mapping placeholder is invalid: {0}")]
110 InvalidPathPlaceholder(String),
111 #[error("required compiler input was not provided: {0}")]
112 MissingRequiredInput(String),
113 #[error("compiler input has an invalid digest: {0}")]
114 InvalidInputDigest(String),
115 #[error("compiler input appears more than once with different content: {0}")]
116 ConflictingInput(String),
117 #[error("rustc dep-info is malformed: {0}")]
118 MalformedDepInfo(String),
119 #[error("failed to read rustc dep-info {path}: {message}")]
120 DepInfoRead { path: PathBuf, message: String },
121 #[error("rustc dep-info output path must be absolute: {0}")]
122 RelativeDepInfoPath(PathBuf),
123 #[error("rustc dep-info output path cannot contain a comma: {0}")]
124 UnsafeDepInfoPath(PathBuf),
125 #[error("failed to read compiler input {path}: {message}")]
126 InputRead { path: PathBuf, message: String },
127 #[error("compiler input changed after discovery: {0}")]
128 InputChanged(PathBuf),
129 #[error("compiler input was modified during compilation: {0}")]
130 InputModifiedDuringCompilation(PathBuf),
131 #[error("discovered inputs were collected from a different working directory")]
132 DiscoveryWorkingDirectory,
133 #[error("compiler environment input has conflicting values: {0}")]
134 ConflictingEnvironment(String),
135 #[error("failed to serialize the rustc action: {0}")]
136 Serialization(String),
137 #[error("rustc action prediction is unsupported")]
138 UnsupportedPrediction,
139 #[error("rustc action prediction contains an invalid input path: {0}")]
140 InvalidPredictedInput(String),
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144enum Argument {
145 Plain(String),
146 Path { flag: String, path: PathBuf },
147 SearchPath { kind: String, path: PathBuf },
148 Extern { name: String, path: Option<PathBuf> },
149 Emit(Vec<Emit>),
150 RemapPath { from: PathBuf, to: String },
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
154struct Emit {
155 kind: String,
156 path: Option<PathBuf>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct RustcInvocation {
161 arguments: Vec<Argument>,
162 source: PathBuf,
163 required_inputs: Vec<PathBuf>,
164 crate_name: String,
165 extra_filename: String,
166 out_dir: Option<PathBuf>,
167 explicit_output: Option<PathBuf>,
168 emits: Vec<Emit>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct RustcOutputs {
174 pub directory: PathBuf,
175 pub files: Vec<PathBuf>,
176 pub dep_info: PathBuf,
177}
178
179impl RustcInvocation {
180 pub fn parse(arguments: &[OsString]) -> Result<Self, BypassReason> {
187 Parser::new(arguments).parse()
188 }
189
190 pub fn source(&self) -> &Path {
192 &self.source
193 }
194
195 pub fn outputs(&self, working_dir: &Path) -> Result<RustcOutputs, BypassReason> {
200 if !working_dir.is_absolute() {
201 return Err(BypassReason::RelativeWorkingDirectory(
202 working_dir.to_path_buf(),
203 ));
204 }
205 let explicit_output = self
206 .explicit_output
207 .as_deref()
208 .map(|path| absolute_path(path, working_dir));
209 let output_directory = explicit_output
210 .as_deref()
211 .and_then(Path::parent)
212 .map(Path::to_path_buf)
213 .or_else(|| {
214 self.out_dir
215 .as_deref()
216 .map(|path| absolute_path(path, working_dir))
217 })
218 .unwrap_or_else(|| normalize_components(working_dir));
219 if let Some(output) = &explicit_output
223 && self.emits.iter().any(|emit| {
224 emit.path.is_none()
225 && matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata")
226 })
227 {
228 return Err(BypassReason::ImplicitEmitWithOutputFile(output.clone()));
229 }
230 let mut files = BTreeSet::new();
231 let mut dep_info = None;
232 for emit in &self.emits {
233 if emit.kind == "dep-info" {
234 let path = emit.path.as_ref().map_or_else(
235 || {
236 explicit_output.clone().map_or_else(
237 || {
238 output_directory
239 .join(format!("{}{}.d", self.crate_name, self.extra_filename))
240 },
241 |path| path.with_extension("d"),
242 )
243 },
244 |path| absolute_path(path, working_dir),
245 );
246 if path.file_name().is_none() {
247 return Err(BypassReason::InvalidOutputPath(path));
248 }
249 dep_info = Some(path);
250 continue;
251 }
252 let extension = match emit.kind.as_str() {
253 "link" => "rlib",
254 "metadata" => "rmeta",
255 _ => continue,
256 };
257 let path = if let Some(path) = &emit.path {
258 absolute_path(path, working_dir)
259 } else {
260 output_directory.join(format!(
261 "lib{}{}.{}",
262 self.crate_name, self.extra_filename, extension
263 ))
264 };
265 if path.file_name().is_none() {
266 return Err(BypassReason::InvalidOutputPath(path));
267 }
268 if path.parent() != Some(output_directory.as_path()) {
269 return Err(BypassReason::SplitOutputDirectories);
270 }
271 files.insert(path);
272 }
273 let dep_info = dep_info.ok_or(BypassReason::NoDepInfo)?;
274 if dep_info.parent() != Some(output_directory.as_path()) {
275 return Err(BypassReason::SplitOutputDirectories);
276 }
277 Ok(RustcOutputs {
278 directory: output_directory,
279 files: files.into_iter().collect(),
280 dep_info,
281 })
282 }
283
284 pub fn action(&self, context: ActionContext) -> Result<RustcAction, BypassReason> {
290 ActionBuilder::new(self, context).build()
291 }
292
293 pub fn invocation_digest(&self, context: &ActionContext) -> Result<CacheDigest, BypassReason> {
295 let descriptor = ActionBuilder::new(self, context.clone()).invocation_descriptor()?;
296 let bytes = canonical_json(&descriptor)
297 .map_err(|error| BypassReason::Serialization(error.to_string()))?;
298 Ok(CacheDigest::blake3(&bytes))
299 }
300
301 pub fn prediction(
304 &self,
305 context: &ActionContext,
306 discovered: &DiscoveredInputs,
307 ) -> Result<RustcInputPrediction, BypassReason> {
308 let builder = ActionBuilder::new(self, context.clone());
309 builder.validate_mappings()?;
310 let inputs = discovered
311 .inputs
312 .iter()
313 .map(|input| builder.normalize_path(&input.path))
314 .collect::<Result<BTreeSet<_>, _>>()?
315 .into_iter()
316 .collect();
317 Ok(RustcInputPrediction {
318 version: 1,
319 inputs,
320 environment: discovered.environment.keys().cloned().collect(),
321 })
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct PathMapping {
327 pub root: PathBuf,
328 pub placeholder: String,
329}
330
331impl PathMapping {
332 pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
334 Self {
335 root: root.into(),
336 placeholder: placeholder.into(),
337 }
338 }
339
340 pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
343 let mut ordered = mappings.to_vec();
344 ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
345 ordered
346 }
347}
348
349pub fn normalize_mapped_path(
356 path: &Path,
357 working_dir: &Path,
358 mappings: &[PathMapping],
359) -> Result<String, BypassReason> {
360 let absolute = if path.is_absolute() {
361 normalize_components(path)
362 } else {
363 normalize_components(&working_dir.join(path))
364 };
365 for mapping in mappings {
366 let root = normalize_components(&mapping.root);
367 if let Ok(relative) = absolute.strip_prefix(&root) {
368 let suffix = slash_path(relative)?;
369 return Ok(if suffix.is_empty() {
370 format!("${{{}}}", mapping.placeholder)
371 } else {
372 format!("${{{}}}/{suffix}", mapping.placeholder)
373 });
374 }
375 }
376 Err(BypassReason::UnmappedAbsolutePath(absolute))
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct CompilerIdentity {
381 pub toolchain: String,
382 pub rustc_version: String,
383 pub host: String,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct ActionInput {
388 pub path: PathBuf,
389 pub digest: CacheDigest,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct ActionContext {
394 pub compiler: CompilerIdentity,
395 pub working_dir: PathBuf,
396 pub path_mappings: Vec<PathMapping>,
397 pub environment: BTreeMap<String, Option<String>>,
398 pub portable_environment: BTreeSet<String>,
405 pub inputs: Vec<ActionInput>,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct RustcAction {
410 pub digest: CacheDigest,
411 pub bytes: Vec<u8>,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct RustcInputPrediction {
419 pub version: u8,
420 pub inputs: Vec<String>,
421 pub environment: Vec<String>,
422}
423
424impl RustcInputPrediction {
425 pub fn discover(
428 &self,
429 working_dir: &Path,
430 path_mappings: &[PathMapping],
431 ) -> Result<DiscoveredInputs, BypassReason> {
432 if self.version != 1 {
433 return Err(BypassReason::UnsupportedPrediction);
434 }
435 if self.inputs.len() > 16 * 1024 || self.environment.len() > 4 * 1024 {
436 return Err(BypassReason::UnsupportedPrediction);
437 }
438 let paths = self
439 .inputs
440 .iter()
441 .map(|path| denormalize_path(path, path_mappings))
442 .collect::<Result<BTreeSet<_>, _>>()?;
443 let environment = self
444 .environment
445 .iter()
446 .map(|name| {
447 if name.is_empty() || name.contains(['=', '\0']) {
448 return Err(BypassReason::UnsupportedPrediction);
449 }
450 let value = std::env::var_os(name)
451 .map(|value| {
452 value
453 .into_string()
454 .map_err(|_| BypassReason::UnsupportedPrediction)
455 })
456 .transpose()?;
457 Ok((name.clone(), value))
458 })
459 .collect::<Result<BTreeMap<_, _>, _>>()?;
460 DiscoveredInputs::from_paths(working_dir, paths, environment)
461 }
462}
463
464#[derive(Serialize)]
465struct ActionDescriptor {
466 version: u8,
467 kind: &'static str,
468 adapter_version: u8,
469 compiler: CompilerDescriptor,
470 arguments: Vec<String>,
471 environment: BTreeMap<String, Option<String>>,
472 inputs: Vec<InputDescriptor>,
473}
474
475#[derive(Serialize)]
476struct InvocationDescriptor {
477 version: u8,
478 kind: &'static str,
479 adapter_version: u8,
480 compiler: CompilerDescriptor,
481 arguments: Vec<String>,
482 required_inputs: Vec<String>,
483}
484
485#[derive(Serialize)]
486struct CompilerDescriptor {
487 toolchain: String,
488 rustc_version: String,
489 host: String,
490}
491
492#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
493struct InputDescriptor {
494 path: String,
495 digest: CacheDigest,
496}
497
498struct Parser<'a> {
499 arguments: &'a [OsString],
500 index: usize,
501 parsed: Vec<Argument>,
502 source: Option<PathBuf>,
503 crate_types: Vec<String>,
504 emits: Vec<Emit>,
505 required_inputs: Vec<PathBuf>,
506 test: bool,
507 crate_name: Option<String>,
508 extra_filename: String,
509 out_dir: Option<PathBuf>,
510 explicit_output: Option<PathBuf>,
511}
512
513impl<'a> Parser<'a> {
514 fn new(arguments: &'a [OsString]) -> Self {
515 Self {
516 arguments,
517 index: 0,
518 parsed: Vec::new(),
519 source: None,
520 crate_types: Vec::new(),
521 emits: Vec::new(),
522 required_inputs: Vec::new(),
523 test: false,
524 crate_name: None,
525 extra_filename: String::new(),
526 out_dir: None,
527 explicit_output: None,
528 }
529 }
530
531 fn parse(mut self) -> Result<RustcInvocation, BypassReason> {
532 while self.index < self.arguments.len() {
533 let value = self.current()?.to_string();
534 self.index += 1;
535 if value.starts_with('@') {
536 return Err(BypassReason::ResponseFile(value));
537 }
538 if let Some(long) = value.strip_prefix("--") {
539 self.parse_long(long)?;
540 } else if value.starts_with('-') && value != "-" {
541 self.parse_short(&value)?;
542 } else {
543 self.parse_input(&value)?;
544 }
545 }
546
547 let source = self.source.clone().ok_or(BypassReason::MissingInput)?;
548 self.classify()?;
549 let crate_name = self.crate_name.clone().map_or_else(
550 || {
551 source
552 .file_stem()
553 .and_then(|name| name.to_str())
554 .map(|name| name.replace('-', "_"))
555 .ok_or_else(|| BypassReason::NonUtf8Path(source.clone()))
556 },
557 Ok,
558 )?;
559 self.required_inputs.push(source.clone());
560 Ok(RustcInvocation {
561 arguments: self.parsed,
562 source,
563 required_inputs: self.required_inputs,
564 crate_name,
565 extra_filename: self.extra_filename,
566 out_dir: self.out_dir,
567 explicit_output: self.explicit_output,
568 emits: self.emits,
569 })
570 }
571
572 fn current(&self) -> Result<&str, BypassReason> {
573 self.arguments[self.index]
574 .to_str()
575 .ok_or(BypassReason::NonUtf8Argument { index: self.index })
576 }
577
578 fn take_value(&mut self, flag: &str, inline: Option<&str>) -> Result<String, BypassReason> {
579 if let Some(value) = inline {
580 if value.is_empty() {
581 return Err(BypassReason::MissingValue(flag.into()));
582 }
583 return Ok(value.into());
584 }
585 if self.index >= self.arguments.len() {
586 return Err(BypassReason::MissingValue(flag.into()));
587 }
588 let value = self.current()?.to_string();
589 self.index += 1;
590 Ok(value)
591 }
592
593 fn parse_long(&mut self, value: &str) -> Result<(), BypassReason> {
594 let (flag, inline) = value
595 .split_once('=')
596 .map_or((value, None), |(flag, value)| (flag, Some(value)));
597 let rendered_flag = format!("--{flag}");
598 match flag {
599 "help" | "version" | "explain" | "print" => Err(BypassReason::CompilerQuery),
600 "test" => {
601 self.test = true;
602 self.parsed.push(Argument::Plain(rendered_flag));
603 Ok(())
604 }
605 "verbose" => {
606 self.parsed.push(Argument::Plain(rendered_flag));
607 Ok(())
608 }
609 "crate-name" => {
610 let value = self.take_value(&rendered_flag, inline)?;
611 self.crate_name = Some(value.clone());
612 self.parsed
613 .push(Argument::Plain(format!("{rendered_flag}={value}")));
614 Ok(())
615 }
616 "cfg" | "check-cfg" | "edition" | "error-format" | "json" | "color"
617 | "diagnostic-width" | "remap-path-scope" | "allow" | "warn" | "force-warn"
618 | "deny" | "forbid" | "cap-lints" => {
619 let value = self.take_value(&rendered_flag, inline)?;
620 self.parsed
621 .push(Argument::Plain(format!("{rendered_flag}={value}")));
622 Ok(())
623 }
624 "target" => {
625 let value = self.take_value(&rendered_flag, inline)?;
626 if value.ends_with(".json") || value.contains(['/', '\\']) {
627 let path = PathBuf::from(value);
628 self.required_inputs.push(path.clone());
629 self.parsed.push(Argument::Path {
630 flag: rendered_flag,
631 path,
632 });
633 } else {
634 self.parsed
635 .push(Argument::Plain(format!("{rendered_flag}={value}")));
636 }
637 Ok(())
638 }
639 "crate-type" => {
640 let value = self.take_value(&rendered_flag, inline)?;
641 self.crate_types
642 .extend(value.split(',').map(ToOwned::to_owned));
643 self.parsed
644 .push(Argument::Plain(format!("{rendered_flag}={value}")));
645 Ok(())
646 }
647 "emit" => {
648 let value = self.take_value(&rendered_flag, inline)?;
649 let emits = parse_emits(&value);
650 self.emits.extend(emits.clone());
651 self.parsed.push(Argument::Emit(emits));
652 Ok(())
653 }
654 "out-dir" => {
655 let path = PathBuf::from(self.take_value(&rendered_flag, inline)?);
656 self.out_dir = Some(path.clone());
657 self.parsed.push(Argument::Path {
658 flag: rendered_flag,
659 path,
660 });
661 Ok(())
662 }
663 "sysroot" => {
664 let path = self.take_value(&rendered_flag, inline)?;
665 self.parsed.push(Argument::Path {
666 flag: rendered_flag,
667 path: path.into(),
668 });
669 Ok(())
670 }
671 "extern" => {
672 let value = self.take_value(&rendered_flag, inline)?;
673 let (name, path) = value
674 .split_once('=')
675 .map_or((value.as_str(), None), |(name, path)| {
676 (name, Some(PathBuf::from(path)))
677 });
678 if let Some(path) = &path {
679 self.required_inputs.push(path.clone());
680 }
681 self.parsed.push(Argument::Extern {
682 name: name.into(),
683 path,
684 });
685 Ok(())
686 }
687 "remap-path-prefix" => {
688 let value = self.take_value(&rendered_flag, inline)?;
689 let Some((from, to)) = value.split_once('=') else {
690 return Err(BypassReason::MissingValue(rendered_flag));
691 };
692 self.parsed.push(Argument::RemapPath {
693 from: from.into(),
694 to: to.into(),
695 });
696 Ok(())
697 }
698 "codegen" => {
699 let value = self.take_value(&rendered_flag, inline)?;
700 self.parse_codegen(&value)
701 }
702 _ => Err(BypassReason::UnknownFlag(rendered_flag)),
703 }
704 }
705
706 fn parse_short(&mut self, value: &str) -> Result<(), BypassReason> {
707 match value {
708 "-h" | "-V" | "-vV" => return Err(BypassReason::CompilerQuery),
711 "-g" | "-O" | "-v" => {
712 self.parsed.push(Argument::Plain(value.into()));
713 return Ok(());
714 }
715 _ => {}
716 }
717 for (short, long) in [
718 ("-A", "--allow"),
719 ("-W", "--warn"),
720 ("-D", "--deny"),
721 ("-F", "--forbid"),
722 ] {
723 if let Some(attached) = value.strip_prefix(short) {
724 let lint = self.take_value(short, (!attached.is_empty()).then_some(attached))?;
725 self.parsed.push(Argument::Plain(format!("{long}={lint}")));
726 return Ok(());
727 }
728 }
729 if let Some(attached) = value.strip_prefix("-C") {
730 let option = self.take_value("-C", (!attached.is_empty()).then_some(attached))?;
731 return self.parse_codegen(&option);
732 }
733 if let Some(attached) = value.strip_prefix("-L") {
734 let search = self.take_value("-L", (!attached.is_empty()).then_some(attached))?;
735 let (kind, path) = search
736 .split_once('=')
737 .map_or(("all", search.as_str()), |(kind, path)| (kind, path));
738 if kind != "dependency" {
739 return Err(BypassReason::UnsupportedSearchPath(kind.into()));
740 }
741 self.parsed.push(Argument::SearchPath {
742 kind: kind.into(),
743 path: path.into(),
744 });
745 return Ok(());
746 }
747 if value == "-l" || value.starts_with("-l") {
748 return Err(BypassReason::NativeLibrary);
749 }
750 if let Some(attached) = value.strip_prefix("-o") {
751 let path = self.take_value("-o", (!attached.is_empty()).then_some(attached))?;
752 self.explicit_output = Some(path.clone().into());
753 self.parsed.push(Argument::Path {
754 flag: "-o".into(),
755 path: path.into(),
756 });
757 return Ok(());
758 }
759 Err(BypassReason::UnknownFlag(value.into()))
760 }
761
762 fn parse_codegen(&mut self, value: &str) -> Result<(), BypassReason> {
763 let name = value.split_once('=').map_or(value, |(name, _)| name);
764 if name == "incremental" {
765 return Err(BypassReason::Incremental);
766 }
767 if SUPPORTED_CODEGEN_OPTIONS.binary_search(&name).is_err() {
768 return Err(BypassReason::UnknownCodegenOption(name.into()));
769 }
770 self.parsed
771 .push(Argument::Plain(format!("--codegen={value}")));
772 if name == "extra-filename" {
773 self.extra_filename = value
774 .split_once('=')
775 .map_or(String::new(), |(_, value)| value.to_string());
776 }
777 Ok(())
778 }
779
780 fn parse_input(&mut self, value: &str) -> Result<(), BypassReason> {
781 if value == "-" {
782 return Err(BypassReason::StandardInput);
783 }
784 if self.source.replace(value.into()).is_some() {
785 return Err(BypassReason::MultipleInputs);
786 }
787 Ok(())
788 }
789
790 fn classify(&self) -> Result<(), BypassReason> {
791 if self.crate_types.is_empty() {
792 return Err(BypassReason::UnsupportedCrateType("bin".into()));
793 }
794 if let Some(crate_type) = self
795 .crate_types
796 .iter()
797 .find(|crate_type| !matches!(crate_type.as_str(), "lib" | "rlib"))
798 {
799 return Err(BypassReason::UnsupportedCrateType(crate_type.clone()));
800 }
801 if self.test {
802 return Err(BypassReason::UnsupportedCrateType("test".into()));
803 }
804 if let Some(name) = self.parsed.iter().find_map(|argument| match argument {
805 Argument::Extern { name, path: None } if name != "proc_macro" => Some(name),
806 _ => None,
807 }) {
808 return Err(BypassReason::UnresolvedExtern(name.clone()));
809 }
810 if let Some(emit) = self
811 .emits
812 .iter()
813 .find(|emit| !matches!(emit.kind.as_str(), "dep-info" | "link" | "metadata"))
814 {
815 return Err(BypassReason::UnsupportedEmit(emit.kind.clone()));
816 }
817 if !self
818 .emits
819 .iter()
820 .any(|emit| matches!(emit.kind.as_str(), "link" | "metadata"))
821 {
822 return Err(BypassReason::NoCacheableOutput);
823 }
824 Ok(())
825 }
826}
827
828fn parse_emits(value: &str) -> Vec<Emit> {
829 value
830 .split(',')
831 .map(|emit| {
832 let (kind, path) = emit
833 .split_once('=')
834 .map_or((emit, None), |(kind, path)| (kind, Some(path.into())));
835 Emit {
836 kind: kind.into(),
837 path,
838 }
839 })
840 .collect()
841}
842
843struct ActionBuilder<'a> {
844 invocation: &'a RustcInvocation,
845 context: ActionContext,
846 mappings: Vec<PathMapping>,
847}
848
849impl<'a> ActionBuilder<'a> {
850 fn new(invocation: &'a RustcInvocation, mut context: ActionContext) -> Self {
851 context.path_mappings = PathMapping::ordered(&context.path_mappings);
852 Self {
853 invocation,
854 mappings: context.path_mappings.clone(),
855 context,
856 }
857 }
858
859 fn build(self) -> Result<RustcAction, BypassReason> {
860 self.validate_mappings()?;
861 let invocation = self.invocation_descriptor()?;
862 let environment = self.environment_descriptor()?;
863
864 let mut inputs = BTreeMap::<String, CacheDigest>::new();
865 for input in &self.context.inputs {
866 input
867 .digest
868 .validate()
869 .map_err(|_| BypassReason::InvalidInputDigest(input.path.display().to_string()))?;
870 let path = self.normalize_path(&input.path)?;
871 if inputs
872 .insert(path.clone(), input.digest.clone())
873 .is_some_and(|existing| existing != input.digest)
874 {
875 return Err(BypassReason::ConflictingInput(path));
876 }
877 }
878 let required = self
879 .invocation
880 .required_inputs
881 .iter()
882 .map(|path| self.normalize_path(path))
883 .collect::<Result<BTreeSet<_>, _>>()?;
884 if let Some(missing) = required.iter().find(|path| !inputs.contains_key(*path)) {
885 return Err(BypassReason::MissingRequiredInput(missing.clone()));
886 }
887 let inputs = inputs
888 .into_iter()
889 .map(|(path, digest)| InputDescriptor { path, digest })
890 .collect();
891 let descriptor = ActionDescriptor {
892 version: ACTION_SCHEMA_VERSION,
893 kind: "rustc",
894 adapter_version: ADAPTER_VERSION,
895 compiler: invocation.compiler,
896 arguments: invocation.arguments,
897 environment,
898 inputs,
899 };
900 let bytes = canonical_json(&descriptor)
901 .map_err(|error| BypassReason::Serialization(error.to_string()))?;
902 let digest = CacheDigest::blake3(&bytes);
903 Ok(RustcAction { digest, bytes })
904 }
905
906 fn invocation_descriptor(&self) -> Result<InvocationDescriptor, BypassReason> {
907 self.validate_mappings()?;
908 let arguments = self
909 .invocation
910 .arguments
911 .iter()
912 .map(|argument| self.normalize_argument(argument))
913 .collect::<Result<Vec<_>, _>>()?;
914 let required_inputs = self
915 .invocation
916 .required_inputs
917 .iter()
918 .map(|path| self.normalize_path(path))
919 .collect::<Result<BTreeSet<_>, _>>()?
920 .into_iter()
921 .collect();
922 Ok(InvocationDescriptor {
923 version: ACTION_SCHEMA_VERSION,
924 kind: "rustc",
925 adapter_version: ADAPTER_VERSION,
926 compiler: CompilerDescriptor {
927 toolchain: self.context.compiler.toolchain.clone(),
928 rustc_version: self.context.compiler.rustc_version.clone(),
929 host: self.context.compiler.host.clone(),
930 },
931 arguments,
932 required_inputs,
933 })
934 }
935
936 fn validate_mappings(&self) -> Result<(), BypassReason> {
937 if !self.context.working_dir.is_absolute() {
938 return Err(BypassReason::RelativeWorkingDirectory(
939 self.context.working_dir.clone(),
940 ));
941 }
942 let mut roots = BTreeSet::new();
943 let mut placeholders = BTreeSet::new();
944 for mapping in &self.mappings {
945 if !mapping.root.is_absolute() {
946 return Err(BypassReason::RelativePathMapping(mapping.root.clone()));
947 }
948 if mapping.placeholder.is_empty()
949 || !mapping
950 .placeholder
951 .bytes()
952 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
953 || !roots.insert(normalize_components(&mapping.root))
954 || !placeholders.insert(&mapping.placeholder)
955 {
956 return Err(BypassReason::InvalidPathPlaceholder(
957 mapping.placeholder.clone(),
958 ));
959 }
960 }
961 Ok(())
962 }
963
964 fn normalize_argument(&self, argument: &Argument) -> Result<String, BypassReason> {
965 match argument {
966 Argument::Plain(value) => Ok(value.clone()),
967 Argument::Path { flag, path } => Ok(format!("{flag}={}", self.normalize_path(path)?)),
968 Argument::SearchPath { kind, path } => {
969 Ok(format!("-L{kind}={}", self.normalize_path(path)?))
970 }
971 Argument::Extern { name, path } => match path {
972 Some(path) => Ok(format!("--extern={name}={}", self.normalize_path(path)?)),
973 None => Ok(format!("--extern={name}")),
974 },
975 Argument::Emit(emits) => Ok(format!(
976 "--emit={}",
977 emits
978 .iter()
979 .map(|emit| match &emit.path {
980 Some(path) => self
981 .normalize_path(path)
982 .map(|path| format!("{}={path}", emit.kind)),
983 None => Ok(emit.kind.clone()),
984 })
985 .collect::<Result<Vec<_>, _>>()?
986 .join(",")
987 )),
988 Argument::RemapPath { from, to } => Ok(format!(
989 "--remap-path-prefix={}={}",
990 self.normalize_path(from)?,
991 to
992 )),
993 }
994 }
995
996 fn environment_descriptor(&self) -> Result<BTreeMap<String, Option<String>>, BypassReason> {
1003 self.context
1004 .environment
1005 .iter()
1006 .map(|(name, value)| {
1007 let value = match value {
1008 Some(value) if self.context.portable_environment.contains(name) => {
1009 Some(self.normalize_path(Path::new(value))?)
1010 }
1011 value => value.clone(),
1012 };
1013 Ok((name.clone(), value))
1014 })
1015 .collect()
1016 }
1017
1018 fn normalize_path(&self, path: &Path) -> Result<String, BypassReason> {
1019 normalize_mapped_path(path, &self.context.working_dir, &self.mappings)
1020 }
1021}
1022
1023fn denormalize_path(value: &str, mappings: &[PathMapping]) -> Result<PathBuf, BypassReason> {
1024 for mapping in mappings {
1025 let prefix = format!("${{{}}}", mapping.placeholder);
1026 let suffix = if value == prefix {
1027 ""
1028 } else if let Some(suffix) = value.strip_prefix(&format!("{prefix}/")) {
1029 suffix
1030 } else {
1031 continue;
1032 };
1033 if !mapping.root.is_absolute()
1034 || (!suffix.is_empty()
1035 && suffix.split('/').any(|component| {
1036 component.is_empty()
1037 || matches!(component, "." | "..")
1038 || component.contains('\\')
1039 }))
1040 {
1041 return Err(BypassReason::InvalidPredictedInput(value.into()));
1042 }
1043 let mut path = normalize_components(&mapping.root);
1044 path.extend(suffix.split('/').filter(|component| !component.is_empty()));
1045 return Ok(path);
1046 }
1047 Err(BypassReason::InvalidPredictedInput(value.into()))
1048}
1049
1050fn normalize_components(path: &Path) -> PathBuf {
1051 let mut normalized = PathBuf::new();
1052 for component in path.components() {
1053 match component {
1054 Component::CurDir => {}
1055 Component::ParentDir => {
1056 normalized.pop();
1057 }
1058 component => normalized.push(component.as_os_str()),
1059 }
1060 }
1061 normalized
1062}
1063
1064fn absolute_path(path: &Path, working_dir: &Path) -> PathBuf {
1065 if path.is_absolute() {
1066 normalize_components(path)
1067 } else {
1068 normalize_components(&working_dir.join(path))
1069 }
1070}
1071
1072fn slash_path(path: &Path) -> Result<String, BypassReason> {
1073 path.components()
1074 .filter_map(|component| match component {
1075 Component::Normal(value) => Some(
1076 value
1077 .to_str()
1078 .map(ToOwned::to_owned)
1079 .ok_or_else(|| BypassReason::NonUtf8Path(path.to_path_buf())),
1080 ),
1081 _ => None,
1082 })
1083 .collect::<Result<Vec<_>, _>>()
1084 .map(|components| components.join("/"))
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 #[test]
1090 fn verbose_version_is_a_query_not_an_unmodeled_flag() {
1091 assert_eq!(
1094 RustcInvocation::parse(&args(&["-vV"])).unwrap_err(),
1095 BypassReason::CompilerQuery
1096 );
1097 }
1098
1099 #[test]
1100 fn bypass_kinds_are_stable_and_field_independent() {
1101 assert_eq!(BypassReason::CompilerQuery.kind(), "compiler-query");
1102 assert_eq!(BypassReason::Incremental.kind(), "incremental");
1103 assert_eq!(
1104 BypassReason::UnsupportedCrateType("bin".into()).kind(),
1105 "unsupported-crate-type"
1106 );
1107 assert_eq!(
1109 BypassReason::UnmappedAbsolutePath(PathBuf::from("/a")).kind(),
1110 BypassReason::UnmappedAbsolutePath(PathBuf::from("/b")).kind()
1111 );
1112 }
1113
1114 use super::*;
1115
1116 fn args(values: &[&str]) -> Vec<OsString> {
1117 values.iter().map(OsString::from).collect()
1118 }
1119
1120 fn digest(value: &str) -> CacheDigest {
1121 CacheDigest::blake3(value.as_bytes())
1122 }
1123
1124 fn absolute(segments: &[&str]) -> PathBuf {
1125 let mut path = if cfg!(windows) {
1126 PathBuf::from(r"C:\")
1127 } else {
1128 PathBuf::from("/")
1129 };
1130 path.extend(segments);
1131 path
1132 }
1133
1134 fn workspace() -> PathBuf {
1135 absolute(&["work", "project"])
1136 }
1137
1138 fn sysroot() -> PathBuf {
1139 absolute(&["toolchains", "1.97.1"])
1140 }
1141
1142 fn context(inputs: &[(&str, &str)]) -> ActionContext {
1143 ActionContext {
1144 compiler: CompilerIdentity {
1145 toolchain: "core:rust@1.97.1".into(),
1146 rustc_version: "1.97.1 (8bab26f4f 2026-07-14)".into(),
1147 host: "x86_64-unknown-linux-gnu".into(),
1148 },
1149 working_dir: workspace(),
1150 path_mappings: vec![
1151 PathMapping::new(workspace().join("target"), "target"),
1152 PathMapping::new(workspace(), "workspace"),
1153 PathMapping::new(absolute(&["home", "user", ".cargo"]), "cargo_home"),
1154 PathMapping::new(sysroot(), "sysroot"),
1155 ],
1156 environment: BTreeMap::from([("CARGO_PKG_VERSION".into(), Some("1.0.0".into()))]),
1157 portable_environment: BTreeSet::new(),
1158 inputs: inputs
1159 .iter()
1160 .map(|(path, contents)| ActionInput {
1161 path: (*path).into(),
1162 digest: digest(contents),
1163 })
1164 .collect(),
1165 }
1166 }
1167
1168 fn common_invocation() -> RustcInvocation {
1169 let output = workspace().join("target/debug/deps");
1170 RustcInvocation::parse(&[
1171 "--crate-name".into(),
1172 "widget".into(),
1173 "--edition=2024".into(),
1174 "src/lib.rs".into(),
1175 "--crate-type".into(),
1176 "lib".into(),
1177 "--emit=dep-info,metadata,link".into(),
1178 "-Cembed-bitcode=no".into(),
1179 "-C".into(),
1180 "metadata=abc123".into(),
1181 "--out-dir".into(),
1182 output.clone().into_os_string(),
1183 format!("-Ldependency={}", output.display()).into(),
1184 "--extern".into(),
1185 format!("serde={}", output.join("libserde.rlib").display()).into(),
1186 format!("--sysroot={}", sysroot().display()).into(),
1187 "--cap-lints".into(),
1188 "allow".into(),
1189 ])
1190 .unwrap()
1191 }
1192
1193 #[test]
1194 fn parses_a_cargo_library_invocation() {
1195 let invocation = common_invocation();
1196 assert_eq!(invocation.source(), Path::new("src/lib.rs"));
1197 let action = invocation
1198 .action(context(&[
1199 ("src/lib.rs", "source"),
1200 ("target/debug/deps/libserde.rlib", "serde"),
1201 ]))
1202 .unwrap();
1203 let json = String::from_utf8(action.bytes).unwrap();
1204 assert!(json.contains(r#""kind":"rustc""#));
1205 assert!(json.contains(r#""--out-dir=${target}/debug/deps""#));
1206 assert!(json.contains(r#""--extern=serde=${target}/debug/deps/libserde.rlib""#));
1207 assert_eq!(action.digest.algorithm, "blake3");
1208 }
1209
1210 #[test]
1211 fn resolves_cargo_library_outputs() {
1212 let working_dir = absolute(&["workspace"]);
1213 let invocation = RustcInvocation::parse(&args(&[
1214 "--crate-name=widget",
1215 "--crate-type=lib",
1216 "--emit=dep-info,metadata,link",
1217 "--out-dir=target/debug/deps",
1218 "-Cextra-filename=-abc123",
1219 "src/lib.rs",
1220 ]))
1221 .unwrap();
1222 assert_eq!(
1223 invocation.outputs(&working_dir).unwrap(),
1224 RustcOutputs {
1225 directory: working_dir.join("target/debug/deps"),
1226 files: vec![
1227 working_dir.join("target/debug/deps/libwidget-abc123.rlib"),
1228 working_dir.join("target/debug/deps/libwidget-abc123.rmeta"),
1229 ],
1230 dep_info: working_dir.join("target/debug/deps/widget-abc123.d"),
1231 }
1232 );
1233 }
1234
1235 #[test]
1236 fn infers_a_valid_crate_name_from_a_hyphenated_source() {
1237 let working_dir = absolute(&["workspace"]);
1238 let invocation = RustcInvocation::parse(&args(&[
1239 "--crate-type=lib",
1240 "--emit=dep-info,metadata",
1241 "my-library.rs",
1242 ]))
1243 .unwrap();
1244
1245 assert_eq!(
1246 invocation.outputs(&working_dir).unwrap().dep_info,
1247 working_dir.join("my_library.d")
1248 );
1249 }
1250
1251 #[test]
1252 fn refuses_an_output_file_with_implicit_emit_paths() {
1253 let working_dir = absolute(&["workspace"]);
1254 let invocation = RustcInvocation::parse(&args(&[
1255 "--crate-name=widget",
1256 "--crate-type=lib",
1257 "--emit=dep-info,metadata,link",
1258 "-o",
1259 "target/custom.rlib",
1260 "src/lib.rs",
1261 ]))
1262 .unwrap();
1263
1264 assert_eq!(
1267 invocation.outputs(&working_dir).unwrap_err(),
1268 BypassReason::ImplicitEmitWithOutputFile(working_dir.join("target/custom.rlib"))
1269 );
1270 }
1271
1272 #[test]
1273 fn resolves_an_output_file_when_every_emit_names_its_path() {
1274 let working_dir = absolute(&["workspace"]);
1275 let invocation = RustcInvocation::parse(&args(&[
1276 "--crate-name=widget",
1277 "--crate-type=lib",
1278 "--emit=dep-info=target/widget.d,metadata=target/widget.rmeta,link=target/widget.rlib",
1279 "-o",
1280 "target/custom.rlib",
1281 "src/lib.rs",
1282 ]))
1283 .unwrap();
1284
1285 assert_eq!(
1286 invocation.outputs(&working_dir).unwrap(),
1287 RustcOutputs {
1288 directory: working_dir.join("target"),
1289 files: vec![
1290 working_dir.join("target/widget.rlib"),
1291 working_dir.join("target/widget.rmeta"),
1292 ],
1293 dep_info: working_dir.join("target/widget.d"),
1294 }
1295 );
1296 }
1297
1298 #[test]
1299 fn dep_info_must_share_the_artifact_output_directory() {
1300 let working_dir = absolute(&["workspace"]);
1301 let invocation = RustcInvocation::parse(&args(&[
1302 "--crate-name=widget",
1303 "--crate-type=lib",
1304 "--emit=dep-info=target/dep-info/widget.d,metadata,link",
1305 "--out-dir=target/debug/deps",
1306 "src/lib.rs",
1307 ]))
1308 .unwrap();
1309
1310 assert_eq!(
1311 invocation.outputs(&working_dir),
1312 Err(BypassReason::SplitOutputDirectories)
1313 );
1314 }
1315
1316 #[test]
1317 fn equivalent_worktrees_produce_the_same_action_key() {
1318 let first_context = context(&[
1319 ("src/lib.rs", "source"),
1320 ("target/debug/deps/libserde.rlib", "serde"),
1321 ]);
1322 let first = common_invocation().action(first_context).unwrap();
1323 let other = absolute(&["other", "checkout"]);
1324 let output = other.join("target/debug/deps");
1325 let invocation = RustcInvocation::parse(&[
1326 "--crate-name=widget".into(),
1327 "--edition=2024".into(),
1328 "src/lib.rs".into(),
1329 "--crate-type=lib".into(),
1330 "--emit=dep-info,metadata,link".into(),
1331 "-Cembed-bitcode=no".into(),
1332 "-Cmetadata=abc123".into(),
1333 format!("--out-dir={}", output.display()).into(),
1334 format!("-Ldependency={}", output.display()).into(),
1335 format!("--extern=serde={}", output.join("libserde.rlib").display()).into(),
1336 format!("--sysroot={}", sysroot().display()).into(),
1337 "--cap-lints=allow".into(),
1338 ])
1339 .unwrap();
1340 let mut second_context = context(&[]);
1341 second_context.working_dir = other.clone();
1342 second_context.path_mappings[0].root = other.join("target");
1343 second_context.path_mappings[1].root = other.clone();
1344 second_context.inputs = vec![
1345 ActionInput {
1346 path: "src/lib.rs".into(),
1347 digest: digest("source"),
1348 },
1349 ActionInput {
1350 path: "target/debug/deps/libserde.rlib".into(),
1351 digest: digest("serde"),
1352 },
1353 ];
1354 let second = invocation.action(second_context).unwrap();
1355 assert_eq!(first.digest, second.digest);
1356 }
1357
1358 #[test]
1359 fn predicts_inputs_without_reusing_stale_contents() {
1360 let directory = tempfile::tempdir().unwrap();
1361 let workspace = directory.path().canonicalize().unwrap();
1362 std::fs::create_dir(workspace.join("src")).unwrap();
1363 std::fs::write(workspace.join("src/lib.rs"), "pub fn value() -> u8 { 1 }").unwrap();
1364 let invocation = RustcInvocation::parse(&args(&[
1365 "--crate-name=widget",
1366 "--crate-type=lib",
1367 "--emit=dep-info,metadata,link",
1368 "--out-dir=target/debug/deps",
1369 "src/lib.rs",
1370 ]))
1371 .unwrap();
1372 let compiler = CompilerIdentity {
1373 toolchain: "stable".into(),
1374 rustc_version: "rustc test".into(),
1375 host: "test-host".into(),
1376 };
1377 let context = ActionContext {
1378 compiler,
1379 working_dir: workspace.clone(),
1380 path_mappings: vec![PathMapping::new(&workspace, "workspace")],
1381 environment: BTreeMap::new(),
1382 portable_environment: BTreeSet::new(),
1383 inputs: Vec::new(),
1384 };
1385 let dep_info = RustcDepInfo {
1386 files: vec!["src/lib.rs".into()],
1387 environment: BTreeMap::new(),
1388 };
1389 let discovered = invocation.discover_inputs(&dep_info, &workspace).unwrap();
1390 let mut initial_context = context.clone();
1391 discovered.clone().apply_to(&mut initial_context).unwrap();
1392 let initial = invocation.action(initial_context).unwrap();
1393 let prediction = invocation.prediction(&context, &discovered).unwrap();
1394 assert_eq!(prediction.inputs, ["${workspace}/src/lib.rs"]);
1395
1396 let predicted = prediction
1397 .discover(&workspace, &context.path_mappings)
1398 .unwrap();
1399 let mut predicted_context = context.clone();
1400 predicted.apply_to(&mut predicted_context).unwrap();
1401 assert_eq!(invocation.action(predicted_context).unwrap(), initial);
1402
1403 std::fs::write(workspace.join("src/lib.rs"), "pub fn value() -> u8 { 2 }").unwrap();
1404 let changed = prediction
1405 .discover(&workspace, &context.path_mappings)
1406 .unwrap();
1407 let mut changed_context = context;
1408 changed.apply_to(&mut changed_context).unwrap();
1409 assert_ne!(
1410 invocation.action(changed_context).unwrap().digest,
1411 initial.digest
1412 );
1413 }
1414
1415 #[test]
1416 fn predicted_mapping_root_round_trips() {
1417 let workspace = workspace();
1418 assert_eq!(
1419 denormalize_path("${workspace}", &[PathMapping::new(&workspace, "workspace")]).unwrap(),
1420 workspace
1421 );
1422 }
1423
1424 #[test]
1425 fn absolute_environment_values_remain_literal_action_inputs() {
1426 let invocation = common_invocation();
1427 let mut first_context = context(&[
1428 ("src/lib.rs", "source"),
1429 ("target/debug/deps/libserde.rlib", "serde"),
1430 ]);
1431 let first_out_dir = workspace().join("target/debug/build/widget/out");
1432 first_context
1433 .environment
1434 .insert("OUT_DIR".into(), Some(first_out_dir.display().to_string()));
1435 let first = invocation.action(first_context).unwrap();
1436
1437 let mut second_context = context(&[
1438 ("src/lib.rs", "source"),
1439 ("target/debug/deps/libserde.rlib", "serde"),
1440 ]);
1441 second_context.environment.insert(
1442 "OUT_DIR".into(),
1443 Some(absolute(&["other", "out"]).display().to_string()),
1444 );
1445 let second = invocation.action(second_context).unwrap();
1446
1447 let descriptor = String::from_utf8(first.bytes).unwrap();
1452 let expected =
1453 String::from_utf8(canonical_json(&first_out_dir.display().to_string()).unwrap())
1454 .unwrap();
1455 assert!(descriptor.contains(&expected), "{descriptor}");
1456 assert_ne!(first.digest, second.digest);
1457 }
1458
1459 #[test]
1465 fn portable_environment_values_normalize_across_checkouts() {
1466 let other = absolute(&["other", "checkout"]);
1467 let output = other.join("target/debug/deps");
1468 let relocated = RustcInvocation::parse(&[
1469 "--crate-name=widget".into(),
1470 "--edition=2024".into(),
1471 "src/lib.rs".into(),
1472 "--crate-type=lib".into(),
1473 "--emit=dep-info,metadata,link".into(),
1474 "-Cembed-bitcode=no".into(),
1475 "-Cmetadata=abc123".into(),
1476 format!("--out-dir={}", output.display()).into(),
1477 format!("-Ldependency={}", output.display()).into(),
1478 format!("--extern=serde={}", output.join("libserde.rlib").display()).into(),
1479 format!("--sysroot={}", sysroot().display()).into(),
1480 "--cap-lints=allow".into(),
1481 ])
1482 .unwrap();
1483
1484 let here = |portable: bool| {
1485 let mut context = context(&[
1486 ("src/lib.rs", "source"),
1487 ("target/debug/deps/libserde.rlib", "serde"),
1488 ]);
1489 context.environment.insert(
1490 "OUT_DIR".into(),
1491 Some(
1492 workspace()
1493 .join("target/debug/build/widget/out")
1494 .display()
1495 .to_string(),
1496 ),
1497 );
1498 if portable {
1499 context.portable_environment.insert("OUT_DIR".into());
1500 }
1501 common_invocation().action(context).unwrap().digest
1502 };
1503 let there = |portable: bool| {
1504 let mut context = context(&[]);
1505 context.working_dir = other.clone();
1506 context.path_mappings[0].root = other.join("target");
1507 context.path_mappings[1].root = other.clone();
1508 context.inputs = vec![
1509 ActionInput {
1510 path: "src/lib.rs".into(),
1511 digest: digest("source"),
1512 },
1513 ActionInput {
1514 path: "target/debug/deps/libserde.rlib".into(),
1515 digest: digest("serde"),
1516 },
1517 ];
1518 context.environment.insert(
1519 "OUT_DIR".into(),
1520 Some(
1521 other
1522 .join("target/debug/build/widget/out")
1523 .display()
1524 .to_string(),
1525 ),
1526 );
1527 if portable {
1528 context.portable_environment.insert("OUT_DIR".into());
1529 }
1530 relocated.action(context).unwrap().digest
1531 };
1532
1533 assert_ne!(here(false), there(false));
1534 assert_eq!(here(true), there(true));
1535 assert_ne!(here(false), here(true));
1538 }
1539
1540 #[test]
1541 fn content_and_environment_change_the_action_key() {
1542 let invocation = common_invocation();
1543 let first = invocation
1544 .action(context(&[
1545 ("src/lib.rs", "source"),
1546 ("target/debug/deps/libserde.rlib", "serde"),
1547 ]))
1548 .unwrap();
1549 let changed_source = invocation
1550 .action(context(&[
1551 ("src/lib.rs", "changed"),
1552 ("target/debug/deps/libserde.rlib", "serde"),
1553 ]))
1554 .unwrap();
1555 let mut changed_environment = context(&[
1556 ("src/lib.rs", "source"),
1557 ("target/debug/deps/libserde.rlib", "serde"),
1558 ]);
1559 changed_environment
1560 .environment
1561 .insert("CARGO_PKG_VERSION".into(), Some("2.0.0".into()));
1562 let changed_environment = invocation.action(changed_environment).unwrap();
1563 assert_ne!(first.digest, changed_source.digest);
1564 assert_ne!(first.digest, changed_environment.digest);
1565 }
1566
1567 #[test]
1568 fn unknown_and_incremental_options_bypass() {
1569 for (arguments, expected) in [
1570 (
1571 vec!["--future-flag", "src/lib.rs"],
1572 BypassReason::UnknownFlag("--future-flag".into()),
1573 ),
1574 (
1575 vec!["-Cfuture-option=yes", "src/lib.rs"],
1576 BypassReason::UnknownCodegenOption("future-option".into()),
1577 ),
1578 (
1579 vec!["-Cincremental=target/incremental", "src/lib.rs"],
1580 BypassReason::Incremental,
1581 ),
1582 ] {
1583 assert_eq!(RustcInvocation::parse(&args(&arguments)), Err(expected));
1584 }
1585 }
1586
1587 #[test]
1588 fn linked_and_unmodeled_outputs_bypass() {
1589 for (arguments, expected) in [
1590 (
1591 vec!["--crate-type=bin", "--emit=link", "src/main.rs"],
1592 BypassReason::UnsupportedCrateType("bin".into()),
1593 ),
1594 (
1595 vec!["--crate-type=lib", "--emit=obj", "src/lib.rs"],
1596 BypassReason::UnsupportedEmit("obj".into()),
1597 ),
1598 (
1599 vec!["--crate-type=lib", "--emit=dep-info", "src/lib.rs"],
1600 BypassReason::NoCacheableOutput,
1601 ),
1602 ] {
1603 assert_eq!(RustcInvocation::parse(&args(&arguments)), Err(expected));
1604 }
1605 }
1606
1607 #[test]
1608 fn action_requires_every_direct_input() {
1609 let error = common_invocation()
1610 .action(context(&[("src/lib.rs", "source")]))
1611 .unwrap_err();
1612 assert_eq!(
1613 error,
1614 BypassReason::MissingRequiredInput("${target}/debug/deps/libserde.rlib".into())
1615 );
1616 }
1617
1618 #[test]
1619 fn action_rejects_unmapped_absolute_paths() {
1620 let unmapped = absolute(&["tmp", "rustc-output"]);
1621 let invocation = RustcInvocation::parse(&[
1622 "--crate-type=lib".into(),
1623 "--emit=link".into(),
1624 "src/lib.rs".into(),
1625 format!("--out-dir={}", unmapped.display()).into(),
1626 ])
1627 .unwrap();
1628 let error = invocation
1629 .action(context(&[("src/lib.rs", "source")]))
1630 .unwrap_err();
1631 assert_eq!(error, BypassReason::UnmappedAbsolutePath(unmapped));
1632 }
1633
1634 #[test]
1635 fn custom_targets_are_required_inputs() {
1636 let invocation = RustcInvocation::parse(&args(&[
1637 "--crate-type=lib",
1638 "--emit=metadata",
1639 "--target=targets/custom.json",
1640 "src/lib.rs",
1641 ]))
1642 .unwrap();
1643 let error = invocation
1644 .action(context(&[("src/lib.rs", "source")]))
1645 .unwrap_err();
1646 assert_eq!(
1647 error,
1648 BypassReason::MissingRequiredInput("${workspace}/targets/custom.json".into())
1649 );
1650 }
1651
1652 #[test]
1653 fn remap_destinations_are_stable_virtual_paths() {
1654 let invocation = RustcInvocation::parse(&[
1655 "--crate-type=lib".into(),
1656 "--emit=metadata".into(),
1657 format!("--remap-path-prefix={}=/src", workspace().display()).into(),
1658 "src/lib.rs".into(),
1659 ])
1660 .unwrap();
1661 let action = invocation
1662 .action(context(&[("src/lib.rs", "source")]))
1663 .unwrap();
1664 assert!(
1665 String::from_utf8(action.bytes)
1666 .unwrap()
1667 .contains(r#"--remap-path-prefix=${workspace}=/src"#)
1668 );
1669 }
1670}