Skip to main content

ic_testkit/artifacts/
wasm_cache.rs

1use serde_json::Value;
2use std::{
3    collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
4    ffi::{OsStr, OsString},
5    fs::{self, File},
6    io,
7    path::{Path, PathBuf},
8    process::{Child, Command, ExitStatus, Output, Stdio},
9    sync::mpsc::{self, RecvTimeoutError},
10    thread,
11    time::{Duration, Instant, SystemTime},
12};
13use toml::Value as TomlValue;
14
15use super::{
16    cache_fs::{
17        ArtifactCacheMaintenance, ArtifactCachePrunePolicy, ArtifactCachePruneReport, CacheFsError,
18        cache_entry_last_used, cache_maintenance_due, directory_logical_size,
19        ensure_cache_directory_tag as ensure_cache_tag, is_sha256_directory, lock_cache_file,
20        lock_cache_file_with_wait_observer, perform_scheduled_cache_maintenance,
21        prune_direct_child_directories, record_cache_entry_use as record_entry_use,
22        record_cache_maintenance, remove_path_if_present,
23    },
24    digest::{
25        InputDigest, InputHasher, copy_file_atomic, digest_bytes, digest_file,
26        digest_labeled_paths, os_bytes, write_atomic,
27    },
28    wasm::wasm_path,
29};
30
31const CACHE_FORMAT_VERSION: &str = "ic-testkit-wasm-build-v1";
32const DEFAULT_TARGET: &str = "wasm32-unknown-unknown";
33const AUTOMATIC_ENVIRONMENT: &[&str] = &[
34    "CARGO_BUILD_RUSTC",
35    "CARGO_ENCODED_RUSTFLAGS",
36    "RUSTC",
37    "RUSTC_WRAPPER",
38    "RUSTC_WORKSPACE_WRAPPER",
39    "RUSTFLAGS",
40    "RUSTUP_TOOLCHAIN",
41];
42
43/// Complete caller-owned description of one cacheable Cargo Wasm build.
44///
45/// The package dependency closure, workspace manifest, lockfile, Cargo
46/// configuration, Rust toolchain files, target, profile arguments, explicit
47/// child environment, selected inherited environment, and additional watched
48/// inputs all contribute to the build fingerprint.
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct WasmBuildSpec {
51    workspace_root: PathBuf,
52    target_dir: PathBuf,
53    packages: Vec<String>,
54    profile_target_dir: String,
55    cargo_profile_args: Vec<OsString>,
56    extra_env: BTreeMap<OsString, OsString>,
57    inherited_env: BTreeSet<OsString>,
58    additional_inputs: Vec<PathBuf>,
59    target: String,
60    cargo_program: OsString,
61    rustc_program: OsString,
62    cache_mode: WasmBuildCacheMode,
63    prune_policy: Option<WasmBuildCachePrunePolicy>,
64    prune_interval: Option<Duration>,
65    shared_incremental_maintenance_config: Option<SharedIncrementalTargetMaintenanceConfig>,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69struct SharedIncrementalTargetMaintenanceConfig {
70    policy: SharedIncrementalTargetPrunePolicy,
71    minimum_interval: Duration,
72}
73
74/// Cargo-target ownership mode for one exact cached Wasm build.
75#[non_exhaustive]
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub enum WasmBuildCacheMode {
78    /// Build each exact fingerprint in its own content-addressed Cargo target.
79    Isolated,
80    /// Build misses in caller-owned shared Cargo incremental state, then cache final Wasm files.
81    SharedIncremental {
82        /// Mutable Cargo target directory shared across source fingerprints.
83        target_dir: PathBuf,
84    },
85}
86
87/// Whether a cacheable Wasm build ran Cargo or reused exact matching artifacts.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub enum WasmBuildOutcome {
90    /// Cargo ran and a new successful stamp was published.
91    Built(WasmBuildRecord),
92    /// Existing artifacts and their content-addressed stamp matched exactly.
93    Reused(WasmBuildRecord),
94}
95
96/// Details shared by built and reused Wasm outcomes.
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct WasmBuildRecord {
99    fingerprint: InputDigest,
100    input_digest: InputDigest,
101    artifacts: Vec<PathBuf>,
102    timings: WasmBuildTimings,
103    maintenance: Option<WasmBuildCacheMaintenance>,
104    shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
105}
106
107/// Timings for cache coordination, input resolution, and Cargo execution.
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub struct WasmBuildTimings {
110    lock_wait: Duration,
111    shared_incremental_lock_wait: Option<Duration>,
112    input_resolution: WasmInputResolutionTimings,
113    cargo_build: Option<Duration>,
114    cache_maintenance: Option<Duration>,
115    total: Duration,
116}
117
118/// Detailed timings for exact Wasm build-input resolution.
119#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
120pub struct WasmInputResolutionTimings {
121    tool_identity: Duration,
122    cargo_metadata: Duration,
123    input_discovery: Duration,
124    content_hashing: Duration,
125    total: Duration,
126}
127
128/// One exact local Cargo source or configuration input under a stable logical label.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct CargoBuildInput {
131    label: PathBuf,
132    path: PathBuf,
133}
134
135/// Resolved exact inputs and identity for one [`WasmBuildSpec`].
136///
137/// The snapshot can be resolved again after an external operation to detect
138/// source, configuration, toolchain, argument, or environment changes.
139#[derive(Clone, Debug, Eq, PartialEq)]
140pub struct ResolvedCargoBuildInputs {
141    fingerprint: InputDigest,
142    input_digest: InputDigest,
143    inputs: Vec<CargoBuildInput>,
144    exclusions: Vec<PathBuf>,
145    timings: WasmInputResolutionTimings,
146}
147
148/// Lock-coordinated disk-usage observation for a caller-owned shared Cargo target.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct SharedIncrementalTargetInspection {
151    target_dir: PathBuf,
152    logical_size_bytes: u64,
153    last_used: SystemTime,
154    lock_wait: Duration,
155}
156
157/// Whole-target retention limits for caller-owned shared Cargo state.
158///
159/// Unlike immutable fingerprint entries, a shared Cargo target has no safe
160/// per-entry LRU boundary. When either configured limit is exceeded,
161/// maintenance clears every other target child while preserving
162/// `ic-testkit`'s coordination metadata and the target root. Callers must not
163/// colocate unrelated data that needs to survive a clear.
164#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
165pub struct SharedIncrementalTargetPrunePolicy {
166    max_age: Option<Duration>,
167    max_size_bytes: Option<u64>,
168}
169
170/// Result of explicit shared Cargo target maintenance.
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct SharedIncrementalTargetMaintenance {
173    target_dir: PathBuf,
174    logical_size_bytes_before: u64,
175    logical_size_bytes_after: u64,
176    last_used_before: SystemTime,
177    cleared: bool,
178    lock_wait: Duration,
179    maintenance: Duration,
180}
181
182/// Result of interval-limited shared Cargo target maintenance.
183#[non_exhaustive]
184#[derive(Clone, Debug, Eq, PartialEq)]
185pub enum SharedIncrementalTargetMaintenanceOutcome {
186    /// The configured shared target does not exist, so nothing was created or inspected.
187    Missing {
188        /// Configured target path. A missing path cannot necessarily be canonicalized.
189        target_dir: PathBuf,
190    },
191    /// A successful matching maintenance pass is still inside the requested interval.
192    Skipped {
193        /// Canonical shared Cargo target directory.
194        target_dir: PathBuf,
195        /// Time spent waiting for another process using the shared target.
196        lock_wait: Duration,
197        /// Time spent checking the small cross-process schedule marker.
198        schedule_check: Duration,
199    },
200    /// Retention was evaluated under the shared-target lock.
201    Performed {
202        /// Completed retention report.
203        maintenance: SharedIncrementalTargetMaintenance,
204        /// Time spent checking the small cross-process schedule marker.
205        schedule_check: Duration,
206    },
207}
208
209/// Observation settings for one cacheable Wasm build.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub struct WasmBuildProgressConfig {
212    heartbeat_interval: Option<Duration>,
213    emit_cargo_output: bool,
214}
215
216/// Raw child-process stream attached to a Cargo progress event.
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
218pub enum WasmBuildOutputStream {
219    /// Cargo standard output.
220    Stdout,
221    /// Cargo standard error.
222    Stderr,
223}
224
225/// Final cache state reported by a successful observed build.
226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub enum WasmBuildProgressOutcome {
228    /// Cargo ran and exact artifacts were published.
229    Built,
230    /// Exact artifacts were reused without Cargo.
231    Reused,
232}
233
234/// Potentially long phase of one observed Wasm-cache acquisition.
235#[non_exhaustive]
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub enum WasmBuildProgressPhase {
238    /// Waiting for exclusive ownership of the exact artifact cache.
239    ExactCacheLock,
240    /// Reading the Cargo executable identity.
241    CargoIdentity,
242    /// Reading the Rust compiler identity.
243    RustcIdentity,
244    /// Resolving Cargo's package graph.
245    CargoMetadata,
246    /// Discovering local source and configuration inputs.
247    InputDiscovery,
248    /// Hashing exact source and configuration contents.
249    ContentHashing,
250    /// Waiting for exclusive ownership of a shared incremental Cargo target.
251    SharedTargetLock,
252    /// Inspecting or clearing a shared incremental Cargo target.
253    SharedTargetMaintenance,
254    /// Compiling the selected Wasm packages.
255    CargoBuild,
256    /// Validating, copying, hashing, or stamping exact artifacts.
257    ArtifactPublication,
258    /// Applying retention to immutable exact-cache entries.
259    ExactCacheMaintenance,
260}
261
262/// Structured progress emitted by an observed cacheable Wasm build.
263#[non_exhaustive]
264#[derive(Clone, Debug, Eq, PartialEq)]
265pub enum WasmBuildProgressEvent {
266    /// One build/cache acquisition started.
267    Started,
268    /// One exact Cargo input-resolution pass completed.
269    InputsResolved {
270        /// Complete exact build fingerprint.
271        fingerprint: InputDigest,
272        /// Source/configuration-only digest.
273        input_digest: InputDigest,
274        /// Time spent on this resolution pass.
275        elapsed: Duration,
276    },
277    /// No reusable exact entry existed for this fingerprint.
278    CacheMiss {
279        /// Missing exact fingerprint.
280        fingerprint: InputDigest,
281    },
282    /// Exact artifacts were found and materialized when necessary.
283    CacheHit {
284        /// Reused exact fingerprint.
285        fingerprint: InputDigest,
286    },
287    /// The build is about to wait for a caller-owned shared Cargo target.
288    SharedTargetLockStarted {
289        /// Shared target selected by the build specification.
290        target_dir: PathBuf,
291    },
292    /// Exclusive shared-target ownership was acquired.
293    SharedTargetLockAcquired {
294        /// Canonical shared target directory.
295        target_dir: PathBuf,
296        /// Time spent waiting for another process.
297        wait: Duration,
298    },
299    /// Scheduled shared-target retention is about to be evaluated under lock.
300    SharedTargetMaintenanceStarted {
301        /// Canonical shared target selected by the build specification.
302        target_dir: PathBuf,
303    },
304    /// Scheduled shared-target retention completed or was skipped.
305    SharedTargetMaintenanceFinished {
306        /// Structured retention result attached to the successful acquisition.
307        outcome: SharedIncrementalTargetMaintenanceOutcome,
308    },
309    /// Cargo compilation started.
310    CargoStarted {
311        /// Cargo target receiving compilation state.
312        target_dir: PathBuf,
313    },
314    /// One raw Cargo output chunk was read without lossy UTF-8 conversion.
315    CargoOutput {
316        /// Child-process stream that produced the bytes.
317        stream: WasmBuildOutputStream,
318        /// Raw output bytes in per-stream read order.
319        bytes: Vec<u8>,
320    },
321    /// The current acquisition phase remained active without another event.
322    Heartbeat {
323        /// Phase that is still making or waiting for progress.
324        phase: WasmBuildProgressPhase,
325        /// Time elapsed since this phase started.
326        elapsed: Duration,
327    },
328    /// Cargo remained active without another emitted output chunk.
329    ///
330    /// This compatibility event is emitted alongside [`Self::Heartbeat`] for
331    /// [`WasmBuildProgressPhase::CargoBuild`]. New observers can handle the
332    /// phase-aware event alone.
333    CargoHeartbeat {
334        /// Time elapsed since Cargo started.
335        elapsed: Duration,
336    },
337    /// Cargo exited and all captured output was drained.
338    CargoFinished {
339        /// Whether Cargo reported success.
340        success: bool,
341        /// Portable exit code when the platform exposes one.
342        code: Option<i32>,
343        /// Complete Cargo execution duration.
344        elapsed: Duration,
345    },
346    /// The complete cacheable build operation succeeded.
347    Finished {
348        /// Whether Cargo ran or an exact entry was reused.
349        outcome: WasmBuildProgressOutcome,
350        /// Exact fingerprint selected by the operation.
351        fingerprint: InputDigest,
352        /// Total operation duration.
353        elapsed: Duration,
354    },
355}
356
357impl Default for WasmBuildProgressConfig {
358    fn default() -> Self {
359        Self {
360            heartbeat_interval: Some(Duration::from_secs(10)),
361            emit_cargo_output: true,
362        }
363    }
364}
365
366impl WasmBuildProgressConfig {
367    /// Observe acquisition progress and emit a heartbeat at least every ten quiet seconds.
368    #[must_use]
369    pub fn new() -> Self {
370        Self::default()
371    }
372
373    /// Select the maximum quiet interval between phase-aware heartbeat events.
374    ///
375    /// A zero interval is rejected before any build work begins.
376    #[must_use]
377    pub const fn with_heartbeat_interval(mut self, interval: Duration) -> Self {
378        self.heartbeat_interval = Some(interval);
379        self
380    }
381
382    /// Disable time-based heartbeats while retaining phase and output events.
383    #[must_use]
384    pub const fn without_heartbeats(mut self) -> Self {
385        self.heartbeat_interval = None;
386        self
387    }
388
389    /// Select whether raw Cargo stdout/stderr chunks are forwarded.
390    ///
391    /// Output is always captured for structured build failures.
392    #[must_use]
393    pub const fn with_cargo_output(mut self, emit: bool) -> Self {
394        self.emit_cargo_output = emit;
395        self
396    }
397
398    /// Configured heartbeat interval, or `None` when disabled.
399    #[must_use]
400    pub const fn heartbeat_interval(self) -> Option<Duration> {
401        self.heartbeat_interval
402    }
403
404    /// Whether raw Cargo output chunks are emitted to the observer.
405    #[must_use]
406    pub const fn emits_cargo_output(self) -> bool {
407        self.emit_cargo_output
408    }
409}
410
411struct ProgressReporter<'a> {
412    config: WasmBuildProgressConfig,
413    observer: Option<&'a mut dyn FnMut(WasmBuildProgressEvent)>,
414    last_event: Instant,
415}
416
417impl ProgressReporter<'_> {
418    fn silent() -> Self {
419        Self {
420            config: WasmBuildProgressConfig {
421                heartbeat_interval: None,
422                emit_cargo_output: false,
423            },
424            observer: None,
425            last_event: Instant::now(),
426        }
427    }
428
429    fn observed(
430        config: WasmBuildProgressConfig,
431        observer: &'_ mut dyn FnMut(WasmBuildProgressEvent),
432    ) -> ProgressReporter<'_> {
433        ProgressReporter {
434            config,
435            observer: Some(observer),
436            last_event: Instant::now(),
437        }
438    }
439
440    fn emit(&mut self, event: WasmBuildProgressEvent) {
441        if let Some(observer) = &mut self.observer {
442            observer(event);
443            self.last_event = Instant::now();
444        }
445    }
446
447    const fn is_observed(&self) -> bool {
448        self.observer.is_some()
449    }
450
451    fn heartbeat_due_in(&self) -> Option<Duration> {
452        self.config
453            .heartbeat_interval
454            .map(|interval| interval.saturating_sub(self.last_event.elapsed()))
455    }
456
457    fn emit_heartbeat(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
458        self.emit(WasmBuildProgressEvent::Heartbeat { phase, elapsed });
459        if phase == WasmBuildProgressPhase::CargoBuild {
460            self.emit(WasmBuildProgressEvent::CargoHeartbeat { elapsed });
461        }
462    }
463
464    fn emit_heartbeat_if_due(&mut self, phase: WasmBuildProgressPhase, elapsed: Duration) {
465        if self.heartbeat_due_in() == Some(Duration::ZERO) {
466            self.emit_heartbeat(phase, elapsed);
467        }
468    }
469
470    fn run_phase<T, F>(&mut self, phase: WasmBuildProgressPhase, operation: F) -> T
471    where
472        T: Send,
473        F: FnOnce() -> T + Send,
474    {
475        if !self.is_observed() || self.config.heartbeat_interval.is_none() {
476            return operation();
477        }
478
479        let started = Instant::now();
480        thread::scope(|scope| {
481            let (finished, completion) = mpsc::sync_channel(0);
482            let worker = scope.spawn(move || {
483                let result = operation();
484                let _ = finished.send(());
485                result
486            });
487            loop {
488                let wait = self
489                    .heartbeat_due_in()
490                    .expect("observed phase must have a heartbeat interval");
491                match completion.recv_timeout(wait) {
492                    Ok(()) | Err(RecvTimeoutError::Disconnected) => {
493                        return worker
494                            .join()
495                            .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
496                    }
497                    Err(RecvTimeoutError::Timeout) => self.emit_heartbeat(phase, started.elapsed()),
498                }
499            }
500        })
501    }
502}
503
504/// Wasm-cache compatibility name for generic artifact-cache retention limits.
505pub type WasmBuildCachePrunePolicy = ArtifactCachePrunePolicy;
506
507/// Wasm-cache compatibility name for a generic artifact-cache pruning report.
508pub type WasmBuildCachePruneReport = ArtifactCachePruneReport;
509
510/// Wasm-cache compatibility name for generic nonfatal cache maintenance.
511pub type WasmBuildCacheMaintenance = ArtifactCacheMaintenance;
512
513/// External phase associated with a cacheable Wasm build failure.
514#[non_exhaustive]
515#[derive(Clone, Copy, Debug, Eq, PartialEq)]
516pub enum WasmBuildPhase {
517    /// Resolving Cargo's package graph.
518    CargoMetadata,
519    /// Reading the Cargo executable identity.
520    CargoIdentity,
521    /// Reading the Rust compiler identity.
522    RustcIdentity,
523    /// Compiling the selected Wasm packages.
524    CargoBuild,
525}
526
527/// Structured failure from a cacheable Wasm build.
528#[non_exhaustive]
529#[derive(Debug)]
530pub enum WasmBuildError {
531    /// The caller supplied an incomplete or inconsistent specification.
532    InvalidSpec { message: String },
533    /// A filesystem operation failed.
534    Io {
535        operation: &'static str,
536        path: PathBuf,
537        source: io::Error,
538    },
539    /// An external command could not be launched.
540    CommandSpawn {
541        phase: WasmBuildPhase,
542        program: OsString,
543        source: io::Error,
544    },
545    /// An external command completed unsuccessfully.
546    CommandFailed {
547        phase: WasmBuildPhase,
548        status: ExitStatus,
549        stdout: String,
550        stderr: String,
551    },
552    /// Cargo metadata did not contain the expected package graph.
553    InvalidMetadata { message: String },
554    /// A discovered Cargo configuration could not be interpreted exactly.
555    InvalidCargoConfiguration { path: PathBuf, message: String },
556    /// Cargo succeeded without producing every declared Wasm artifact.
557    MissingArtifacts { paths: Vec<PathBuf> },
558    /// Declared inputs changed while Cargo was building.
559    InputsChangedDuringBuild {
560        before: InputDigest,
561        after: InputDigest,
562    },
563    /// A build failed and its incomplete fingerprint directory could not be removed.
564    FailedBuildCleanup {
565        build_error: Box<Self>,
566        path: PathBuf,
567        source: io::Error,
568    },
569}
570
571impl WasmBuildSpec {
572    /// Describe one Cargo build targeting `wasm32-unknown-unknown`.
573    ///
574    /// `profile_target_dir` is Cargo's output subdirectory, such as `debug`,
575    /// `release`, or the name supplied to `--profile`.
576    #[must_use]
577    pub fn new(
578        workspace_root: &Path,
579        target_dir: &Path,
580        packages: &[&str],
581        profile_target_dir: &str,
582    ) -> Self {
583        Self {
584            workspace_root: workspace_root.to_owned(),
585            target_dir: target_dir.to_owned(),
586            packages: packages
587                .iter()
588                .map(|package| (*package).to_owned())
589                .collect(),
590            profile_target_dir: profile_target_dir.to_owned(),
591            cargo_profile_args: Vec::new(),
592            extra_env: BTreeMap::new(),
593            inherited_env: BTreeSet::new(),
594            additional_inputs: Vec::new(),
595            target: DEFAULT_TARGET.to_owned(),
596            cargo_program: std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()),
597            rustc_program: std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()),
598            cache_mode: WasmBuildCacheMode::Isolated,
599            prune_policy: None,
600            prune_interval: None,
601            shared_incremental_maintenance_config: None,
602        }
603    }
604
605    /// Set Cargo profile and feature arguments used for both the build and fingerprint.
606    #[must_use]
607    pub fn with_cargo_profile_args(mut self, arguments: &[&str]) -> Self {
608        self.cargo_profile_args = arguments.iter().map(OsString::from).collect();
609        self
610    }
611
612    /// Set OS-native Cargo profile and feature arguments used for the build and fingerprint.
613    #[must_use]
614    pub fn with_cargo_profile_args_os<I, S>(mut self, arguments: I) -> Self
615    where
616        I: IntoIterator<Item = S>,
617        S: Into<OsString>,
618    {
619        self.cargo_profile_args = arguments.into_iter().map(Into::into).collect();
620        self
621    }
622
623    /// Set deterministic child-process environment overrides.
624    #[must_use]
625    pub fn with_extra_env(mut self, environment: &[(&str, &str)]) -> Self {
626        self.extra_env = environment
627            .iter()
628            .map(|(key, value)| (OsString::from(key), OsString::from(value)))
629            .collect();
630        self
631    }
632
633    /// Set OS-native deterministic child-process environment overrides.
634    #[must_use]
635    pub fn with_extra_env_os<I, K, V>(mut self, environment: I) -> Self
636    where
637        I: IntoIterator<Item = (K, V)>,
638        K: Into<OsString>,
639        V: Into<OsString>,
640    {
641        self.extra_env = environment
642            .into_iter()
643            .map(|(key, value)| (key.into(), value.into()))
644            .collect();
645        self
646    }
647
648    /// Add ambient environment variables whose current values affect the build.
649    ///
650    /// Common Rust and Cargo toolchain variables are included automatically.
651    /// Callers must declare application-specific variables read by build scripts.
652    #[must_use]
653    pub fn with_inherited_env(mut self, names: &[&str]) -> Self {
654        self.inherited_env.extend(names.iter().map(OsString::from));
655        self
656    }
657
658    /// Add OS-native ambient environment names whose current values affect the build.
659    #[must_use]
660    pub fn with_inherited_env_os<I, S>(mut self, names: I) -> Self
661    where
662        I: IntoIterator<Item = S>,
663        S: Into<OsString>,
664    {
665        self.inherited_env.extend(names.into_iter().map(Into::into));
666        self
667    }
668
669    /// Add files or directories not discoverable through Cargo's local dependency graph.
670    ///
671    /// Relative paths are resolved from the workspace root. Use this for build
672    /// script configuration, generated schemas, or other externally read inputs.
673    #[must_use]
674    pub fn with_additional_inputs(mut self, paths: &[&str]) -> Self {
675        self.additional_inputs
676            .extend(paths.iter().map(PathBuf::from));
677        self
678    }
679
680    /// Add path-native files or directories outside Cargo's local dependency graph.
681    #[must_use]
682    pub fn with_additional_input_paths<I, P>(mut self, paths: I) -> Self
683    where
684        I: IntoIterator<Item = P>,
685        P: Into<PathBuf>,
686    {
687        self.additional_inputs
688            .extend(paths.into_iter().map(Into::into));
689        self
690    }
691
692    /// Override the Cargo compilation target.
693    #[must_use]
694    pub fn with_target(mut self, target: &str) -> Self {
695        target.clone_into(&mut self.target);
696        self
697    }
698
699    /// Override the Cargo executable used by metadata, identity, and build commands.
700    #[must_use]
701    pub fn with_cargo_program(mut self, program: impl Into<OsString>) -> Self {
702        self.cargo_program = program.into();
703        self
704    }
705
706    /// Override the Rust compiler executable used to fingerprint the toolchain.
707    #[must_use]
708    pub fn with_rustc_program(mut self, program: impl Into<OsString>) -> Self {
709        self.rustc_program = program.into();
710        self
711    }
712
713    /// Build cache misses in one caller-owned shared Cargo incremental target.
714    ///
715    /// Exact final Wasm artifacts still live in the content-addressed cache.
716    /// The shared target is coordinated across processes but is never pruned
717    /// or removed by `ic-testkit` after a failed build.
718    #[must_use]
719    pub fn with_shared_incremental_target(mut self, target_dir: impl Into<PathBuf>) -> Self {
720        self.cache_mode = WasmBuildCacheMode::SharedIncremental {
721            target_dir: target_dir.into(),
722        };
723        self
724    }
725
726    /// Schedule caller-owned shared-target retention as part of acquisition.
727    ///
728    /// This option requires [`Self::with_shared_incremental_target`]. Every
729    /// acquisition coordinates through that target, including an exact hit,
730    /// so a missing target can be created and receive its first schedule
731    /// marker immediately. Matching recent passes only check the marker; due
732    /// passes reuse the acquisition's exact Cargo input resolution before
733    /// evaluating retention. The structured result is attached to the build
734    /// record and emitted through observed progress. Maintenance failures fail
735    /// the acquisition and do not record a successful schedule marker.
736    #[must_use]
737    pub const fn with_shared_incremental_target_maintenance_at_most_every(
738        mut self,
739        policy: SharedIncrementalTargetPrunePolicy,
740        minimum_interval: Duration,
741    ) -> Self {
742        self.shared_incremental_maintenance_config =
743            Some(SharedIncrementalTargetMaintenanceConfig {
744                policy,
745                minimum_interval,
746            });
747        self
748    }
749
750    /// Apply cache retention under the build operation's existing process lock.
751    ///
752    /// Maintenance is best-effort: its structured result is attached to the
753    /// successful build record and cannot turn ready artifacts into a build
754    /// failure. The active fingerprint is protected from this pruning pass.
755    #[must_use]
756    pub const fn with_prune_policy(mut self, policy: WasmBuildCachePrunePolicy) -> Self {
757        self.prune_policy = Some(policy);
758        self.prune_interval = None;
759        self
760    }
761
762    /// Apply exact-entry retention at most once per `minimum_interval`.
763    ///
764    /// The active fingerprint remains protected. A zero interval is equivalent
765    /// to [`Self::with_prune_policy`]. The interval covers attempted
766    /// maintenance, including a nonfatal failed attempt. This schedule never
767    /// owns or scans a caller-owned shared incremental Cargo target.
768    #[must_use]
769    pub const fn with_prune_policy_at_most_every(
770        mut self,
771        policy: WasmBuildCachePrunePolicy,
772        minimum_interval: Duration,
773    ) -> Self {
774        self.prune_policy = Some(policy);
775        self.prune_interval = Some(minimum_interval);
776        self
777    }
778
779    /// Workspace containing the selected Cargo packages.
780    #[must_use]
781    pub fn workspace_root(&self) -> &Path {
782        &self.workspace_root
783    }
784
785    /// Cargo target directory containing artifacts, lock, and stamps.
786    #[must_use]
787    pub fn target_dir(&self) -> &Path {
788        &self.target_dir
789    }
790
791    /// Selected Cargo package names.
792    #[must_use]
793    pub fn packages(&self) -> &[String] {
794        &self.packages
795    }
796
797    /// Cargo-target ownership mode used for cache misses.
798    #[must_use]
799    pub const fn cache_mode(&self) -> &WasmBuildCacheMode {
800        &self.cache_mode
801    }
802}
803
804impl WasmBuildOutcome {
805    /// Read the common build record.
806    #[must_use]
807    pub const fn record(&self) -> &WasmBuildRecord {
808        match self {
809            Self::Built(record) | Self::Reused(record) => record,
810        }
811    }
812
813    /// Report whether exact matching artifacts were reused.
814    #[must_use]
815    pub const fn is_reused(&self) -> bool {
816        matches!(self, Self::Reused(_))
817    }
818}
819
820impl WasmBuildRecord {
821    /// Exact build fingerprint used by the atomic cache stamp.
822    #[must_use]
823    pub const fn fingerprint(&self) -> InputDigest {
824        self.fingerprint
825    }
826
827    /// Exact digest of package sources, lockfile, and configuration inputs.
828    #[must_use]
829    pub const fn input_digest(&self) -> InputDigest {
830        self.input_digest
831    }
832
833    /// Expected Wasm artifacts produced or reused by the build.
834    #[must_use]
835    pub fn artifacts(&self) -> &[PathBuf] {
836        &self.artifacts
837    }
838
839    /// Phase timings captured by the cacheable build operation.
840    #[must_use]
841    pub const fn timings(&self) -> WasmBuildTimings {
842        self.timings
843    }
844
845    /// Cache maintenance attempted under the build lock, when configured.
846    #[must_use]
847    pub const fn maintenance(&self) -> Option<&WasmBuildCacheMaintenance> {
848        self.maintenance.as_ref()
849    }
850
851    /// Scheduled caller-owned shared-target maintenance, when configured.
852    #[must_use]
853    pub const fn shared_incremental_maintenance(
854        &self,
855    ) -> Option<&SharedIncrementalTargetMaintenanceOutcome> {
856        self.shared_incremental_maintenance.as_ref()
857    }
858}
859
860impl WasmBuildTimings {
861    /// Time spent waiting for the output-directory process lock.
862    #[must_use]
863    pub const fn lock_wait(self) -> Duration {
864        self.lock_wait
865    }
866
867    /// Time spent waiting for a shared incremental-target lock, when configured.
868    #[must_use]
869    pub const fn shared_incremental_lock_wait(self) -> Option<Duration> {
870        self.shared_incremental_lock_wait
871    }
872
873    /// Time spent resolving toolchain identity, Cargo metadata, and exact inputs.
874    #[must_use]
875    pub const fn input_resolution(self) -> Duration {
876        self.input_resolution.total
877    }
878
879    /// Detailed tool, metadata, discovery, and hashing timings.
880    #[must_use]
881    pub const fn input_resolution_detail(self) -> WasmInputResolutionTimings {
882        self.input_resolution
883    }
884
885    /// Time spent in `cargo build`, or `None` for a cache hit.
886    #[must_use]
887    pub const fn cargo_build(self) -> Option<Duration> {
888        self.cargo_build
889    }
890
891    /// Time spent on configured best-effort cache maintenance.
892    #[must_use]
893    pub const fn cache_maintenance(self) -> Option<Duration> {
894        self.cache_maintenance
895    }
896
897    /// Total operation duration, including lock coordination.
898    #[must_use]
899    pub const fn total(self) -> Duration {
900        self.total
901    }
902}
903
904impl WasmInputResolutionTimings {
905    /// Time spent reading Cargo and rustc identities.
906    #[must_use]
907    pub const fn tool_identity(self) -> Duration {
908        self.tool_identity
909    }
910
911    /// Time spent running and decoding `cargo metadata`.
912    #[must_use]
913    pub const fn cargo_metadata(self) -> Duration {
914        self.cargo_metadata
915    }
916
917    /// Time spent resolving packages, configuration, and watched paths.
918    #[must_use]
919    pub const fn input_discovery(self) -> Duration {
920        self.input_discovery
921    }
922
923    /// Time spent reading and hashing exact input contents.
924    #[must_use]
925    pub const fn content_hashing(self) -> Duration {
926        self.content_hashing
927    }
928
929    /// Complete input-resolution duration.
930    #[must_use]
931    pub const fn total(self) -> Duration {
932        self.total
933    }
934
935    const fn include(&mut self, other: Self) {
936        self.tool_identity = self.tool_identity.saturating_add(other.tool_identity);
937        self.cargo_metadata = self.cargo_metadata.saturating_add(other.cargo_metadata);
938        self.input_discovery = self.input_discovery.saturating_add(other.input_discovery);
939        self.content_hashing = self.content_hashing.saturating_add(other.content_hashing);
940        self.total = self.total.saturating_add(other.total);
941    }
942}
943
944impl CargoBuildInput {
945    /// Stable checkout-independent label used while hashing this input.
946    #[must_use]
947    pub fn label(&self) -> &Path {
948        &self.label
949    }
950
951    /// Resolved file or directory read by the Cargo build.
952    #[must_use]
953    pub fn path(&self) -> &Path {
954        &self.path
955    }
956}
957
958impl ResolvedCargoBuildInputs {
959    /// Exact build fingerprint including Cargo inputs, tools, arguments, and environment.
960    #[must_use]
961    pub const fn fingerprint(&self) -> InputDigest {
962        self.fingerprint
963    }
964
965    /// Exact digest of local source and configuration contents.
966    #[must_use]
967    pub const fn input_digest(&self) -> InputDigest {
968        self.input_digest
969    }
970
971    /// Stable logical labels and resolved local input paths.
972    #[must_use]
973    pub fn inputs(&self) -> &[CargoBuildInput] {
974        &self.inputs
975    }
976
977    /// Generated-state roots excluded while recursively hashing local inputs.
978    ///
979    /// These exclusions are derived by `ic-testkit`; callers cannot add
980    /// arbitrary exclusions through this snapshot.
981    #[must_use]
982    pub fn exclusions(&self) -> &[PathBuf] {
983        &self.exclusions
984    }
985
986    /// Timings for tool identity, metadata, discovery, and content hashing.
987    #[must_use]
988    pub const fn timings(&self) -> WasmInputResolutionTimings {
989        self.timings
990    }
991
992    /// Resolve `spec` again and report whether its exact identity is unchanged.
993    pub fn is_current(&self, spec: &WasmBuildSpec) -> Result<bool, WasmBuildError> {
994        resolve_cargo_build_inputs(spec).map(|current| current.fingerprint == self.fingerprint)
995    }
996
997    /// Rehash the already discovered Cargo source/configuration set.
998    ///
999    /// This is cheaper than rerunning Cargo metadata and is intended for
1000    /// before/after guards around external artifact transformations. Resolve a
1001    /// new snapshot to observe tool, argument, environment, or dependency-graph
1002    /// identity changes between separate acquisitions.
1003    pub fn is_content_current(&self) -> Result<bool, WasmBuildError> {
1004        self.current_input_digest()
1005            .map(|current| current == self.input_digest)
1006    }
1007
1008    pub(super) fn current_input_digest(&self) -> Result<InputDigest, WasmBuildError> {
1009        let inputs = self
1010            .inputs
1011            .iter()
1012            .map(|input| (input.label.clone(), input.path.clone()))
1013            .collect::<Vec<_>>();
1014        digest_labeled_paths("wasm-source-inputs-v1", &inputs, &self.exclusions).map_err(|source| {
1015            WasmBuildError::Io {
1016                operation: "rehash resolved Cargo build inputs",
1017                path: self
1018                    .inputs
1019                    .first()
1020                    .map_or_else(PathBuf::new, |input| input.path.clone()),
1021                source,
1022            }
1023        })
1024    }
1025}
1026
1027impl SharedIncrementalTargetInspection {
1028    /// Canonical shared Cargo target directory that was inspected.
1029    #[must_use]
1030    pub fn target_dir(&self) -> &Path {
1031        &self.target_dir
1032    }
1033
1034    /// Logical bytes currently occupied by the complete shared target.
1035    #[must_use]
1036    pub const fn logical_size_bytes(&self) -> u64 {
1037        self.logical_size_bytes
1038    }
1039
1040    /// Most recent build use recorded by `ic-testkit`, or the directory mtime for older targets.
1041    #[must_use]
1042    pub const fn last_used(&self) -> SystemTime {
1043        self.last_used
1044    }
1045
1046    /// Time spent waiting for another process using the shared target.
1047    #[must_use]
1048    pub const fn lock_wait(&self) -> Duration {
1049        self.lock_wait
1050    }
1051}
1052
1053impl SharedIncrementalTargetPrunePolicy {
1054    /// Create an explicit policy without a clearing threshold.
1055    #[must_use]
1056    pub const fn new() -> Self {
1057        Self {
1058            max_age: None,
1059            max_size_bytes: None,
1060        }
1061    }
1062
1063    /// Clear shared Cargo state when its recorded use is older than `max_age`.
1064    #[must_use]
1065    pub const fn with_max_age(mut self, max_age: Duration) -> Self {
1066        self.max_age = Some(max_age);
1067        self
1068    }
1069
1070    /// Clear shared Cargo state when its logical size exceeds `bytes`.
1071    #[must_use]
1072    pub const fn with_max_size_bytes(mut self, bytes: u64) -> Self {
1073        self.max_size_bytes = Some(bytes);
1074        self
1075    }
1076
1077    /// Configured maximum time since recorded build use.
1078    #[must_use]
1079    pub const fn max_age(self) -> Option<Duration> {
1080        self.max_age
1081    }
1082
1083    /// Configured maximum logical target size.
1084    #[must_use]
1085    pub const fn max_size_bytes(self) -> Option<u64> {
1086        self.max_size_bytes
1087    }
1088
1089    fn maintenance_identity(self) -> String {
1090        format!(
1091            "age={:?};size={:?}",
1092            self.max_age.map(|duration| duration.as_nanos()),
1093            self.max_size_bytes
1094        )
1095    }
1096}
1097
1098impl SharedIncrementalTargetMaintenance {
1099    /// Canonical shared Cargo target directory maintained under lock.
1100    #[must_use]
1101    pub fn target_dir(&self) -> &Path {
1102        &self.target_dir
1103    }
1104
1105    /// Logical bytes observed before applying the policy.
1106    #[must_use]
1107    pub const fn logical_size_bytes_before(&self) -> u64 {
1108        self.logical_size_bytes_before
1109    }
1110
1111    /// Logical bytes retained after applying the policy.
1112    #[must_use]
1113    pub const fn logical_size_bytes_after(&self) -> u64 {
1114        self.logical_size_bytes_after
1115    }
1116
1117    /// Most recent build use observed before applying the policy.
1118    #[must_use]
1119    pub const fn last_used_before(&self) -> SystemTime {
1120        self.last_used_before
1121    }
1122
1123    /// Whether a configured limit caused the mutable target contents to be cleared.
1124    #[must_use]
1125    pub const fn was_cleared(&self) -> bool {
1126        self.cleared
1127    }
1128
1129    /// Time spent waiting for another process using the shared target.
1130    #[must_use]
1131    pub const fn lock_wait(&self) -> Duration {
1132        self.lock_wait
1133    }
1134
1135    /// Time spent measuring and, when required, clearing the target.
1136    #[must_use]
1137    pub const fn maintenance(&self) -> Duration {
1138        self.maintenance
1139    }
1140}
1141
1142impl std::fmt::Display for SharedIncrementalTargetMaintenance {
1143    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1144        write!(
1145            formatter,
1146            "target={} action={} bytes={}=>{} lock={:?} maintenance={:?}",
1147            self.target_dir.display(),
1148            if self.cleared { "cleared" } else { "retained" },
1149            self.logical_size_bytes_before,
1150            self.logical_size_bytes_after,
1151            self.lock_wait,
1152            self.maintenance,
1153        )
1154    }
1155}
1156
1157impl SharedIncrementalTargetMaintenanceOutcome {
1158    /// Configured or canonical target associated with this result.
1159    #[must_use]
1160    pub fn target_dir(&self) -> &Path {
1161        match self {
1162            Self::Missing { target_dir } | Self::Skipped { target_dir, .. } => target_dir,
1163            Self::Performed { maintenance, .. } => maintenance.target_dir(),
1164        }
1165    }
1166
1167    /// Completed maintenance report, when retention was evaluated.
1168    #[must_use]
1169    pub const fn maintenance(&self) -> Option<&SharedIncrementalTargetMaintenance> {
1170        match self {
1171            Self::Performed { maintenance, .. } => Some(maintenance),
1172            Self::Missing { .. } | Self::Skipped { .. } => None,
1173        }
1174    }
1175
1176    /// Whether retention was evaluated during this call.
1177    #[must_use]
1178    pub const fn was_performed(&self) -> bool {
1179        matches!(self, Self::Performed { .. })
1180    }
1181
1182    /// Time spent waiting for another process, when the target existed.
1183    #[must_use]
1184    pub const fn lock_wait(&self) -> Option<Duration> {
1185        match self {
1186            Self::Missing { .. } => None,
1187            Self::Skipped { lock_wait, .. } => Some(*lock_wait),
1188            Self::Performed { maintenance, .. } => Some(maintenance.lock_wait()),
1189        }
1190    }
1191
1192    /// Time spent checking the schedule marker, when the target existed.
1193    #[must_use]
1194    pub const fn schedule_check(&self) -> Option<Duration> {
1195        match self {
1196            Self::Missing { .. } => None,
1197            Self::Skipped { schedule_check, .. } | Self::Performed { schedule_check, .. } => {
1198                Some(*schedule_check)
1199            }
1200        }
1201    }
1202}
1203
1204impl std::fmt::Display for SharedIncrementalTargetMaintenanceOutcome {
1205    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206        match self {
1207            Self::Missing { target_dir } => {
1208                write!(formatter, "target={} action=missing", target_dir.display())
1209            }
1210            Self::Skipped {
1211                target_dir,
1212                lock_wait,
1213                schedule_check,
1214            } => write!(
1215                formatter,
1216                "target={} action=skipped lock={lock_wait:?} schedule={schedule_check:?}",
1217                target_dir.display(),
1218            ),
1219            Self::Performed {
1220                maintenance,
1221                schedule_check,
1222            } => write!(formatter, "{maintenance} schedule={schedule_check:?}"),
1223        }
1224    }
1225}
1226
1227impl std::fmt::Display for WasmBuildTimings {
1228    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1229        write!(
1230            formatter,
1231            "total={:?} lock={:?} shared_lock={:?} inputs={:?} cargo={:?} maintenance={:?}",
1232            self.total,
1233            self.lock_wait,
1234            self.shared_incremental_lock_wait,
1235            self.input_resolution.total,
1236            self.cargo_build,
1237            self.cache_maintenance,
1238        )
1239    }
1240}
1241
1242impl std::fmt::Display for WasmBuildOutcome {
1243    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1244        let state = if self.is_reused() { "reused" } else { "built" };
1245        write!(
1246            formatter,
1247            "{state} fingerprint={} artifacts={} {}",
1248            self.record().fingerprint,
1249            self.record().artifacts.len(),
1250            self.record().timings,
1251        )?;
1252        if let Some(maintenance) = self.record().shared_incremental_maintenance() {
1253            write!(formatter, " shared_maintenance=({maintenance})")?;
1254        }
1255        Ok(())
1256    }
1257}
1258
1259/// Resolve the exact Cargo source, configuration, toolchain, argument, and environment identity.
1260///
1261/// This performs the same resolution used before and after cached Wasm builds
1262/// without running `cargo build`.
1263pub fn resolve_cargo_build_inputs(
1264    spec: &WasmBuildSpec,
1265) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1266    validate_spec(spec)?;
1267    build_fingerprint(spec)
1268}
1269
1270/// Inspect one configured shared Cargo target under its build coordination lock.
1271///
1272/// Returns `None` without creating anything when the caller-owned target does
1273/// not exist. This operation never removes Cargo state.
1274pub fn inspect_shared_incremental_target(
1275    spec: &WasmBuildSpec,
1276) -> Result<Option<SharedIncrementalTargetInspection>, WasmBuildError> {
1277    if !shared_incremental_target_exists(spec, "inspect shared incremental Cargo target")? {
1278        return Ok(None);
1279    }
1280
1281    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1282    let logical_size_bytes =
1283        directory_logical_size(&canonical).map_err(|source| WasmBuildError::Io {
1284            operation: "measure shared incremental Cargo target",
1285            path: canonical.clone(),
1286            source,
1287        })?;
1288    let last_used = cache_entry_last_used(&canonical).map_err(|source| WasmBuildError::Io {
1289        operation: "read shared incremental Cargo target use time",
1290        path: canonical.clone(),
1291        source,
1292    })?;
1293    Ok(Some(SharedIncrementalTargetInspection {
1294        target_dir: canonical,
1295        logical_size_bytes,
1296        last_used,
1297        lock_wait,
1298    }))
1299}
1300
1301/// Apply explicit whole-target retention to caller-owned shared Cargo state.
1302///
1303/// Returns `None` without creating anything when the target does not exist.
1304/// Policy evaluation and any clearing occur under the same cross-process lock
1305/// used by shared-incremental builds. The target root, `CACHEDIR.TAG`, and
1306/// `.ic-testkit` lock metadata are preserved, so another process cannot enter
1307/// through a replacement lock while maintenance is active.
1308/// Every other target child is removed when a limit is exceeded; unrelated
1309/// data that must survive must not be colocated there. Exact Cargo input
1310/// resolution first rejects targets overlapping source or configuration.
1311///
1312/// This function is never called automatically by exact Wasm acquisitions.
1313/// Consumers retain ownership of when mutable incremental state may be lost.
1314pub fn maintain_shared_incremental_target(
1315    spec: &WasmBuildSpec,
1316    policy: SharedIncrementalTargetPrunePolicy,
1317) -> Result<Option<SharedIncrementalTargetMaintenance>, WasmBuildError> {
1318    if !shared_incremental_target_exists(
1319        spec,
1320        "inspect shared incremental Cargo target before maintenance",
1321    )? {
1322        return Ok(None);
1323    }
1324
1325    // Reuse the exact build resolver so destructive maintenance cannot act on
1326    // a target that overlaps Cargo sources, configuration, or additional
1327    // inputs. The target itself is excluded as generated state during hashing.
1328    let _ = resolve_cargo_build_inputs(spec)?;
1329    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1330    maintain_shared_incremental_target_locked(&canonical, policy, lock_wait).map(Some)
1331}
1332
1333/// Apply whole-target retention at most once per interval across processes.
1334///
1335/// The schedule marker is checked under the same lock used by shared Cargo
1336/// builds. A matching successful pass inside `minimum_interval` returns
1337/// [`SharedIncrementalTargetMaintenanceOutcome::Skipped`] without resolving
1338/// Cargo inputs or traversing the target. Missing targets are not created.
1339/// Changing the policy makes maintenance immediately due, and a zero interval
1340/// always evaluates retention.
1341///
1342/// Due maintenance performs exact Cargo input resolution before inspecting or
1343/// clearing the target. Failures are returned and are not recorded as a
1344/// successful pass, so an unsafe configuration cannot be hidden by the
1345/// schedule.
1346pub fn maintain_shared_incremental_target_at_most_every(
1347    spec: &WasmBuildSpec,
1348    policy: SharedIncrementalTargetPrunePolicy,
1349    minimum_interval: Duration,
1350) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1351    let target_dir =
1352        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
1353            message: "shared incremental target is not configured".to_owned(),
1354        })?;
1355    if !shared_incremental_target_exists(
1356        spec,
1357        "inspect shared incremental Cargo target before scheduled maintenance",
1358    )? {
1359        return Ok(SharedIncrementalTargetMaintenanceOutcome::Missing { target_dir });
1360    }
1361
1362    let (_lock, lock_wait, canonical) = lock_shared_incremental_target(spec)?;
1363    let schedule = schedule_shared_incremental_target_maintenance(
1364        &canonical,
1365        policy,
1366        minimum_interval,
1367        lock_wait,
1368    )?;
1369    let schedule = match schedule {
1370        SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => return Ok(outcome),
1371        SharedIncrementalTargetMaintenanceSchedule::Due(due) => due,
1372    };
1373
1374    // Keep the schedule decision and maintenance in one critical section so
1375    // concurrent test binaries cannot all perform the same expensive scan.
1376    let _ = resolve_cargo_build_inputs(spec)?;
1377    perform_due_shared_incremental_target_maintenance(&canonical, policy, lock_wait, schedule)
1378}
1379
1380enum SharedIncrementalTargetMaintenanceSchedule {
1381    Skipped(SharedIncrementalTargetMaintenanceOutcome),
1382    Due(DueSharedIncrementalTargetMaintenance),
1383}
1384
1385struct DueSharedIncrementalTargetMaintenance {
1386    schedule_root: PathBuf,
1387    maintenance_identity: String,
1388    schedule_check: Duration,
1389}
1390
1391fn schedule_shared_incremental_target_maintenance(
1392    canonical: &Path,
1393    policy: SharedIncrementalTargetPrunePolicy,
1394    minimum_interval: Duration,
1395    lock_wait: Duration,
1396) -> Result<SharedIncrementalTargetMaintenanceSchedule, WasmBuildError> {
1397    let schedule_root = canonical.join(".ic-testkit");
1398    let maintenance_identity = policy.maintenance_identity();
1399    let schedule_started = Instant::now();
1400    let due = cache_maintenance_due(
1401        &schedule_root,
1402        Some(minimum_interval),
1403        &maintenance_identity,
1404    )
1405    .map_err(wasm_cache_fs_error)?;
1406    let schedule_check = schedule_started.elapsed();
1407    if !due {
1408        return Ok(SharedIncrementalTargetMaintenanceSchedule::Skipped(
1409            SharedIncrementalTargetMaintenanceOutcome::Skipped {
1410                target_dir: canonical.to_owned(),
1411                lock_wait,
1412                schedule_check,
1413            },
1414        ));
1415    }
1416    Ok(SharedIncrementalTargetMaintenanceSchedule::Due(
1417        DueSharedIncrementalTargetMaintenance {
1418            schedule_root,
1419            maintenance_identity,
1420            schedule_check,
1421        },
1422    ))
1423}
1424
1425fn perform_due_shared_incremental_target_maintenance(
1426    canonical: &Path,
1427    policy: SharedIncrementalTargetPrunePolicy,
1428    lock_wait: Duration,
1429    due: DueSharedIncrementalTargetMaintenance,
1430) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1431    let DueSharedIncrementalTargetMaintenance {
1432        schedule_root,
1433        maintenance_identity,
1434        schedule_check,
1435    } = due;
1436    let maintenance = maintain_shared_incremental_target_locked(canonical, policy, lock_wait)?;
1437    record_cache_maintenance(&schedule_root, &maintenance_identity).map_err(wasm_cache_fs_error)?;
1438    Ok(SharedIncrementalTargetMaintenanceOutcome::Performed {
1439        maintenance,
1440        schedule_check,
1441    })
1442}
1443
1444fn maintain_shared_incremental_target_locked(
1445    canonical: &Path,
1446    policy: SharedIncrementalTargetPrunePolicy,
1447    lock_wait: Duration,
1448) -> Result<SharedIncrementalTargetMaintenance, WasmBuildError> {
1449    let started = Instant::now();
1450    let logical_size_bytes_before =
1451        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1452            operation: "measure shared incremental Cargo target before maintenance",
1453            path: canonical.to_owned(),
1454            source,
1455        })?;
1456    let last_used_before =
1457        cache_entry_last_used(canonical).map_err(|source| WasmBuildError::Io {
1458            operation: "read shared incremental Cargo target use time before maintenance",
1459            path: canonical.to_owned(),
1460            source,
1461        })?;
1462    let expired = policy.max_age.is_some_and(|max_age| {
1463        SystemTime::now()
1464            .duration_since(last_used_before)
1465            .is_ok_and(|age| age > max_age)
1466    });
1467    let oversized = policy
1468        .max_size_bytes
1469        .is_some_and(|max_size_bytes| logical_size_bytes_before > max_size_bytes);
1470    let cleared = expired || oversized;
1471    if cleared {
1472        clear_shared_incremental_target_contents(canonical)?;
1473        record_cache_entry_use(canonical)?;
1474    }
1475    let logical_size_bytes_after = if cleared {
1476        directory_logical_size(canonical).map_err(|source| WasmBuildError::Io {
1477            operation: "measure shared incremental Cargo target after maintenance",
1478            path: canonical.to_owned(),
1479            source,
1480        })?
1481    } else {
1482        logical_size_bytes_before
1483    };
1484    Ok(SharedIncrementalTargetMaintenance {
1485        target_dir: canonical.to_owned(),
1486        logical_size_bytes_before,
1487        logical_size_bytes_after,
1488        last_used_before,
1489        cleared,
1490        lock_wait,
1491        maintenance: started.elapsed(),
1492    })
1493}
1494
1495fn clear_shared_incremental_target_contents(target_dir: &Path) -> Result<(), WasmBuildError> {
1496    let entries = fs::read_dir(target_dir).map_err(|source| WasmBuildError::Io {
1497        operation: "read shared incremental Cargo target for maintenance",
1498        path: target_dir.to_owned(),
1499        source,
1500    })?;
1501    for entry in entries {
1502        let path = entry
1503            .map_err(|source| WasmBuildError::Io {
1504                operation: "read shared incremental Cargo target entry for maintenance",
1505                path: target_dir.to_owned(),
1506                source,
1507            })?
1508            .path();
1509        let preserved = path
1510            .file_name()
1511            .is_some_and(|name| name == ".ic-testkit" || name == "CACHEDIR.TAG");
1512        if !preserved {
1513            remove_path_if_present(&path).map_err(|source| WasmBuildError::Io {
1514                operation: "clear shared incremental Cargo target entry",
1515                path,
1516                source,
1517            })?;
1518        }
1519    }
1520    Ok(())
1521}
1522
1523/// Build or reuse one exact set of Cargo Wasm artifacts.
1524///
1525/// The operation takes an exclusive process lock scoped to `target_dir`, then
1526/// fingerprints all declared inputs. A cache hit requires both a matching
1527/// atomic stamp and every expected nonempty Wasm output. Failed or interrupted
1528/// builds never publish a successful stamp.
1529pub fn build_wasm_canisters_cached(
1530    spec: &WasmBuildSpec,
1531) -> Result<WasmBuildOutcome, WasmBuildError> {
1532    build_wasm_canisters_cached_internal(spec, &mut ProgressReporter::silent())
1533}
1534
1535/// Build or reuse one exact Wasm set while streaming structured progress.
1536///
1537/// Cargo output remains captured for [`WasmBuildError::CommandFailed`] and is
1538/// additionally forwarded as raw chunks when enabled. Potentially long input
1539/// resolution, lock waits, maintenance, Cargo, and publication phases emit
1540/// periodic heartbeats, so a legitimate acquisition need not appear stalled.
1541/// Observer panics propagate after joining active phase work, terminating the
1542/// Cargo child when applicable, and preserving normal cleanup.
1543pub fn build_wasm_canisters_cached_with_progress<F>(
1544    spec: &WasmBuildSpec,
1545    config: WasmBuildProgressConfig,
1546    mut observer: F,
1547) -> Result<WasmBuildOutcome, WasmBuildError>
1548where
1549    F: FnMut(WasmBuildProgressEvent),
1550{
1551    if config.heartbeat_interval == Some(Duration::ZERO) {
1552        return Err(WasmBuildError::InvalidSpec {
1553            message: "Wasm build progress heartbeat interval must be greater than zero".to_owned(),
1554        });
1555    }
1556    build_wasm_canisters_cached_internal(
1557        spec,
1558        &mut ProgressReporter::observed(config, &mut observer),
1559    )
1560}
1561
1562fn build_wasm_canisters_cached_internal(
1563    spec: &WasmBuildSpec,
1564    progress: &mut ProgressReporter<'_>,
1565) -> Result<WasmBuildOutcome, WasmBuildError> {
1566    let total_started = Instant::now();
1567    validate_spec(spec)?;
1568    progress.emit(WasmBuildProgressEvent::Started);
1569    if spec.shared_incremental_maintenance_config.is_some() {
1570        let outcome = build_wasm_canisters_cached_with_scheduled_shared_maintenance(
1571            spec,
1572            total_started,
1573            progress,
1574        )?;
1575        emit_finished_progress(&outcome, progress);
1576        return Ok(outcome);
1577    }
1578    let (cache_lock, first_lock_wait) =
1579        lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
1580    ensure_cache_directory_tag(&spec.target_dir)?;
1581
1582    let resolved = resolve_inputs_with_progress(spec, progress)?;
1583    if let Some(outcome) = try_reuse_wasm_artifacts(
1584        spec,
1585        &resolved,
1586        first_lock_wait,
1587        &SharedIncrementalAcquisitionContext::default(),
1588        total_started,
1589        progress,
1590    )? {
1591        emit_finished_progress(&outcome, progress);
1592        return Ok(outcome);
1593    }
1594    progress.emit(WasmBuildProgressEvent::CacheMiss {
1595        fingerprint: resolved.fingerprint,
1596    });
1597
1598    let outcome = match &spec.cache_mode {
1599        WasmBuildCacheMode::Isolated => {
1600            let cache_entry = cache_entry_directory(spec, resolved.fingerprint);
1601            build_wasm_cache_miss(
1602                spec,
1603                resolved,
1604                first_lock_wait,
1605                SharedIncrementalAcquisitionContext::default(),
1606                cache_entry,
1607                total_started,
1608                progress,
1609            )
1610        }
1611        WasmBuildCacheMode::SharedIncremental { .. } => {
1612            drop(cache_lock);
1613            let configured_target = shared_incremental_target(spec)
1614                .expect("shared cache mode must resolve a shared Cargo target");
1615            progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
1616                target_dir: configured_target,
1617            });
1618            let (shared_lock, shared_lock_wait, shared_target) =
1619                lock_shared_incremental_target_with_progress(spec, progress)?;
1620            progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
1621                target_dir: shared_target.clone(),
1622                wait: shared_lock_wait,
1623            });
1624            let (_cache_lock, second_lock_wait) =
1625                lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
1626            ensure_cache_directory_tag(&spec.target_dir)?;
1627
1628            let mut current = resolve_inputs_with_progress(spec, progress)?;
1629            current.timings.include(resolved.timings);
1630            let lock_wait = first_lock_wait.saturating_add(second_lock_wait);
1631            let shared_incremental = SharedIncrementalAcquisitionContext {
1632                lock_wait: Some(shared_lock_wait),
1633                maintenance: None,
1634            };
1635            if let Some(outcome) = try_reuse_wasm_artifacts(
1636                spec,
1637                &current,
1638                lock_wait,
1639                &shared_incremental,
1640                total_started,
1641                progress,
1642            )? {
1643                emit_finished_progress(&outcome, progress);
1644                return Ok(outcome);
1645            }
1646
1647            let outcome = build_wasm_cache_miss(
1648                spec,
1649                current,
1650                lock_wait,
1651                shared_incremental,
1652                shared_target,
1653                total_started,
1654                progress,
1655            );
1656            drop(shared_lock);
1657            outcome
1658        }
1659    }?;
1660    emit_finished_progress(&outcome, progress);
1661    Ok(outcome)
1662}
1663
1664fn build_wasm_canisters_cached_with_scheduled_shared_maintenance(
1665    spec: &WasmBuildSpec,
1666    total_started: Instant,
1667    progress: &mut ProgressReporter<'_>,
1668) -> Result<WasmBuildOutcome, WasmBuildError> {
1669    let configured_target = shared_incremental_target(spec)
1670        .expect("validated scheduled maintenance must have a shared Cargo target");
1671    progress.emit(WasmBuildProgressEvent::SharedTargetLockStarted {
1672        target_dir: configured_target,
1673    });
1674    let (_shared_lock, shared_lock_wait, shared_target) =
1675        lock_shared_incremental_target_with_progress(spec, progress)?;
1676    progress.emit(WasmBuildProgressEvent::SharedTargetLockAcquired {
1677        target_dir: shared_target.clone(),
1678        wait: shared_lock_wait,
1679    });
1680    let (_cache_lock, lock_wait) = lock_wasm_build_cache_with_progress(&spec.target_dir, progress)?;
1681    ensure_cache_directory_tag(&spec.target_dir)?;
1682
1683    // Resolution under both locks proves the target boundary once for the
1684    // scheduled retention pass and the following exact-cache acquisition.
1685    let resolved = resolve_inputs_with_progress(spec, progress)?;
1686    let shared_maintenance = perform_configured_shared_incremental_target_maintenance(
1687        spec,
1688        &shared_target,
1689        shared_lock_wait,
1690        progress,
1691    )?;
1692    let shared_incremental = SharedIncrementalAcquisitionContext {
1693        lock_wait: Some(shared_lock_wait),
1694        maintenance: Some(shared_maintenance),
1695    };
1696    if let Some(outcome) = try_reuse_wasm_artifacts(
1697        spec,
1698        &resolved,
1699        lock_wait,
1700        &shared_incremental,
1701        total_started,
1702        progress,
1703    )? {
1704        return Ok(outcome);
1705    }
1706    progress.emit(WasmBuildProgressEvent::CacheMiss {
1707        fingerprint: resolved.fingerprint,
1708    });
1709    build_wasm_cache_miss(
1710        spec,
1711        resolved,
1712        lock_wait,
1713        shared_incremental,
1714        shared_target,
1715        total_started,
1716        progress,
1717    )
1718}
1719
1720fn perform_configured_shared_incremental_target_maintenance(
1721    spec: &WasmBuildSpec,
1722    shared_target: &Path,
1723    lock_wait: Duration,
1724    progress: &mut ProgressReporter<'_>,
1725) -> Result<SharedIncrementalTargetMaintenanceOutcome, WasmBuildError> {
1726    let config = spec
1727        .shared_incremental_maintenance_config
1728        .expect("configured shared-target maintenance must have settings");
1729    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceStarted {
1730        target_dir: shared_target.to_owned(),
1731    });
1732    let schedule = schedule_shared_incremental_target_maintenance(
1733        shared_target,
1734        config.policy,
1735        config.minimum_interval,
1736        lock_wait,
1737    )?;
1738    let outcome =
1739        progress.run_phase(
1740            WasmBuildProgressPhase::SharedTargetMaintenance,
1741            || match schedule {
1742                SharedIncrementalTargetMaintenanceSchedule::Skipped(outcome) => Ok(outcome),
1743                SharedIncrementalTargetMaintenanceSchedule::Due(due) => {
1744                    perform_due_shared_incremental_target_maintenance(
1745                        shared_target,
1746                        config.policy,
1747                        lock_wait,
1748                        due,
1749                    )
1750                }
1751            },
1752        )?;
1753    progress.emit(WasmBuildProgressEvent::SharedTargetMaintenanceFinished {
1754        outcome: outcome.clone(),
1755    });
1756    Ok(outcome)
1757}
1758
1759fn resolve_inputs_with_progress(
1760    spec: &WasmBuildSpec,
1761    progress: &mut ProgressReporter<'_>,
1762) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
1763    let resolved = build_fingerprint_with_progress(spec, progress)?;
1764    progress.emit(WasmBuildProgressEvent::InputsResolved {
1765        fingerprint: resolved.fingerprint,
1766        input_digest: resolved.input_digest,
1767        elapsed: resolved.timings.total,
1768    });
1769    Ok(resolved)
1770}
1771
1772fn emit_finished_progress(outcome: &WasmBuildOutcome, progress: &mut ProgressReporter<'_>) {
1773    let state = if outcome.is_reused() {
1774        progress.emit(WasmBuildProgressEvent::CacheHit {
1775            fingerprint: outcome.record().fingerprint,
1776        });
1777        WasmBuildProgressOutcome::Reused
1778    } else {
1779        WasmBuildProgressOutcome::Built
1780    };
1781    progress.emit(WasmBuildProgressEvent::Finished {
1782        outcome: state,
1783        fingerprint: outcome.record().fingerprint,
1784        elapsed: outcome.record().timings.total,
1785    });
1786}
1787
1788#[derive(Clone, Debug, Default)]
1789struct SharedIncrementalAcquisitionContext {
1790    lock_wait: Option<Duration>,
1791    maintenance: Option<SharedIncrementalTargetMaintenanceOutcome>,
1792}
1793
1794fn try_reuse_wasm_artifacts(
1795    spec: &WasmBuildSpec,
1796    resolved: &ResolvedCargoBuildInputs,
1797    lock_wait: Duration,
1798    shared_incremental: &SharedIncrementalAcquisitionContext,
1799    total_started: Instant,
1800    progress: &mut ProgressReporter<'_>,
1801) -> Result<Option<WasmBuildOutcome>, WasmBuildError> {
1802    let fingerprint = resolved.fingerprint;
1803    let artifacts = expected_artifacts(spec, &spec.target_dir);
1804    let cache_entry = cache_entry_directory(spec, fingerprint);
1805    let artifacts_match = progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
1806        artifact_set_matches(&artifacts, fingerprint)
1807    });
1808    if artifacts_match {
1809        progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
1810            record_cache_entry_use_if_present(&cache_entry)
1811        })?;
1812        return Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1813            spec,
1814            BuildRecordInput {
1815                fingerprint,
1816                input_digest: resolved.input_digest,
1817                artifacts,
1818                lock_wait,
1819                shared_incremental: shared_incremental.clone(),
1820                input_resolution: resolved.timings,
1821                cargo_build: None,
1822                active_entry: &cache_entry,
1823            },
1824            total_started,
1825            progress,
1826        ))));
1827    }
1828
1829    let cached_artifacts = expected_artifacts(spec, &cache_entry);
1830    let cached_artifacts_match = progress
1831        .run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
1832            artifact_set_matches(&cached_artifacts, fingerprint)
1833        });
1834    if !cached_artifacts_match {
1835        return Ok(None);
1836    }
1837    progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
1838        materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1839        record_cache_entry_use(&cache_entry)
1840    })?;
1841    Ok(Some(WasmBuildOutcome::Reused(complete_build_record(
1842        spec,
1843        BuildRecordInput {
1844            fingerprint,
1845            input_digest: resolved.input_digest,
1846            artifacts,
1847            lock_wait,
1848            shared_incremental: shared_incremental.clone(),
1849            input_resolution: resolved.timings,
1850            cargo_build: None,
1851            active_entry: &cache_entry,
1852        },
1853        total_started,
1854        progress,
1855    ))))
1856}
1857
1858fn build_wasm_cache_miss(
1859    spec: &WasmBuildSpec,
1860    resolved: ResolvedCargoBuildInputs,
1861    lock_wait: Duration,
1862    shared_incremental: SharedIncrementalAcquisitionContext,
1863    cargo_target_dir: PathBuf,
1864    total_started: Instant,
1865    progress: &mut ProgressReporter<'_>,
1866) -> Result<WasmBuildOutcome, WasmBuildError> {
1867    let fingerprint = resolved.fingerprint;
1868    let mut input_resolution = resolved.timings;
1869    let artifacts = expected_artifacts(spec, &spec.target_dir);
1870    let cache_entry = cache_entry_directory(spec, fingerprint);
1871    remove_directory_if_present(&cache_entry)?;
1872    create_dir_all(
1873        &cache_entry,
1874        "create content-addressed Cargo target directory",
1875    )?;
1876    let incomplete_directory = IncompleteBuildDirectory::new(cache_entry.clone());
1877    let build_result = (|| {
1878        if matches!(
1879            spec.cache_mode,
1880            WasmBuildCacheMode::SharedIncremental { .. }
1881        ) {
1882            record_cache_entry_use(&cargo_target_dir)?;
1883        }
1884        let build_started = Instant::now();
1885        run_cargo_build(spec, &cargo_target_dir, progress)?;
1886        let cargo_build = build_started.elapsed();
1887        let built_artifacts = expected_artifacts(spec, &cargo_target_dir);
1888        let missing = missing_artifacts(&built_artifacts);
1889        if !missing.is_empty() {
1890            return Err(WasmBuildError::MissingArtifacts { paths: missing });
1891        }
1892
1893        let verified = resolve_inputs_with_progress(spec, progress)?;
1894        input_resolution.include(verified.timings);
1895        if fingerprint != verified.fingerprint {
1896            return Err(WasmBuildError::InputsChangedDuringBuild {
1897                before: fingerprint,
1898                after: verified.fingerprint,
1899            });
1900        }
1901
1902        let cached_artifacts = expected_artifacts(spec, &cache_entry);
1903        progress.run_phase(WasmBuildProgressPhase::ArtifactPublication, || {
1904            if cargo_target_dir != cache_entry {
1905                copy_wasm_artifacts(&built_artifacts, &cached_artifacts)?;
1906            }
1907            publish_artifact_stamps(&cached_artifacts, fingerprint)?;
1908            materialize_artifacts(&cached_artifacts, &artifacts, fingerprint)?;
1909            record_cache_entry_use(&cache_entry)
1910        })?;
1911
1912        Ok(WasmBuildOutcome::Built(complete_build_record(
1913            spec,
1914            BuildRecordInput {
1915                fingerprint,
1916                input_digest: resolved.input_digest,
1917                artifacts,
1918                lock_wait,
1919                shared_incremental,
1920                input_resolution,
1921                cargo_build: Some(cargo_build),
1922                active_entry: &cache_entry,
1923            },
1924            total_started,
1925            progress,
1926        )))
1927    })();
1928    finish_fingerprint_build(build_result, incomplete_directory)
1929}
1930
1931/// Prune fingerprint-specific Cargo target directories under `target_dir`.
1932///
1933/// Pruning uses the same exclusive process lock as builds. Entries older than
1934/// the configured age are removed first, then least-recently-used entries are
1935/// removed until the configured logical byte limit is met. Only direct child
1936/// directories with SHA-256 fingerprint names are eligible; caller-facing
1937/// artifacts and unrelated target contents are never removed.
1938pub fn prune_wasm_build_cache(
1939    target_dir: &Path,
1940    policy: WasmBuildCachePrunePolicy,
1941) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1942    let (_lock_file, _) = lock_wasm_build_cache(target_dir)?;
1943    ensure_cache_directory_tag(target_dir)?;
1944
1945    prune_wasm_build_cache_locked(target_dir, policy, None)
1946}
1947
1948struct BuildRecordInput<'a> {
1949    fingerprint: InputDigest,
1950    input_digest: InputDigest,
1951    artifacts: Vec<PathBuf>,
1952    lock_wait: Duration,
1953    shared_incremental: SharedIncrementalAcquisitionContext,
1954    input_resolution: WasmInputResolutionTimings,
1955    cargo_build: Option<Duration>,
1956    active_entry: &'a Path,
1957}
1958
1959fn complete_build_record(
1960    spec: &WasmBuildSpec,
1961    input: BuildRecordInput<'_>,
1962    total_started: Instant,
1963    progress: &mut ProgressReporter<'_>,
1964) -> WasmBuildRecord {
1965    let (maintenance, cache_maintenance) = spec.prune_policy.map_or((None, None), |policy| {
1966        progress.run_phase(WasmBuildProgressPhase::ExactCacheMaintenance, || {
1967            let cache_root = spec.target_dir.join(".ic-testkit/wasm-targets");
1968            let identity = policy.maintenance_identity();
1969            perform_scheduled_cache_maintenance(&cache_root, spec.prune_interval, &identity, || {
1970                prune_wasm_build_cache_locked(&spec.target_dir, policy, Some(input.active_entry))
1971                    .map_err(|error| error.to_string())
1972            })
1973        })
1974    });
1975    WasmBuildRecord {
1976        fingerprint: input.fingerprint,
1977        input_digest: input.input_digest,
1978        artifacts: input.artifacts,
1979        timings: WasmBuildTimings {
1980            lock_wait: input.lock_wait,
1981            shared_incremental_lock_wait: input.shared_incremental.lock_wait,
1982            input_resolution: input.input_resolution,
1983            cargo_build: input.cargo_build,
1984            cache_maintenance,
1985            total: total_started.elapsed(),
1986        },
1987        maintenance,
1988        shared_incremental_maintenance: input.shared_incremental.maintenance,
1989    }
1990}
1991
1992fn prune_wasm_build_cache_locked(
1993    target_dir: &Path,
1994    policy: WasmBuildCachePrunePolicy,
1995    protected_entry: Option<&Path>,
1996) -> Result<WasmBuildCachePruneReport, WasmBuildError> {
1997    let cache_root = target_dir.join(".ic-testkit/wasm-targets");
1998    prune_direct_child_directories(&cache_root, policy, protected_entry, is_sha256_directory)
1999        .map_err(wasm_cache_fs_error)
2000}
2001
2002struct IncompleteBuildDirectory {
2003    path: PathBuf,
2004    armed: bool,
2005}
2006
2007impl IncompleteBuildDirectory {
2008    const fn new(path: PathBuf) -> Self {
2009        Self { path, armed: true }
2010    }
2011
2012    fn preserve(mut self) {
2013        self.armed = false;
2014    }
2015
2016    fn cleanup(mut self) -> io::Result<()> {
2017        let result = remove_path_if_present(&self.path);
2018        if result.is_ok() {
2019            self.armed = false;
2020        }
2021        result
2022    }
2023}
2024
2025impl Drop for IncompleteBuildDirectory {
2026    fn drop(&mut self) {
2027        if self.armed {
2028            let _ = remove_path_if_present(&self.path);
2029        }
2030    }
2031}
2032
2033fn finish_fingerprint_build(
2034    result: Result<WasmBuildOutcome, WasmBuildError>,
2035    incomplete_directory: IncompleteBuildDirectory,
2036) -> Result<WasmBuildOutcome, WasmBuildError> {
2037    match result {
2038        Ok(outcome) => {
2039            incomplete_directory.preserve();
2040            Ok(outcome)
2041        }
2042        Err(build_error) => {
2043            let path = incomplete_directory.path.clone();
2044            match incomplete_directory.cleanup() {
2045                Ok(()) => Err(build_error),
2046                Err(source) => Err(WasmBuildError::FailedBuildCleanup {
2047                    build_error: Box::new(build_error),
2048                    path,
2049                    source,
2050                }),
2051            }
2052        }
2053    }
2054}
2055
2056fn lock_wasm_build_cache(target_dir: &Path) -> Result<(File, Duration), WasmBuildError> {
2057    create_dir_all(target_dir, "create Cargo target directory")?;
2058    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
2059    lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)
2060}
2061
2062fn lock_wasm_build_cache_with_progress(
2063    target_dir: &Path,
2064    progress: &mut ProgressReporter<'_>,
2065) -> Result<(File, Duration), WasmBuildError> {
2066    create_dir_all(target_dir, "create Cargo target directory")?;
2067    let lock_path = target_dir.join(".ic-testkit/wasm-build.lock");
2068    lock_cache_file_with_progress(&lock_path, WasmBuildProgressPhase::ExactCacheLock, progress)
2069}
2070
2071fn lock_shared_incremental_target(
2072    spec: &WasmBuildSpec,
2073) -> Result<(File, Duration, PathBuf), WasmBuildError> {
2074    lock_shared_incremental_target_internal(spec, None)
2075}
2076
2077fn lock_shared_incremental_target_with_progress(
2078    spec: &WasmBuildSpec,
2079    progress: &mut ProgressReporter<'_>,
2080) -> Result<(File, Duration, PathBuf), WasmBuildError> {
2081    lock_shared_incremental_target_internal(spec, Some(progress))
2082}
2083
2084fn lock_shared_incremental_target_internal(
2085    spec: &WasmBuildSpec,
2086    progress: Option<&mut ProgressReporter<'_>>,
2087) -> Result<(File, Duration, PathBuf), WasmBuildError> {
2088    let target_dir =
2089        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2090            message: "shared incremental target is not configured".to_owned(),
2091        })?;
2092    create_dir_all(
2093        &target_dir,
2094        "create shared incremental Cargo target directory",
2095    )?;
2096    ensure_cache_tag(&target_dir).map_err(wasm_cache_fs_error)?;
2097    let canonical = target_dir
2098        .canonicalize()
2099        .map_err(|source| WasmBuildError::Io {
2100            operation: "resolve shared incremental Cargo target directory",
2101            path: target_dir.clone(),
2102            source,
2103        })?;
2104    let lock_path = canonical.join(".ic-testkit/wasm-incremental.lock");
2105    let (lock, wait) = if let Some(progress) = progress {
2106        lock_cache_file_with_progress(
2107            &lock_path,
2108            WasmBuildProgressPhase::SharedTargetLock,
2109            progress,
2110        )?
2111    } else {
2112        lock_cache_file(&lock_path).map_err(wasm_cache_fs_error)?
2113    };
2114    Ok((lock, wait, canonical))
2115}
2116
2117fn lock_cache_file_with_progress(
2118    lock_path: &Path,
2119    phase: WasmBuildProgressPhase,
2120    progress: &mut ProgressReporter<'_>,
2121) -> Result<(File, Duration), WasmBuildError> {
2122    if !progress.is_observed() || progress.config.heartbeat_interval.is_none() {
2123        return lock_cache_file(lock_path).map_err(wasm_cache_fs_error);
2124    }
2125    let heartbeat_interval = progress
2126        .config
2127        .heartbeat_interval
2128        .expect("observed cache lock must have a heartbeat interval");
2129    lock_cache_file_with_wait_observer(lock_path, heartbeat_interval, |elapsed| {
2130        progress.emit_heartbeat_if_due(phase, elapsed);
2131    })
2132    .map_err(wasm_cache_fs_error)
2133}
2134
2135fn ensure_cache_directory_tag(target_dir: &Path) -> Result<(), WasmBuildError> {
2136    ensure_cache_tag(target_dir).map_err(wasm_cache_fs_error)
2137}
2138
2139fn record_cache_entry_use_if_present(path: &Path) -> Result<(), WasmBuildError> {
2140    if path.is_dir() {
2141        record_cache_entry_use(path)?;
2142    }
2143    Ok(())
2144}
2145
2146fn record_cache_entry_use(path: &Path) -> Result<(), WasmBuildError> {
2147    record_entry_use(path).map_err(wasm_cache_fs_error)
2148}
2149
2150fn wasm_cache_fs_error(error: CacheFsError) -> WasmBuildError {
2151    WasmBuildError::Io {
2152        operation: error.operation,
2153        path: error.path,
2154        source: error.source,
2155    }
2156}
2157
2158fn validate_spec(spec: &WasmBuildSpec) -> Result<(), WasmBuildError> {
2159    if spec.packages.is_empty() {
2160        return Err(WasmBuildError::InvalidSpec {
2161            message: "at least one Cargo package is required".to_owned(),
2162        });
2163    }
2164    if spec.profile_target_dir.is_empty() {
2165        return Err(WasmBuildError::InvalidSpec {
2166            message: "Cargo profile target directory must not be empty".to_owned(),
2167        });
2168    }
2169    if spec.target.is_empty() {
2170        return Err(WasmBuildError::InvalidSpec {
2171            message: "Cargo compilation target must not be empty".to_owned(),
2172        });
2173    }
2174    if matches!(
2175        &spec.cache_mode,
2176        WasmBuildCacheMode::SharedIncremental { target_dir } if target_dir.as_os_str().is_empty()
2177    ) {
2178        return Err(WasmBuildError::InvalidSpec {
2179            message: "shared incremental Cargo target directory must not be empty".to_owned(),
2180        });
2181    }
2182    if spec.shared_incremental_maintenance_config.is_some()
2183        && !matches!(
2184            spec.cache_mode,
2185            WasmBuildCacheMode::SharedIncremental { .. }
2186        )
2187    {
2188        return Err(WasmBuildError::InvalidSpec {
2189            message:
2190                "scheduled shared-target maintenance requires a shared incremental Cargo target"
2191                    .to_owned(),
2192        });
2193    }
2194    Ok(())
2195}
2196
2197fn build_fingerprint(spec: &WasmBuildSpec) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2198    build_fingerprint_with_progress(spec, &mut ProgressReporter::silent())
2199}
2200
2201fn build_fingerprint_with_progress(
2202    spec: &WasmBuildSpec,
2203    progress: &mut ProgressReporter<'_>,
2204) -> Result<ResolvedCargoBuildInputs, WasmBuildError> {
2205    let total_started = Instant::now();
2206    let tool_started = Instant::now();
2207    let cargo_identity = progress.run_phase(WasmBuildProgressPhase::CargoIdentity, || {
2208        command_identity(
2209            spec,
2210            WasmBuildPhase::CargoIdentity,
2211            &spec.cargo_program,
2212            &["--version", "--verbose"],
2213        )
2214    })?;
2215    let rustc_program = spec
2216        .extra_env
2217        .get(OsStr::new("RUSTC"))
2218        .unwrap_or(&spec.rustc_program);
2219    let rustc_identity = progress.run_phase(WasmBuildProgressPhase::RustcIdentity, || {
2220        command_identity(spec, WasmBuildPhase::RustcIdentity, rustc_program, &["-vV"])
2221    })?;
2222    let tool_identity = tool_started.elapsed();
2223
2224    let metadata_started = Instant::now();
2225    let metadata = progress.run_phase(WasmBuildProgressPhase::CargoMetadata, || {
2226        cargo_metadata(spec)
2227    })?;
2228    let cargo_metadata = metadata_started.elapsed();
2229
2230    let discovery_started = Instant::now();
2231    let (inputs, exclusions) =
2232        progress.run_phase(WasmBuildProgressPhase::InputDiscovery, || {
2233            let inputs = resolve_local_inputs(spec, &metadata)?;
2234            validate_shared_incremental_target_boundary(spec, &inputs)?;
2235            let exclusions = source_exclusions(spec, &inputs);
2236            Ok::<_, WasmBuildError>((inputs, exclusions))
2237        })?;
2238    let input_discovery = discovery_started.elapsed();
2239
2240    let hashing_started = Instant::now();
2241    let input_digest = progress.run_phase(WasmBuildProgressPhase::ContentHashing, || {
2242        digest_labeled_paths("wasm-source-inputs-v1", &inputs, &exclusions).map_err(|source| {
2243            WasmBuildError::Io {
2244                operation: "hash Wasm build inputs",
2245                path: spec.workspace_root.clone(),
2246                source,
2247            }
2248        })
2249    })?;
2250    let content_hashing = hashing_started.elapsed();
2251
2252    let mut hasher = InputHasher::new(CACHE_FORMAT_VERSION);
2253    let mut packages = spec.packages.clone();
2254    packages.sort();
2255    packages.dedup();
2256    for package in packages {
2257        hasher.field("package", package.as_bytes());
2258    }
2259    hasher.field("target", spec.target.as_bytes());
2260    hasher.field("profile-target-dir", spec.profile_target_dir.as_bytes());
2261    for argument in &spec.cargo_profile_args {
2262        hasher.field("cargo-argument", &os_bytes(argument));
2263    }
2264    for (key, value) in effective_environment(spec) {
2265        hasher.field("environment-key", &os_bytes(&key));
2266        if let Some(value) = value {
2267            hasher.field("environment-value", &os_bytes(&value));
2268        } else {
2269            hasher.field("environment-unset", b"");
2270        }
2271    }
2272    hasher.field("cargo-identity", &cargo_identity);
2273    hasher.field("rustc-identity", &rustc_identity);
2274    hasher.field("source-input-digest", input_digest.as_bytes());
2275    Ok(ResolvedCargoBuildInputs {
2276        fingerprint: hasher.finish(),
2277        input_digest,
2278        inputs: inputs
2279            .into_iter()
2280            .map(|(label, path)| CargoBuildInput { label, path })
2281            .collect(),
2282        exclusions,
2283        timings: WasmInputResolutionTimings {
2284            tool_identity,
2285            cargo_metadata,
2286            input_discovery,
2287            content_hashing,
2288            total: total_started.elapsed(),
2289        },
2290    })
2291}
2292
2293fn command_identity(
2294    spec: &WasmBuildSpec,
2295    phase: WasmBuildPhase,
2296    program: &OsStr,
2297    arguments: &[&str],
2298) -> Result<Vec<u8>, WasmBuildError> {
2299    let mut command = Command::new(program);
2300    command.current_dir(&spec.workspace_root).args(arguments);
2301    apply_command_environment(&mut command, spec);
2302    let output = command
2303        .output()
2304        .map_err(|source| WasmBuildError::CommandSpawn {
2305            phase,
2306            program: program.to_owned(),
2307            source,
2308        })?;
2309    ensure_command_success(phase, output).map(|output| {
2310        let mut identity = output.stdout;
2311        identity.extend_from_slice(&output.stderr);
2312        identity
2313    })
2314}
2315
2316fn cargo_metadata(spec: &WasmBuildSpec) -> Result<Value, WasmBuildError> {
2317    let mut command = Command::new(&spec.cargo_program);
2318    command
2319        .current_dir(&spec.workspace_root)
2320        .args(["metadata", "--format-version", "1"]);
2321    for argument in metadata_arguments(&spec.cargo_profile_args) {
2322        command.arg(argument);
2323    }
2324    apply_command_environment(&mut command, spec);
2325    let output = command
2326        .output()
2327        .map_err(|source| WasmBuildError::CommandSpawn {
2328            phase: WasmBuildPhase::CargoMetadata,
2329            program: spec.cargo_program.clone(),
2330            source,
2331        })?;
2332    let output = ensure_command_success(WasmBuildPhase::CargoMetadata, output)?;
2333    serde_json::from_slice(&output.stdout).map_err(|error| WasmBuildError::InvalidMetadata {
2334        message: format!("Cargo metadata was not valid JSON: {error}"),
2335    })
2336}
2337
2338fn metadata_arguments(arguments: &[OsString]) -> Vec<OsString> {
2339    let mut selected = Vec::new();
2340    let mut arguments = arguments.iter();
2341    while let Some(argument) = arguments.next() {
2342        let argument_text = argument.to_string_lossy();
2343        match argument_text.as_ref() {
2344            "--all-features" | "--no-default-features" | "--locked" | "--offline" | "--frozen" => {
2345                selected.push(argument.clone());
2346            }
2347            "--features" | "-F" | "--filter-platform" => {
2348                selected.push(argument.clone());
2349                if let Some(value) = arguments.next() {
2350                    selected.push(value.clone());
2351                }
2352            }
2353            _ if argument_text.starts_with("--features=")
2354                || argument_text.starts_with("--filter-platform=") =>
2355            {
2356                selected.push(argument.clone());
2357            }
2358            _ => {}
2359        }
2360    }
2361    selected
2362}
2363
2364#[derive(Clone)]
2365struct MetadataPackage {
2366    id: String,
2367    name: String,
2368    version: String,
2369    manifest_path: PathBuf,
2370    is_local: bool,
2371}
2372
2373fn resolve_local_inputs(
2374    spec: &WasmBuildSpec,
2375    metadata: &Value,
2376) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
2377    let packages = metadata_packages(metadata)?;
2378    let mut selected_ids = selected_package_ids(spec, metadata, &packages)?;
2379    let dependencies = metadata_dependencies(metadata)?;
2380    let mut closure = BTreeSet::new();
2381    while let Some(id) = selected_ids.pop_front() {
2382        if !closure.insert(id.clone()) {
2383            continue;
2384        }
2385        if let Some(deps) = dependencies.get(&id) {
2386            selected_ids.extend(deps.iter().cloned());
2387        }
2388    }
2389
2390    let workspace_root = metadata
2391        .get("workspace_root")
2392        .and_then(Value::as_str)
2393        .map_or_else(|| spec.workspace_root.clone(), PathBuf::from);
2394    let mut inputs = workspace_configuration_inputs(spec, &workspace_root)?;
2395    append_package_inputs(&mut inputs, &packages, closure, &workspace_root)?;
2396    append_additional_inputs(&mut inputs, spec, &workspace_root);
2397    Ok(inputs)
2398}
2399
2400fn metadata_packages(metadata: &Value) -> Result<HashMap<String, MetadataPackage>, WasmBuildError> {
2401    let packages_value = metadata
2402        .get("packages")
2403        .and_then(Value::as_array)
2404        .ok_or_else(|| invalid_metadata("Cargo metadata has no package array"))?;
2405    let mut packages = HashMap::new();
2406    for value in packages_value {
2407        let package = MetadataPackage {
2408            id: required_string(value, "id")?,
2409            name: required_string(value, "name")?,
2410            version: required_string(value, "version")?,
2411            manifest_path: PathBuf::from(required_string(value, "manifest_path")?),
2412            is_local: value.get("source").is_some_and(Value::is_null),
2413        };
2414        packages.insert(package.id.clone(), package);
2415    }
2416    Ok(packages)
2417}
2418
2419fn selected_package_ids(
2420    spec: &WasmBuildSpec,
2421    metadata: &Value,
2422    packages: &HashMap<String, MetadataPackage>,
2423) -> Result<VecDeque<String>, WasmBuildError> {
2424    let workspace_members = metadata
2425        .get("workspace_members")
2426        .and_then(Value::as_array)
2427        .ok_or_else(|| invalid_metadata("Cargo metadata has no workspace member array"))?
2428        .iter()
2429        .filter_map(Value::as_str)
2430        .collect::<HashSet<_>>();
2431    let mut selected_ids = VecDeque::new();
2432    for requested in &spec.packages {
2433        let matches = packages
2434            .values()
2435            .filter(|package| {
2436                package.name == *requested && workspace_members.contains(package.id.as_str())
2437            })
2438            .map(|package| package.id.clone())
2439            .collect::<Vec<_>>();
2440        match matches.as_slice() {
2441            [id] => selected_ids.push_back(id.clone()),
2442            [] => {
2443                return Err(WasmBuildError::InvalidSpec {
2444                    message: format!("Cargo workspace contains no package named `{requested}`"),
2445                });
2446            }
2447            _ => {
2448                return Err(WasmBuildError::InvalidSpec {
2449                    message: format!("Cargo workspace package name `{requested}` is ambiguous"),
2450                });
2451            }
2452        }
2453    }
2454    Ok(selected_ids)
2455}
2456
2457fn metadata_dependencies(metadata: &Value) -> Result<HashMap<String, Vec<String>>, WasmBuildError> {
2458    let mut dependencies = HashMap::<String, Vec<String>>::new();
2459    let nodes = metadata
2460        .pointer("/resolve/nodes")
2461        .and_then(Value::as_array)
2462        .ok_or_else(|| invalid_metadata("Cargo metadata has no resolved dependency nodes"))?;
2463    for node in nodes {
2464        let id = required_string(node, "id")?;
2465        let deps = node
2466            .get("deps")
2467            .and_then(Value::as_array)
2468            .ok_or_else(|| invalid_metadata("Cargo metadata dependency node has no deps array"))?
2469            .iter()
2470            .map(|dependency| required_string(dependency, "pkg"))
2471            .collect::<Result<Vec<_>, _>>()?;
2472        dependencies.insert(id, deps);
2473    }
2474    Ok(dependencies)
2475}
2476
2477fn workspace_configuration_inputs(
2478    spec: &WasmBuildSpec,
2479    workspace_root: &Path,
2480) -> Result<Vec<(PathBuf, PathBuf)>, WasmBuildError> {
2481    let mut inputs = Vec::new();
2482    add_if_present(
2483        &mut inputs,
2484        "workspace/Cargo.toml",
2485        workspace_root.join("Cargo.toml"),
2486    );
2487    add_if_present(
2488        &mut inputs,
2489        "workspace/Cargo.lock",
2490        workspace_root.join("Cargo.lock"),
2491    );
2492    add_if_present(
2493        &mut inputs,
2494        "workspace/rust-toolchain.toml",
2495        workspace_root.join("rust-toolchain.toml"),
2496    );
2497    add_if_present(
2498        &mut inputs,
2499        "workspace/rust-toolchain",
2500        workspace_root.join("rust-toolchain"),
2501    );
2502    append_cargo_configuration_inputs(&mut inputs, spec, workspace_root)?;
2503    Ok(inputs)
2504}
2505
2506fn append_cargo_configuration_inputs(
2507    inputs: &mut Vec<(PathBuf, PathBuf)>,
2508    spec: &WasmBuildSpec,
2509    workspace_root: &Path,
2510) -> Result<(), WasmBuildError> {
2511    let invocation_root =
2512        spec.workspace_root
2513            .canonicalize()
2514            .map_err(|source| WasmBuildError::Io {
2515                operation: "resolve Cargo invocation directory",
2516                path: spec.workspace_root.clone(),
2517                source,
2518            })?;
2519    let canonical_workspace =
2520        workspace_root
2521            .canonicalize()
2522            .map_err(|source| WasmBuildError::Io {
2523                operation: "resolve Cargo workspace directory",
2524                path: workspace_root.to_owned(),
2525                source,
2526            })?;
2527
2528    let mut roots = invocation_root
2529        .ancestors()
2530        .filter_map(|directory| effective_cargo_config(&directory.join(".cargo")))
2531        .collect::<Vec<_>>();
2532    if let Some(cargo_home) = effective_cargo_home(spec, &invocation_root)
2533        && let Some(config) = effective_cargo_config(&cargo_home)
2534    {
2535        roots.push(config);
2536    }
2537
2538    let mut visited = BTreeSet::new();
2539    for config in roots {
2540        append_cargo_configuration_tree(
2541            inputs,
2542            &config,
2543            &canonical_workspace,
2544            &mut visited,
2545            false,
2546        )?;
2547    }
2548    Ok(())
2549}
2550
2551fn effective_cargo_config(directory: &Path) -> Option<PathBuf> {
2552    let extensionless = directory.join("config");
2553    if extensionless.exists() {
2554        return Some(extensionless);
2555    }
2556    let toml = directory.join("config.toml");
2557    toml.exists().then_some(toml)
2558}
2559
2560fn effective_cargo_home(spec: &WasmBuildSpec, invocation_root: &Path) -> Option<PathBuf> {
2561    if let Some(cargo_home) = command_environment_value(spec, "CARGO_HOME") {
2562        let cargo_home = PathBuf::from(cargo_home);
2563        return Some(if cargo_home.is_absolute() {
2564            cargo_home
2565        } else {
2566            invocation_root.join(cargo_home)
2567        });
2568    }
2569
2570    default_home_directory(spec).map(|home| {
2571        let home = if home.is_absolute() {
2572            home
2573        } else {
2574            invocation_root.join(home)
2575        };
2576        home.join(".cargo")
2577    })
2578}
2579
2580#[cfg(windows)]
2581fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2582    command_environment_value(spec, "USERPROFILE")
2583        .or_else(|| command_environment_value(spec, "HOME"))
2584        .map(PathBuf::from)
2585}
2586
2587#[cfg(not(windows))]
2588fn default_home_directory(spec: &WasmBuildSpec) -> Option<PathBuf> {
2589    command_environment_value(spec, "HOME").map(PathBuf::from)
2590}
2591
2592fn command_environment_value(spec: &WasmBuildSpec, name: &str) -> Option<OsString> {
2593    spec.extra_env
2594        .get(OsStr::new(name))
2595        .cloned()
2596        .or_else(|| std::env::var_os(name))
2597}
2598
2599fn append_cargo_configuration_tree(
2600    inputs: &mut Vec<(PathBuf, PathBuf)>,
2601    config: &Path,
2602    workspace_root: &Path,
2603    visited: &mut BTreeSet<PathBuf>,
2604    optional: bool,
2605) -> Result<(), WasmBuildError> {
2606    let canonical = match config.canonicalize() {
2607        Ok(canonical) => canonical,
2608        Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
2609        Err(source) => {
2610            return Err(WasmBuildError::Io {
2611                operation: "resolve Cargo configuration",
2612                path: config.to_owned(),
2613                source,
2614            });
2615        }
2616    };
2617    if !visited.insert(canonical.clone()) {
2618        return Ok(());
2619    }
2620
2621    let contents = fs::read_to_string(&canonical).map_err(|source| WasmBuildError::Io {
2622        operation: "read Cargo configuration",
2623        path: canonical.clone(),
2624        source,
2625    })?;
2626    let configuration = toml::from_str::<TomlValue>(&contents).map_err(|error| {
2627        WasmBuildError::InvalidCargoConfiguration {
2628            path: canonical.clone(),
2629            message: error.to_string(),
2630        }
2631    })?;
2632    inputs.push((
2633        cargo_configuration_label(&canonical, workspace_root),
2634        canonical.clone(),
2635    ));
2636
2637    let Some(include) = configuration.get("include") else {
2638        return Ok(());
2639    };
2640    let parent = canonical
2641        .parent()
2642        .ok_or_else(|| WasmBuildError::InvalidCargoConfiguration {
2643            path: canonical.clone(),
2644            message: "configuration path has no parent directory".to_owned(),
2645        })?;
2646    for (included, optional) in cargo_configuration_includes(include, &canonical)? {
2647        let included = if included.is_absolute() {
2648            included
2649        } else {
2650            parent.join(included)
2651        };
2652        append_cargo_configuration_tree(inputs, &included, workspace_root, visited, optional)?;
2653    }
2654    Ok(())
2655}
2656
2657fn cargo_configuration_includes(
2658    include: &TomlValue,
2659    config: &Path,
2660) -> Result<Vec<(PathBuf, bool)>, WasmBuildError> {
2661    let values = match include {
2662        TomlValue::Array(values) => values.as_slice(),
2663        value => std::slice::from_ref(value),
2664    };
2665    values
2666        .iter()
2667        .map(|value| match value {
2668            TomlValue::String(path) => Ok((PathBuf::from(path), false)),
2669            TomlValue::Table(table) => {
2670                let path = table
2671                    .get("path")
2672                    .and_then(TomlValue::as_str)
2673                    .ok_or_else(|| {
2674                        invalid_cargo_configuration(
2675                            config,
2676                            "Cargo configuration include table requires a string `path`",
2677                        )
2678                    })?;
2679                let optional = table
2680                    .get("optional")
2681                    .map(|value| {
2682                        value.as_bool().ok_or_else(|| {
2683                            invalid_cargo_configuration(
2684                                config,
2685                                "Cargo configuration include `optional` must be a boolean",
2686                            )
2687                        })
2688                    })
2689                    .transpose()?
2690                    .unwrap_or(false);
2691                Ok((PathBuf::from(path), optional))
2692            }
2693            _ => Err(invalid_cargo_configuration(
2694                config,
2695                "Cargo configuration `include` must contain paths or include tables",
2696            )),
2697        })
2698        .collect()
2699}
2700
2701fn cargo_configuration_label(config: &Path, workspace_root: &Path) -> PathBuf {
2702    if let Ok(relative) = config.strip_prefix(workspace_root) {
2703        return PathBuf::from("cargo-config/workspace").join(relative);
2704    }
2705    let location = digest_bytes("cargo-config-location-v1", &os_bytes(config.as_os_str()));
2706    PathBuf::from("cargo-config/external").join(location.to_hex())
2707}
2708
2709fn invalid_cargo_configuration(path: &Path, message: &str) -> WasmBuildError {
2710    WasmBuildError::InvalidCargoConfiguration {
2711        path: path.to_owned(),
2712        message: message.to_owned(),
2713    }
2714}
2715
2716fn append_package_inputs(
2717    inputs: &mut Vec<(PathBuf, PathBuf)>,
2718    packages: &HashMap<String, MetadataPackage>,
2719    closure: BTreeSet<String>,
2720    workspace_root: &Path,
2721) -> Result<(), WasmBuildError> {
2722    for id in closure {
2723        let Some(package) = packages.get(&id) else {
2724            return Err(invalid_metadata(&format!(
2725                "resolved package `{id}` is missing"
2726            )));
2727        };
2728        if !package.is_local {
2729            continue;
2730        }
2731        let root = package.manifest_path.parent().ok_or_else(|| {
2732            invalid_metadata(&format!(
2733                "package `{}` manifest has no parent",
2734                package.name
2735            ))
2736        })?;
2737        let relative_manifest = package
2738            .manifest_path
2739            .strip_prefix(workspace_root)
2740            .unwrap_or(&package.manifest_path);
2741        let label = PathBuf::from(format!("package/{}@{}", package.name, package.version))
2742            .join(relative_manifest.parent().unwrap_or_else(|| Path::new(".")));
2743        inputs.push((label, root.to_owned()));
2744    }
2745    Ok(())
2746}
2747
2748fn append_additional_inputs(
2749    inputs: &mut Vec<(PathBuf, PathBuf)>,
2750    spec: &WasmBuildSpec,
2751    workspace_root: &Path,
2752) {
2753    for additional in &spec.additional_inputs {
2754        let path = if additional.is_absolute() {
2755            additional.clone()
2756        } else {
2757            workspace_root.join(additional)
2758        };
2759        inputs.push((PathBuf::from("additional").join(additional), path));
2760    }
2761}
2762
2763fn source_exclusions(spec: &WasmBuildSpec, inputs: &[(PathBuf, PathBuf)]) -> Vec<PathBuf> {
2764    let mut exclusions = vec![
2765        spec.target_dir.clone(),
2766        spec.workspace_root.join("target"),
2767        spec.workspace_root.join(".git"),
2768    ];
2769    if let Some(shared_target) = shared_incremental_target(spec) {
2770        exclusions.push(shared_target);
2771    }
2772    for (_, path) in inputs {
2773        if path.is_dir() {
2774            exclusions.push(path.join("target"));
2775            exclusions.push(path.join(".git"));
2776        }
2777    }
2778    exclusions
2779}
2780
2781fn validate_shared_incremental_target_boundary(
2782    spec: &WasmBuildSpec,
2783    inputs: &[(PathBuf, PathBuf)],
2784) -> Result<(), WasmBuildError> {
2785    let Some(shared_target) = shared_incremental_target(spec) else {
2786        return Ok(());
2787    };
2788    let shared_target =
2789        canonicalize_allow_missing(&shared_target).map_err(|source| WasmBuildError::Io {
2790            operation: "resolve shared incremental Cargo target boundary",
2791            path: shared_target.clone(),
2792            source,
2793        })?;
2794    let resolved_inputs = inputs
2795        .iter()
2796        .map(|(_, input)| {
2797            let canonical = input.canonicalize().map_err(|source| WasmBuildError::Io {
2798                operation: "resolve Cargo input boundary",
2799                path: input.clone(),
2800                source,
2801            })?;
2802            let metadata = fs::metadata(&canonical).map_err(|source| WasmBuildError::Io {
2803                operation: "inspect Cargo input boundary",
2804                path: canonical.clone(),
2805                source,
2806            })?;
2807            Ok((canonical, metadata.is_dir()))
2808        })
2809        .collect::<Result<Vec<_>, WasmBuildError>>()?;
2810    let safe_generated_roots = std::iter::once(spec.target_dir.clone())
2811        .chain(std::iter::once(spec.workspace_root.join("target")))
2812        .chain(
2813            inputs
2814                .iter()
2815                .filter(|(_, path)| path.is_dir())
2816                .map(|(_, path)| path.join("target")),
2817        )
2818        .filter_map(|path| canonicalize_allow_missing(&path).ok())
2819        .filter(|root| {
2820            !resolved_inputs
2821                .iter()
2822                .any(|(input, _is_directory)| input.starts_with(root))
2823        })
2824        .collect::<Vec<_>>();
2825    if safe_generated_roots
2826        .iter()
2827        .any(|root| shared_target.starts_with(root))
2828    {
2829        return Ok(());
2830    }
2831
2832    for (input, is_directory) in resolved_inputs {
2833        if shared_target == input
2834            || (is_directory && shared_target.starts_with(&input))
2835            || input.starts_with(&shared_target)
2836        {
2837            return Err(WasmBuildError::InvalidSpec {
2838                message: format!(
2839                    "shared incremental target {} must not overlap exact Cargo inputs unless it is inside a generated target directory",
2840                    shared_target.display()
2841                ),
2842            });
2843        }
2844    }
2845    Ok(())
2846}
2847
2848fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
2849    let absolute = if path.is_absolute() {
2850        path.to_owned()
2851    } else {
2852        std::env::current_dir()?.join(path)
2853    };
2854    let mut unresolved = Vec::<OsString>::new();
2855    let mut existing = absolute.as_path();
2856    loop {
2857        match existing.canonicalize() {
2858            Ok(mut canonical) => {
2859                for component in unresolved.into_iter().rev() {
2860                    canonical.push(component);
2861                }
2862                return Ok(canonical);
2863            }
2864            Err(error) if error.kind() == io::ErrorKind::NotFound => {
2865                let Some(name) = existing.file_name() else {
2866                    return Err(error);
2867                };
2868                unresolved.push(name.to_owned());
2869                existing = existing.parent().ok_or(error)?;
2870            }
2871            Err(error) => return Err(error),
2872        }
2873    }
2874}
2875
2876fn shared_incremental_target(spec: &WasmBuildSpec) -> Option<PathBuf> {
2877    let WasmBuildCacheMode::SharedIncremental { target_dir } = &spec.cache_mode else {
2878        return None;
2879    };
2880    Some(if target_dir.is_absolute() {
2881        target_dir.clone()
2882    } else {
2883        spec.workspace_root.join(target_dir)
2884    })
2885}
2886
2887fn shared_incremental_target_exists(
2888    spec: &WasmBuildSpec,
2889    operation: &'static str,
2890) -> Result<bool, WasmBuildError> {
2891    let target_dir =
2892        shared_incremental_target(spec).ok_or_else(|| WasmBuildError::InvalidSpec {
2893            message: "shared incremental target is not configured".to_owned(),
2894        })?;
2895    match fs::symlink_metadata(&target_dir) {
2896        Ok(metadata) if metadata.is_dir() => Ok(true),
2897        Ok(_) => Err(WasmBuildError::InvalidSpec {
2898            message: format!(
2899                "shared incremental Cargo target {} must be a directory",
2900                target_dir.display()
2901            ),
2902        }),
2903        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
2904        Err(source) => Err(WasmBuildError::Io {
2905            operation,
2906            path: target_dir,
2907            source,
2908        }),
2909    }
2910}
2911
2912fn effective_environment(spec: &WasmBuildSpec) -> BTreeMap<OsString, Option<OsString>> {
2913    let mut names = spec.inherited_env.clone();
2914    names.extend(AUTOMATIC_ENVIRONMENT.iter().map(OsString::from));
2915    let mut environment = names
2916        .into_iter()
2917        .map(|name| {
2918            let value = std::env::var_os(&name);
2919            (name, value)
2920        })
2921        .collect::<BTreeMap<_, _>>();
2922    for (key, value) in &spec.extra_env {
2923        environment.insert(key.clone(), Some(value.clone()));
2924    }
2925    environment
2926}
2927
2928fn apply_command_environment(command: &mut Command, spec: &WasmBuildSpec) {
2929    for (key, value) in &spec.extra_env {
2930        command.env(key, value);
2931    }
2932}
2933
2934fn run_cargo_build(
2935    spec: &WasmBuildSpec,
2936    build_target_dir: &Path,
2937    progress: &mut ProgressReporter<'_>,
2938) -> Result<(), WasmBuildError> {
2939    let mut command = Command::new(&spec.cargo_program);
2940    command
2941        .current_dir(&spec.workspace_root)
2942        .env("CARGO_TARGET_DIR", build_target_dir)
2943        .args(["build", "--target", &spec.target])
2944        .args(&spec.cargo_profile_args);
2945    apply_command_environment(&mut command, spec);
2946    for package in &spec.packages {
2947        command.args(["-p", package]);
2948    }
2949
2950    if !progress.is_observed() {
2951        let output = command
2952            .output()
2953            .map_err(|source| WasmBuildError::CommandSpawn {
2954                phase: WasmBuildPhase::CargoBuild,
2955                program: spec.cargo_program.clone(),
2956                source,
2957            })?;
2958        return ensure_command_success(WasmBuildPhase::CargoBuild, output).map(|_| ());
2959    }
2960
2961    run_observed_cargo_build(spec, build_target_dir, command, progress)
2962}
2963
2964fn run_observed_cargo_build(
2965    spec: &WasmBuildSpec,
2966    build_target_dir: &Path,
2967    mut command: Command,
2968    progress: &mut ProgressReporter<'_>,
2969) -> Result<(), WasmBuildError> {
2970    command.stdout(Stdio::piped()).stderr(Stdio::piped());
2971    let started = Instant::now();
2972    let child = command
2973        .spawn()
2974        .map_err(|source| WasmBuildError::CommandSpawn {
2975            phase: WasmBuildPhase::CargoBuild,
2976            program: spec.cargo_program.clone(),
2977            source,
2978        })?;
2979    let mut child = ObservedChild::new(child);
2980    progress.emit(WasmBuildProgressEvent::CargoStarted {
2981        target_dir: build_target_dir.to_owned(),
2982    });
2983
2984    let stdout = child
2985        .child_mut()
2986        .stdout
2987        .take()
2988        .expect("Cargo stdout must be piped");
2989    let stderr = child
2990        .child_mut()
2991        .stderr
2992        .take()
2993        .expect("Cargo stderr must be piped");
2994    let (sender, chunks) = mpsc::channel();
2995    let stdout_sender = sender.clone();
2996    let stdout_reader = thread::spawn(move || {
2997        read_process_output(stdout, WasmBuildOutputStream::Stdout, stdout_sender)
2998    });
2999    let stderr_reader =
3000        thread::spawn(move || read_process_output(stderr, WasmBuildOutputStream::Stderr, sender));
3001
3002    let captured = capture_observed_cargo_output(chunks, progress, started);
3003
3004    let status = child.wait().map_err(|source| WasmBuildError::Io {
3005        operation: "wait for observed cargo build",
3006        path: PathBuf::from(&spec.cargo_program),
3007        source,
3008    })?;
3009    join_output_reader(
3010        stdout_reader,
3011        "read observed cargo stdout",
3012        &spec.cargo_program,
3013    )?;
3014    join_output_reader(
3015        stderr_reader,
3016        "read observed cargo stderr",
3017        &spec.cargo_program,
3018    )?;
3019    let elapsed = started.elapsed();
3020    progress.emit(WasmBuildProgressEvent::CargoFinished {
3021        success: status.success(),
3022        code: status.code(),
3023        elapsed,
3024    });
3025
3026    ensure_command_success(
3027        WasmBuildPhase::CargoBuild,
3028        Output {
3029            status,
3030            stdout: captured.stdout,
3031            stderr: captured.stderr,
3032        },
3033    )
3034    .map(|_| ())
3035}
3036
3037struct CapturedProcessOutput {
3038    stdout: Vec<u8>,
3039    stderr: Vec<u8>,
3040}
3041
3042fn capture_observed_cargo_output(
3043    chunks: mpsc::Receiver<ProcessOutputChunk>,
3044    progress: &mut ProgressReporter<'_>,
3045    started: Instant,
3046) -> CapturedProcessOutput {
3047    let mut stdout = Vec::new();
3048    let mut stderr = Vec::new();
3049    loop {
3050        let message = match progress.heartbeat_due_in() {
3051            Some(wait) => match chunks.recv_timeout(wait) {
3052                Ok(chunk) => Some(chunk),
3053                Err(RecvTimeoutError::Timeout) => {
3054                    progress.emit_heartbeat(WasmBuildProgressPhase::CargoBuild, started.elapsed());
3055                    None
3056                }
3057                Err(RecvTimeoutError::Disconnected) => break,
3058            },
3059            None => match chunks.recv() {
3060                Ok(chunk) => Some(chunk),
3061                Err(_) => break,
3062            },
3063        };
3064        let Some(chunk) = message else {
3065            continue;
3066        };
3067        match chunk.stream {
3068            WasmBuildOutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes),
3069            WasmBuildOutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes),
3070        }
3071        if progress.config.emit_cargo_output {
3072            progress.emit(WasmBuildProgressEvent::CargoOutput {
3073                stream: chunk.stream,
3074                bytes: chunk.bytes,
3075            });
3076        }
3077    }
3078    CapturedProcessOutput { stdout, stderr }
3079}
3080
3081#[derive(Debug)]
3082struct ProcessOutputChunk {
3083    stream: WasmBuildOutputStream,
3084    bytes: Vec<u8>,
3085}
3086
3087fn read_process_output<R: io::Read>(
3088    mut reader: R,
3089    stream: WasmBuildOutputStream,
3090    sender: mpsc::Sender<ProcessOutputChunk>,
3091) -> io::Result<()> {
3092    let mut buffer = [0_u8; 8 * 1024];
3093    loop {
3094        let count = reader.read(&mut buffer)?;
3095        if count == 0 {
3096            return Ok(());
3097        }
3098        if sender
3099            .send(ProcessOutputChunk {
3100                stream,
3101                bytes: buffer[..count].to_vec(),
3102            })
3103            .is_err()
3104        {
3105            return Ok(());
3106        }
3107    }
3108}
3109
3110fn join_output_reader(
3111    reader: thread::JoinHandle<io::Result<()>>,
3112    operation: &'static str,
3113    cargo_program: &OsStr,
3114) -> Result<(), WasmBuildError> {
3115    let result = reader.join().map_err(|_| WasmBuildError::Io {
3116        operation,
3117        path: PathBuf::from(cargo_program),
3118        source: io::Error::other("Cargo output reader panicked"),
3119    })?;
3120    result.map_err(|source| WasmBuildError::Io {
3121        operation,
3122        path: PathBuf::from(cargo_program),
3123        source,
3124    })
3125}
3126
3127struct ObservedChild(Option<Child>);
3128
3129impl ObservedChild {
3130    const fn new(child: Child) -> Self {
3131        Self(Some(child))
3132    }
3133
3134    const fn child_mut(&mut self) -> &mut Child {
3135        self.0.as_mut().expect("observed child must be present")
3136    }
3137
3138    fn wait(&mut self) -> io::Result<ExitStatus> {
3139        let status = self.child_mut().wait()?;
3140        self.0.take();
3141        Ok(status)
3142    }
3143}
3144
3145impl Drop for ObservedChild {
3146    fn drop(&mut self) {
3147        if let Some(mut child) = self.0.take() {
3148            let _ = child.kill();
3149            let _ = child.wait();
3150        }
3151    }
3152}
3153
3154fn ensure_command_success(phase: WasmBuildPhase, output: Output) -> Result<Output, WasmBuildError> {
3155    if output.status.success() {
3156        return Ok(output);
3157    }
3158    Err(WasmBuildError::CommandFailed {
3159        phase,
3160        status: output.status,
3161        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3162        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3163    })
3164}
3165
3166fn expected_artifacts(spec: &WasmBuildSpec, target_dir: &Path) -> Vec<PathBuf> {
3167    let mut packages = spec.packages.iter().map(String::as_str).collect::<Vec<_>>();
3168    packages.sort_unstable();
3169    packages.dedup();
3170    packages
3171        .into_iter()
3172        .map(|package| {
3173            if spec.target == DEFAULT_TARGET {
3174                wasm_path(target_dir, package, &spec.profile_target_dir)
3175            } else {
3176                target_dir
3177                    .join(&spec.target)
3178                    .join(&spec.profile_target_dir)
3179                    .join(format!("{package}.wasm"))
3180            }
3181        })
3182        .collect()
3183}
3184
3185fn cache_entry_directory(spec: &WasmBuildSpec, fingerprint: InputDigest) -> PathBuf {
3186    spec.target_dir
3187        .join(".ic-testkit/wasm-targets")
3188        .join(fingerprint.to_hex())
3189}
3190
3191fn artifact_set_matches(artifacts: &[PathBuf], fingerprint: InputDigest) -> bool {
3192    artifacts.iter().all(|path| {
3193        fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
3194            && cache_stamp_matches(path, fingerprint)
3195    })
3196}
3197
3198fn missing_artifacts(artifacts: &[PathBuf]) -> Vec<PathBuf> {
3199    artifacts
3200        .iter()
3201        .filter(|path| {
3202            fs::metadata(path).map_or(true, |metadata| !metadata.is_file() || metadata.len() == 0)
3203        })
3204        .cloned()
3205        .collect()
3206}
3207
3208fn cache_stamp_matches(artifact: &Path, fingerprint: InputDigest) -> bool {
3209    let stamp_path = artifact_stamp_path(artifact);
3210    let Ok(expected) = artifact_stamp_contents(artifact, fingerprint) else {
3211        return false;
3212    };
3213    fs::read_to_string(stamp_path).is_ok_and(|stamp| stamp == expected)
3214}
3215
3216fn artifact_stamp_path(artifact: &Path) -> PathBuf {
3217    let mut name = artifact
3218        .file_name()
3219        .map_or_else(|| OsString::from("artifact"), OsString::from);
3220    name.push(".ic-testkit-build");
3221    artifact.with_file_name(name)
3222}
3223
3224fn artifact_stamp_contents(artifact: &Path, fingerprint: InputDigest) -> io::Result<String> {
3225    let (_, artifact_digest) = digest_file("wasm-artifact-v1", artifact)?;
3226    Ok(format!(
3227        "{CACHE_FORMAT_VERSION}\nbuild-sha256:{fingerprint}\nartifact-sha256:{artifact_digest}\n"
3228    ))
3229}
3230
3231fn publish_artifact_stamps(
3232    artifacts: &[PathBuf],
3233    fingerprint: InputDigest,
3234) -> Result<(), WasmBuildError> {
3235    for artifact in artifacts {
3236        let stamp_path = artifact_stamp_path(artifact);
3237        let stamp = artifact_stamp_contents(artifact, fingerprint).map_err(|source| {
3238            WasmBuildError::Io {
3239                operation: "hash built Wasm artifact",
3240                path: artifact.clone(),
3241                source,
3242            }
3243        })?;
3244        write_atomic(&stamp_path, stamp.as_bytes()).map_err(|source| WasmBuildError::Io {
3245            operation: "publish Wasm build stamp",
3246            path: stamp_path,
3247            source,
3248        })?;
3249    }
3250    Ok(())
3251}
3252
3253fn materialize_artifacts(
3254    cached_artifacts: &[PathBuf],
3255    artifacts: &[PathBuf],
3256    fingerprint: InputDigest,
3257) -> Result<(), WasmBuildError> {
3258    for (cached, artifact) in cached_artifacts.iter().zip(artifacts) {
3259        copy_file_atomic(cached, artifact).map_err(|source| WasmBuildError::Io {
3260            operation: "publish Wasm artifact",
3261            path: artifact.clone(),
3262            source,
3263        })?;
3264    }
3265    publish_artifact_stamps(artifacts, fingerprint)
3266}
3267
3268fn copy_wasm_artifacts(
3269    source_artifacts: &[PathBuf],
3270    cached_artifacts: &[PathBuf],
3271) -> Result<(), WasmBuildError> {
3272    for (source, cached) in source_artifacts.iter().zip(cached_artifacts) {
3273        copy_file_atomic(source, cached).map_err(|source_error| WasmBuildError::Io {
3274            operation: "cache shared-incremental Wasm artifact",
3275            path: cached.clone(),
3276            source: source_error,
3277        })?;
3278    }
3279    Ok(())
3280}
3281
3282fn remove_directory_if_present(path: &Path) -> Result<(), WasmBuildError> {
3283    remove_path_if_present(path).map_err(|source| WasmBuildError::Io {
3284        operation: "remove incomplete content-addressed Cargo target directory",
3285        path: path.to_owned(),
3286        source,
3287    })
3288}
3289
3290fn create_dir_all(path: &Path, operation: &'static str) -> Result<(), WasmBuildError> {
3291    fs::create_dir_all(path).map_err(|source| WasmBuildError::Io {
3292        operation,
3293        path: path.to_owned(),
3294        source,
3295    })
3296}
3297
3298fn add_if_present(inputs: &mut Vec<(PathBuf, PathBuf)>, label: &str, path: PathBuf) {
3299    if path.exists() {
3300        inputs.push((PathBuf::from(label), path));
3301    }
3302}
3303
3304fn required_string(value: &Value, field: &str) -> Result<String, WasmBuildError> {
3305    value
3306        .get(field)
3307        .and_then(Value::as_str)
3308        .map(str::to_owned)
3309        .ok_or_else(|| invalid_metadata(&format!("Cargo metadata field `{field}` is missing")))
3310}
3311
3312fn invalid_metadata(message: &str) -> WasmBuildError {
3313    WasmBuildError::InvalidMetadata {
3314        message: message.to_owned(),
3315    }
3316}
3317
3318impl std::fmt::Display for WasmBuildPhase {
3319    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3320        formatter.write_str(match self {
3321            Self::CargoMetadata => "cargo metadata",
3322            Self::CargoIdentity => "Cargo identity",
3323            Self::RustcIdentity => "Rust compiler identity",
3324            Self::CargoBuild => "cargo build",
3325        })
3326    }
3327}
3328
3329impl std::fmt::Display for WasmBuildProgressPhase {
3330    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3331        formatter.write_str(match self {
3332            Self::ExactCacheLock => "exact cache lock",
3333            Self::CargoIdentity => "Cargo identity",
3334            Self::RustcIdentity => "Rust compiler identity",
3335            Self::CargoMetadata => "Cargo metadata",
3336            Self::InputDiscovery => "input discovery",
3337            Self::ContentHashing => "content hashing",
3338            Self::SharedTargetLock => "shared target lock",
3339            Self::SharedTargetMaintenance => "shared target maintenance",
3340            Self::CargoBuild => "Cargo build",
3341            Self::ArtifactPublication => "artifact publication",
3342            Self::ExactCacheMaintenance => "exact cache maintenance",
3343        })
3344    }
3345}
3346
3347impl std::fmt::Display for WasmBuildError {
3348    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3349        match self {
3350            Self::InvalidSpec { message } => {
3351                write!(formatter, "invalid Wasm build spec: {message}")
3352            }
3353            Self::Io {
3354                operation,
3355                path,
3356                source,
3357            } => write!(
3358                formatter,
3359                "failed to {operation} at {}: {source}",
3360                path.display()
3361            ),
3362            Self::CommandSpawn {
3363                phase,
3364                program,
3365                source,
3366            } => write!(
3367                formatter,
3368                "failed to launch {phase} using `{}`: {source}",
3369                program.to_string_lossy(),
3370            ),
3371            Self::CommandFailed {
3372                phase,
3373                status,
3374                stdout,
3375                stderr,
3376            } => write!(
3377                formatter,
3378                "{phase} failed with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}",
3379            ),
3380            Self::InvalidMetadata { message } => {
3381                write!(formatter, "invalid Cargo metadata: {message}")
3382            }
3383            Self::InvalidCargoConfiguration { path, message } => write!(
3384                formatter,
3385                "invalid Cargo configuration at {}: {message}",
3386                path.display(),
3387            ),
3388            Self::MissingArtifacts { paths } => write!(
3389                formatter,
3390                "cargo build succeeded without producing: {}",
3391                paths
3392                    .iter()
3393                    .map(|path| path.display().to_string())
3394                    .collect::<Vec<_>>()
3395                    .join(", "),
3396            ),
3397            Self::InputsChangedDuringBuild { before, after } => write!(
3398                formatter,
3399                "Wasm build inputs changed while Cargo was running: {before} -> {after}",
3400            ),
3401            Self::FailedBuildCleanup {
3402                build_error,
3403                path,
3404                source,
3405            } => write!(
3406                formatter,
3407                "Wasm build failed ({build_error}) and its incomplete target directory at {} could not be removed: {source}",
3408                path.display(),
3409            ),
3410        }
3411    }
3412}
3413
3414impl std::error::Error for WasmBuildError {
3415    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3416        match self {
3417            Self::Io { source, .. }
3418            | Self::CommandSpawn { source, .. }
3419            | Self::FailedBuildCleanup { source, .. } => Some(source),
3420            _ => None,
3421        }
3422    }
3423}
3424
3425#[cfg(test)]
3426mod tests;