Skip to main content

mise_cache_rustc/
lib.rs

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/// The cacheable files and dependency manifest produced by a rustc invocation.
159#[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    /// Parse rustc's arguments, excluding the compiler executable supplied as
168    /// the first argument to `RUSTC_WRAPPER`.
169    ///
170    /// Any flag whose cache semantics are not modeled returns a bypass reason
171    /// instead of guessing. A successful parse only admits the initial
172    /// rlib/rmeta cacheability tier.
173    pub fn parse(arguments: &[OsString]) -> Result<Self, BypassReason> {
174        Parser::new(arguments).parse()
175    }
176
177    /// Return the source input passed to rustc.
178    pub fn source(&self) -> &Path {
179        &self.source
180    }
181
182    /// Resolve the rlib/rmeta files produced by this invocation.
183    ///
184    /// The initial cache tier requires one output directory so its artifact can
185    /// be represented by one protocol directory and restored atomically later.
186    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    /// Build canonical action bytes after precise input discovery has run.
261    ///
262    /// `context.inputs` must contain the source, every explicit extern, and
263    /// every additional source or environment-generated input discovered from
264    /// dep-info.
265    pub fn action(&self, context: ActionContext) -> Result<RustcAction, BypassReason> {
266        ActionBuilder::new(self, context).build()
267    }
268
269    /// Fingerprint the modeled invocation before dependency contents are known.
270    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    /// Capture normalized dependency paths for a future invocation that has no
278    /// dep-info file yet.
279    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    /// Map an absolute host path to a stable cache-key placeholder.
309    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/// Normalized input names from the last successful execution of one modeled
346/// rustc invocation.
347#[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    /// Rehash the predicted paths and read the current environment. The caller
357    /// still recomputes the full action digest, so changed inputs are misses.
358    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        // rustc may embed these values verbatim through `env!`; unlike paths
794        // used to locate inputs and outputs, changing them changes the artifact.
795        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        // The descriptor is canonical JSON, so the value appears the way JSON writes it --
1327        // on Windows that means escaped separators. Quote it with the same serializer instead
1328        // of hand-rolling the escaping; that also pins the surrounding quotes, so this only
1329        // matches a whole JSON string rather than any substring.
1330        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}