Skip to main content

mbx_cache_rustc/
lib.rs

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