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