Skip to main content

ic_build/artifacts/
wasm_batch.rs

1use super::cache_fs::{CacheRetentionLimits, canonicalize_allow_missing};
2use crate::batch::{BatchLabelError, validate_labels};
3
4use std::{
5    collections::HashSet,
6    marker::PhantomData,
7    path::PathBuf,
8    time::{Duration, Instant},
9};
10
11use super::wasm_cache::{
12    SharedIncrementalTargetMaintenanceConfig, SharedIncrementalTargetMaintenanceOutcome,
13    WasmBuildBatchInputMetrics, WasmBuildBatchInputResolver, WasmBuildError,
14    WasmBuildFailureDetails, WasmBuildInputSnapshotState, WasmBuildOutcome,
15    WasmBuildProgressConfig, WasmBuildProgressEvent, WasmBuildSpec, WasmBuildTimings,
16    WasmInputResolutionTimings, build_wasm_canisters_cached_in_batch, shared_incremental_target,
17};
18
19/// Orchestration shared by every entry in one independent Wasm build batch.
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
21pub struct WasmBuildBatchConfig {
22    shared_incremental_maintenance: Option<SharedIncrementalTargetMaintenanceConfig>,
23}
24
25/// Caller-labeled specification for one exact Wasm batch entry.
26///
27/// The label is report and progress identity only; it does not alter the
28/// underlying exact Wasm fingerprint or cache key.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct LabeledWasmBuildSpec {
31    label: String,
32    spec: WasmBuildSpec,
33}
34
35/// Ordered outcomes and failures from a collect-all Wasm build batch.
36#[derive(Debug)]
37pub struct WasmBuildBatchReport {
38    entries: Vec<WasmBuildBatchEntry>,
39    input_resolution: WasmBuildBatchInputMetrics,
40    total: Duration,
41}
42
43/// Immutable prepared Cargo input resolution shared by concurrent readers.
44///
45/// Preparation resolves the complete declared specification set while the
46/// caller holds a genuine source write-exclusion guard. Reader batches may run
47/// concurrently through `&self`, but cannot introduce specifications that
48/// were not declared during preparation.
49pub struct WasmBuildInputSnapshot<'guard> {
50    state: WasmBuildInputSnapshotState,
51    _source_guard: PhantomData<&'guard ()>,
52}
53
54/// Preparation and reader-reuse counters for one immutable input snapshot.
55#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
56pub struct WasmBuildInputSnapshotMetrics {
57    specifications: usize,
58    input_resolution_runs: usize,
59    input_resolution_reuses: usize,
60    input_resolution_timings: WasmInputResolutionTimings,
61    reader_reuses: usize,
62    invalidated: bool,
63}
64
65/// One ordered caller-labeled result from a Wasm build batch.
66#[derive(Debug)]
67pub struct WasmBuildBatchEntry {
68    index: usize,
69    label: String,
70    result: Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
71    entry_elapsed: Duration,
72}
73
74/// Structural error that prevents a labeled Wasm batch from starting.
75#[non_exhaustive]
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub enum WasmBuildBatchContractError {
78    /// Invalid input labels, rejected before source validation or command effects.
79    Labels(BatchLabelError),
80    /// A source mutation invalidated the caller's immutable-source lease.
81    SourceLeaseInvalidated,
82    /// A prepared snapshot reader requested a specification absent at preparation.
83    SpecificationNotPrepared {
84        /// Zero-based position of the undeclared entry.
85        index: usize,
86        /// Caller-owned label of the undeclared entry.
87        label: String,
88    },
89}
90
91impl LabeledWasmBuildSpec {
92    /// Attach a caller-owned stable label to one Wasm build specification.
93    #[must_use]
94    pub fn new(label: impl Into<String>, spec: WasmBuildSpec) -> Self {
95        Self {
96            label: label.into(),
97            spec,
98        }
99    }
100
101    /// Caller-owned report and progress label.
102    #[must_use]
103    pub fn label(&self) -> &str {
104        &self.label
105    }
106
107    /// Underlying exact Wasm build specification.
108    #[must_use]
109    pub const fn spec(&self) -> &WasmBuildSpec {
110        &self.spec
111    }
112
113    /// Consume the entry into its label and Wasm build specification.
114    #[must_use]
115    pub fn into_parts(self) -> (String, WasmBuildSpec) {
116        (self.label, self.spec)
117    }
118}
119
120impl<'guard> WasmBuildInputSnapshot<'guard> {
121    /// Resolve and freeze the complete specification set under a source lease.
122    ///
123    /// The guard must prevent mutation of every Cargo/rustc executable,
124    /// manifest, configuration file, discovered source, declared additional
125    /// input, and relevant environment value used by the supplied
126    /// specifications. The type system cannot verify guard provenance.
127    ///
128    /// # Errors
129    /// Returns specification, tool/metadata, filesystem or input-resolution errors.
130    pub fn prepare_assuming_sources_immutable<Guard: ?Sized>(
131        _source_write_guard: &'guard Guard,
132        specs: &[WasmBuildSpec],
133    ) -> Result<Self, WasmBuildError> {
134        Ok(Self {
135            state: WasmBuildInputSnapshotState::prepare(specs)?,
136            _source_guard: PhantomData,
137        })
138    }
139
140    /// Build a collect-all batch with explicit orchestration and optional progress.
141    ///
142    /// Source lease, feature isolation and retention match independent batches.
143    /// `spawn` selects Cargo child/group custody and cleanup, independently of progress.
144    /// Prepared snapshots reject specifications absent during preparation.
145    ///
146    /// # Errors
147    /// Returns invalid labels, an invalidated lease or an unprepared specification
148    /// error before work. Individual build failures remain in the report.
149    pub fn build_batch(
150        &self,
151        specs: &[LabeledWasmBuildSpec],
152        config: WasmBuildBatchConfig,
153        progress: Option<(
154            WasmBuildProgressConfig,
155            &mut dyn FnMut(WasmBuildBatchProgressEvent),
156        )>,
157        mut spawn: impl FnMut(
158            &mut std::process::Command,
159        ) -> std::io::Result<ic_host_process::child::OwnedChild>,
160    ) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
161        run_wasm_batch(specs, config, Some(&self.state), progress, &mut spawn)
162    }
163
164    /// Current preparation, reader-reuse, and invalidation metrics.
165    #[must_use]
166    pub fn metrics(&self) -> WasmBuildInputSnapshotMetrics {
167        let preparation = self.state.preparation_metrics();
168        WasmBuildInputSnapshotMetrics {
169            specifications: self.state.specification_count(),
170            input_resolution_runs: preparation.runs,
171            input_resolution_reuses: preparation.reuses,
172            input_resolution_timings: self.state.preparation_timings(),
173            reader_reuses: self.state.reader_reuses(),
174            invalidated: self.state.is_invalidated(),
175        }
176    }
177}
178
179impl WasmBuildInputSnapshotMetrics {
180    /// Number of exact specifications captured during preparation.
181    #[must_use]
182    pub const fn specifications(self) -> usize {
183        self.specifications
184    }
185
186    /// Number of workspace/toolchain resolution snapshots prepared.
187    #[must_use]
188    pub const fn input_resolution_runs(self) -> usize {
189        self.input_resolution_runs
190    }
191
192    /// Number of prepared specifications sharing another resolution run.
193    #[must_use]
194    pub const fn input_resolution_reuses(self) -> usize {
195        self.input_resolution_reuses
196    }
197
198    /// Complete tool, metadata, discovery, and hashing preparation timings.
199    #[must_use]
200    pub const fn input_resolution_timings(self) -> WasmInputResolutionTimings {
201        self.input_resolution_timings
202    }
203
204    /// Cumulative exact specification resolutions served to readers.
205    #[must_use]
206    pub const fn reader_reuses(self) -> usize {
207        self.reader_reuses
208    }
209
210    /// Whether any reader detected a violation of the source lease.
211    #[must_use]
212    pub const fn is_invalidated(self) -> bool {
213        self.invalidated
214    }
215}
216
217impl WasmBuildBatchEntry {
218    /// Zero-based position in the supplied labeled specification slice.
219    #[must_use]
220    pub const fn index(&self) -> usize {
221        self.index
222    }
223
224    /// Caller-owned stable label.
225    #[must_use]
226    pub fn label(&self) -> &str {
227        &self.label
228    }
229
230    /// Structured success or failure for this entry.
231    ///
232    /// # Errors
233    /// Returns this entry's recorded build error when the entry failed.
234    pub const fn result(&self) -> Result<&WasmBuildOutcome, &WasmBuildError> {
235        match &self.result {
236            Ok(outcome) => Ok(outcome),
237            Err((error, _)) => Err(error),
238        }
239    }
240
241    /// Successful Wasm outcome, when this entry succeeded.
242    #[must_use]
243    pub fn outcome(&self) -> Option<&WasmBuildOutcome> {
244        self.result.as_ref().ok()
245    }
246
247    /// Structured build failure, when this entry failed.
248    #[must_use]
249    pub fn error(&self) -> Option<&WasmBuildError> {
250        self.result().err()
251    }
252
253    /// Structured phase and partial timings when this entry failed.
254    #[must_use]
255    pub const fn failure_details(&self) -> Option<WasmBuildFailureDetails> {
256        match &self.result {
257            Ok(_) => None,
258            Err((_, details)) => Some(*details),
259        }
260    }
261
262    /// Complete wall-clock time retained for this entry.
263    #[must_use]
264    pub const fn entry_elapsed(&self) -> Duration {
265        self.entry_elapsed
266    }
267
268    /// Whether this entry completed successfully.
269    #[must_use]
270    pub const fn is_success(&self) -> bool {
271        self.result.is_ok()
272    }
273
274    /// Consume the entry into its identity, result, optional failure details, and wall time.
275    pub fn into_parts(
276        self,
277    ) -> (
278        usize,
279        String,
280        Result<WasmBuildOutcome, WasmBuildError>,
281        Option<WasmBuildFailureDetails>,
282        Duration,
283    ) {
284        let (result, failure) = match self.result {
285            Ok(outcome) => (Ok(outcome), None),
286            Err((error, details)) => (Err(error), Some(details)),
287        };
288        (self.index, self.label, result, failure, self.entry_elapsed)
289    }
290}
291
292/// Aggregate counters and successful-acquisition timings for a Wasm build batch.
293#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
294pub struct WasmBuildBatchMetrics {
295    specifications: usize,
296    built: usize,
297    reused: usize,
298    input_resolution_runs: usize,
299    input_resolution_reuses: usize,
300    input_resolution_prepared_reuses: usize,
301    successful_timings: WasmBuildTimings,
302    total: Duration,
303}
304
305/// Structured progress for an independent sequence of exact Wasm builds.
306#[non_exhaustive]
307#[derive(Clone, Debug, Eq, PartialEq)]
308pub enum WasmBuildBatchProgressEvent {
309    /// One independently resolved build specification is about to start.
310    BuildStarted {
311        /// Zero-based position in the supplied specification slice.
312        index: usize,
313        /// Caller-owned stable label.
314        label: String,
315        /// Total number of supplied specifications.
316        total: usize,
317    },
318    /// Progress forwarded from one independent build.
319    BuildProgress {
320        /// Zero-based position in the supplied specification slice.
321        index: usize,
322        /// Caller-owned stable label.
323        label: String,
324        /// Event emitted by that build.
325        event: WasmBuildProgressEvent,
326    },
327    /// One independent build completed successfully.
328    BuildFinished {
329        /// Zero-based position in the supplied specification slice.
330        index: usize,
331        /// Caller-owned stable label.
332        label: String,
333    },
334    /// One independent build failed.
335    BuildFailed {
336        /// Zero-based position in the supplied specification slice.
337        index: usize,
338        /// Caller-owned stable label.
339        label: String,
340    },
341}
342
343impl WasmBuildBatchReport {
344    /// Ordered labeled entries.
345    #[must_use]
346    pub fn entries(&self) -> &[WasmBuildBatchEntry] {
347        &self.entries
348    }
349
350    /// Consume the report into its ordered labeled entries.
351    #[must_use]
352    pub fn into_entries(self) -> Vec<WasmBuildBatchEntry> {
353        self.entries
354    }
355
356    /// Successful entries, retaining the canonical identity, result and timings.
357    pub fn outcomes(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
358        self.entries.iter().filter(|entry| entry.is_success())
359    }
360
361    /// Failed entries; inspect `error()` and `failure_details()` on each entry.
362    pub fn failures(&self) -> impl Iterator<Item = &WasmBuildBatchEntry> {
363        self.entries.iter().filter(|entry| !entry.is_success())
364    }
365
366    /// Canonical entries paired with their integrated shared-target maintenance.
367    ///
368    /// Batch-owned maintenance contributes at most one outcome for each
369    /// distinct configured shared-target path.
370    pub fn shared_incremental_maintenance_outcomes(
371        &self,
372    ) -> impl Iterator<
373        Item = (
374            &WasmBuildBatchEntry,
375            &SharedIncrementalTargetMaintenanceOutcome,
376        ),
377    > {
378        self.outcomes().filter_map(|entry| {
379            entry
380                .outcome()?
381                .record()
382                .shared_incremental_maintenance()
383                .map(|outcome| (entry, outcome))
384        })
385    }
386
387    /// Complete wall-clock time for the sequential collect-all batch.
388    #[must_use]
389    pub const fn total(&self) -> Duration {
390        self.total
391    }
392
393    /// Whether every specification completed successfully.
394    #[must_use]
395    pub fn is_success(&self) -> bool {
396        self.entries.iter().all(WasmBuildBatchEntry::is_success)
397    }
398
399    /// Aggregate outcome, input-resolution reuse, and timing counters.
400    #[must_use]
401    pub fn metrics(&self) -> WasmBuildBatchMetrics {
402        let mut metrics = WasmBuildBatchMetrics {
403            specifications: self.entries.len(),
404            input_resolution_runs: self.input_resolution.runs,
405            input_resolution_reuses: self.input_resolution.reuses,
406            input_resolution_prepared_reuses: self.input_resolution.prepared_reuses,
407            total: self.total,
408            ..WasmBuildBatchMetrics::default()
409        };
410        for outcome in self.entries.iter().filter_map(WasmBuildBatchEntry::outcome) {
411            if outcome.is_reused() {
412                metrics.reused += 1;
413            } else {
414                metrics.built += 1;
415            }
416            metrics.successful_timings = metrics
417                .successful_timings
418                .saturating_add(outcome.record().timings());
419        }
420        metrics
421    }
422}
423
424impl WasmBuildBatchMetrics {
425    /// Number of supplied specifications.
426    #[must_use]
427    pub const fn specifications(self) -> usize {
428        self.specifications
429    }
430
431    /// Number of successful specifications.
432    #[must_use]
433    pub const fn succeeded(self) -> usize {
434        self.built + self.reused
435    }
436
437    /// Number of failed specifications.
438    #[must_use]
439    pub const fn failed(self) -> usize {
440        self.specifications - self.succeeded()
441    }
442
443    /// Number of newly built Wasm artifact sets.
444    #[must_use]
445    pub const fn built(self) -> usize {
446        self.built
447    }
448
449    /// Number of Wasm artifact sets reused from the exact cache.
450    #[must_use]
451    pub const fn reused(self) -> usize {
452        self.reused
453    }
454
455    /// Number of workspace/toolchain input-resolution snapshots performed.
456    #[must_use]
457    pub const fn input_resolution_runs(self) -> usize {
458        self.input_resolution_runs
459    }
460
461    /// Number of specifications resolved by reusing another batch snapshot.
462    #[must_use]
463    pub const fn input_resolution_reuses(self) -> usize {
464        self.input_resolution_reuses
465    }
466
467    /// Number of specifications resolved from a prepared concurrent snapshot.
468    #[must_use]
469    pub const fn input_resolution_prepared_reuses(self) -> usize {
470        self.input_resolution_prepared_reuses
471    }
472
473    /// Sum of timings from successful acquisitions.
474    #[must_use]
475    pub const fn successful_timings(self) -> WasmBuildTimings {
476        self.successful_timings
477    }
478
479    /// Complete wall-clock time for the sequential batch.
480    #[must_use]
481    pub const fn total(self) -> Duration {
482        self.total
483    }
484}
485
486impl WasmBuildBatchConfig {
487    /// Create batch orchestration without batch-owned target maintenance.
488    #[must_use]
489    pub const fn new() -> Self {
490        Self {
491            shared_incremental_maintenance: None,
492        }
493    }
494
495    /// Maintain each distinct resolved shared target once through its first batch entry.
496    #[must_use]
497    pub const fn with_shared_incremental_target_maintenance(
498        mut self,
499        config: SharedIncrementalTargetMaintenanceConfig,
500    ) -> Self {
501        self.shared_incremental_maintenance = Some(config);
502        self
503    }
504
505    /// Strictly maintain each distinct shared target at most once per interval.
506    #[must_use]
507    pub const fn with_shared_incremental_target_maintenance_at_most_every(
508        self,
509        policy: CacheRetentionLimits,
510        minimum_interval: Duration,
511    ) -> Self {
512        self.with_shared_incremental_target_maintenance(
513            SharedIncrementalTargetMaintenanceConfig::new(policy, minimum_interval),
514        )
515    }
516
517    /// Batch-owned shared-target maintenance, when configured.
518    #[must_use]
519    pub const fn shared_incremental_target_maintenance(
520        self,
521    ) -> Option<SharedIncrementalTargetMaintenanceConfig> {
522        self.shared_incremental_maintenance
523    }
524}
525
526impl std::fmt::Display for WasmBuildBatchReport {
527    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        let metrics = self.metrics();
529        write!(
530            formatter,
531            "builds={} succeeded={} failed={} built={} reused={} input_resolution_runs={} input_resolution_reuses={} input_resolution_prepared_reuses={} successful_timings=({}) total={:?}",
532            metrics.specifications(),
533            metrics.succeeded(),
534            metrics.failed(),
535            metrics.built(),
536            metrics.reused(),
537            metrics.input_resolution_runs(),
538            metrics.input_resolution_reuses(),
539            metrics.input_resolution_prepared_reuses(),
540            metrics.successful_timings(),
541            metrics.total(),
542        )
543    }
544}
545
546/// Build every Wasm specification as an independent Cargo invocation.
547///
548/// Pass explicit batch configuration and optional progress reporting.
549/// `spawn` selects Cargo child/group custody and cleanup for each cache miss,
550/// independently of progress; it may override the default null stdin.
551/// Specifications run sequentially and every result is retained. Each entry
552/// keeps its own package set, profile arguments, feature resolution,
553/// fingerprint, locks, and cache policy. Packages are never combined into one
554/// Cargo command because doing so can unify shared dependency features.
555///
556/// # Errors
557/// Returns a contract error for an empty or duplicate label before any work.
558/// Individual build failures are retained in the returned report.
559pub fn build_wasm_canisters_cached_batch(
560    specs: &[LabeledWasmBuildSpec],
561    config: WasmBuildBatchConfig,
562    progress: Option<(
563        WasmBuildProgressConfig,
564        &mut dyn FnMut(WasmBuildBatchProgressEvent),
565    )>,
566    mut spawn: impl FnMut(
567        &mut std::process::Command,
568    ) -> std::io::Result<ic_host_process::child::OwnedChild>,
569) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
570    run_wasm_batch(specs, config, None, progress, &mut spawn)
571}
572
573#[expect(
574    clippy::result_large_err,
575    reason = "Batch entries retain complete failure details inline without per-failure allocation."
576)]
577fn run_wasm_batch(
578    specs: &[LabeledWasmBuildSpec],
579    batch_config: WasmBuildBatchConfig,
580    reuse: Option<&WasmBuildInputSnapshotState>,
581    mut observation: Option<(
582        WasmBuildProgressConfig,
583        &mut dyn FnMut(WasmBuildBatchProgressEvent),
584    )>,
585    mut spawn: impl FnMut(
586        &mut std::process::Command,
587    ) -> std::io::Result<ic_host_process::child::OwnedChild>,
588) -> Result<WasmBuildBatchReport, WasmBuildBatchContractError> {
589    validate_labels(specs.iter().map(LabeledWasmBuildSpec::label))
590        .map_err(WasmBuildBatchContractError::Labels)?;
591    validate_input_reuse(reuse)?;
592    let count = specs.len();
593    let mut resolver =
594        WasmBuildBatchInputResolver::new(specs.iter().map(LabeledWasmBuildSpec::spec), reuse)
595            .map_err(
596                |index| WasmBuildBatchContractError::SpecificationNotPrepared {
597                    index,
598                    label: specs[index].label.clone(),
599                },
600            )?;
601    let mut report = build_wasm_batch(specs, batch_config, |spec, index| {
602        let Some((progress_config, observer)) = observation.as_mut() else {
603            return build_wasm_canisters_cached_in_batch(
604                spec,
605                index,
606                &mut resolver,
607                None,
608                &mut spawn,
609            );
610        };
611        let label = specs[index].label.clone();
612        observer(WasmBuildBatchProgressEvent::BuildStarted {
613            index,
614            label: label.clone(),
615            total: count,
616        });
617        let mut forward = |event| {
618            observer(WasmBuildBatchProgressEvent::BuildProgress {
619                index,
620                label: label.clone(),
621                event,
622            });
623        };
624        let attempt = build_wasm_canisters_cached_in_batch(
625            spec,
626            index,
627            &mut resolver,
628            Some((*progress_config, &mut forward)),
629            &mut spawn,
630        );
631        observer(match &attempt {
632            Ok(_) => WasmBuildBatchProgressEvent::BuildFinished { index, label },
633            Err(_) => WasmBuildBatchProgressEvent::BuildFailed { index, label },
634        });
635        attempt
636    });
637    report.input_resolution = resolver.metrics();
638    Ok(report)
639}
640
641fn build_wasm_batch<F>(
642    specs: &[LabeledWasmBuildSpec],
643    config: WasmBuildBatchConfig,
644    mut build: F,
645) -> WasmBuildBatchReport
646where
647    F: FnMut(
648        &WasmBuildSpec,
649        usize,
650    ) -> Result<WasmBuildOutcome, (WasmBuildError, WasmBuildFailureDetails)>,
651{
652    let started = Instant::now();
653    let mut entries = Vec::with_capacity(specs.len());
654    let mut maintenance = BatchMaintenanceTracker::new(config.shared_incremental_maintenance);
655    for (index, labeled) in specs.iter().enumerate() {
656        let entry_started = Instant::now();
657        let spec = &labeled.spec;
658        let result = if config.shared_incremental_maintenance.is_some()
659            && spec.shared_incremental_target_maintenance().is_some()
660        {
661            Err((
662                batch_maintenance_ownership_error(),
663                WasmBuildFailureDetails::specification(entry_started.elapsed()),
664            ))
665        } else {
666            match maintenance.prepare_spec(spec) {
667                Ok(configured) => build(configured.as_ref().unwrap_or(spec), index),
668                Err(error) => Err((
669                    error,
670                    WasmBuildFailureDetails::specification(entry_started.elapsed()),
671                )),
672            }
673        };
674        entries.push(WasmBuildBatchEntry {
675            index,
676            label: labeled.label.clone(),
677            result,
678            entry_elapsed: entry_started.elapsed(),
679        });
680    }
681    WasmBuildBatchReport {
682        entries,
683        input_resolution: WasmBuildBatchInputMetrics::default(),
684        total: started.elapsed(),
685    }
686}
687
688fn validate_input_reuse(
689    reuse: Option<&WasmBuildInputSnapshotState>,
690) -> Result<(), WasmBuildBatchContractError> {
691    if reuse.is_some_and(WasmBuildInputSnapshotState::is_invalidated) {
692        Err(WasmBuildBatchContractError::SourceLeaseInvalidated)
693    } else {
694        Ok(())
695    }
696}
697
698struct BatchMaintenanceTracker {
699    config: Option<SharedIncrementalTargetMaintenanceConfig>,
700    configured_targets: HashSet<PathBuf>,
701}
702
703impl BatchMaintenanceTracker {
704    fn new(config: Option<SharedIncrementalTargetMaintenanceConfig>) -> Self {
705        Self {
706            config,
707            configured_targets: HashSet::new(),
708        }
709    }
710
711    fn prepare_spec(
712        &mut self,
713        spec: &WasmBuildSpec,
714    ) -> Result<Option<WasmBuildSpec>, WasmBuildError> {
715        let Some(config) = self.config else {
716            return Ok(None);
717        };
718        debug_assert!(spec.shared_incremental_target_maintenance().is_none());
719        let Some(target_dir) = shared_incremental_target(spec) else {
720            return Ok(None);
721        };
722        let canonical =
723            canonicalize_allow_missing(&target_dir).map_err(|source| WasmBuildError::Io {
724                operation: "resolve batch shared incremental target",
725                path: target_dir,
726                source,
727            })?;
728        if !self.configured_targets.insert(canonical) {
729            return Ok(None);
730        }
731        Ok(Some(
732            spec.clone()
733                .with_shared_incremental_target_maintenance(config),
734        ))
735    }
736}
737
738fn batch_maintenance_ownership_error() -> WasmBuildError {
739    WasmBuildError::InvalidSpec {
740        message:
741            "batch-owned shared-target maintenance cannot be combined with per-spec maintenance"
742                .to_owned(),
743    }
744}
745
746impl std::fmt::Display for WasmBuildBatchContractError {
747    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748        match self {
749            Self::Labels(source) => write!(formatter, "Wasm batch {source}"),
750            Self::SourceLeaseInvalidated => formatter
751                .write_str("Wasm build source lease was invalidated by a detected input mutation"),
752            Self::SpecificationNotPrepared { index, label } => write!(
753                formatter,
754                "Wasm batch entry {label:?} at index {index} was not declared when the input snapshot was prepared",
755            ),
756        }
757    }
758}
759
760impl std::error::Error for WasmBuildBatchContractError {
761    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
762        match self {
763            Self::Labels(source) => Some(source),
764            _ => None,
765        }
766    }
767}
768
769#[cfg(test)]
770mod tests;