Skip to main content

waterui_cli/workflows/
build.rs

1//! Build system
2
3use std::{
4    ffi::OsString,
5    io::{self, Write as _},
6    path::{Path, PathBuf},
7    process::Stdio,
8};
9
10use eyre::{Context as _, bail};
11use futures_util::StreamExt as _;
12use smol::{io::AsyncReadExt as _, process::Command, unblock};
13use target_lexicon::{Environment, OperatingSystem, Triple};
14
15use crate::project::Project;
16use crate::utils::{run_command, std_output_enabled};
17
18/// Get the dynamic library extension for a target triple.
19#[must_use]
20pub const fn lib_extension_for_triple(triple: &Triple) -> &'static str {
21    match triple.operating_system {
22        OperatingSystem::Darwin(_)
23        | OperatingSystem::MacOSX { .. }
24        | OperatingSystem::IOS(_)
25        | OperatingSystem::TvOS(_)
26        | OperatingSystem::WatchOS(_)
27        | OperatingSystem::VisionOS(_) => "dylib",
28        OperatingSystem::Windows => "dll",
29        // Linux, Android, and most other Unix-like targets use .so.
30        _ => "so",
31    }
32}
33
34/// Resolve the Rust standard-library directory for a target triple.
35///
36/// # Errors
37/// Returns an error if rustc cannot resolve an existing target library directory.
38pub async fn rust_target_libdir(triple: &Triple) -> eyre::Result<PathBuf> {
39    let target = triple.to_string();
40    let output = run_command(
41        "rustc",
42        ["--print", "target-libdir", "--target", target.as_str()],
43    )
44    .await?;
45    let libdir = output.trim();
46    if libdir.is_empty() {
47        bail!("`rustc --print target-libdir --target {target}` returned an empty path");
48    }
49    let path = PathBuf::from(libdir);
50    if !path.is_dir() {
51        bail!(
52            "Rust target libdir does not exist for dynamic linking: {}",
53            path.display()
54        );
55    }
56    Ok(path)
57}
58
59/// The Cargo target a build selects.
60///
61/// A crate-type override only has meaning for the library target, so carrying the
62/// target kind in the type keeps `cargo rustc -- --crate-type` from ever reaching a
63/// binary build.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub(crate) enum CargoTarget<'a> {
66    /// The crate's library target.
67    Lib,
68    /// One named binary target.
69    Binary(&'a str),
70}
71
72impl<'a> CargoTarget<'a> {
73    fn cargo_args(self) -> Vec<&'a str> {
74        match self {
75            Self::Lib => vec!["--lib"],
76            Self::Binary(name) => vec!["--bin", name],
77        }
78    }
79
80    const fn accepts_crate_type_override(self) -> bool {
81        matches!(self, Self::Lib)
82    }
83
84    /// Whether a `compiler-artifact` message's target is the one this build
85    /// selected.
86    fn matches(&self, target: &cargo_metadata::Target) -> bool {
87        use cargo_metadata::TargetKind;
88        match self {
89            Self::Binary(name) => {
90                target.name.as_str() == *name && target.kind.contains(&TargetKind::Bin)
91            }
92            Self::Lib => target.kind.iter().any(|kind| {
93                matches!(
94                    kind,
95                    TargetKind::Lib
96                        | TargetKind::RLib
97                        | TargetKind::DyLib
98                        | TargetKind::CDyLib
99                        | TargetKind::StaticLib
100                        | TargetKind::ProcMacro
101                )
102            }),
103        }
104    }
105}
106
107/// The outcome of one Cargo invocation: the profile directory everything
108/// landed under and the artifact Cargo reported for the selected target.
109#[derive(Debug)]
110pub struct BuiltTarget {
111    /// `<target>/<triple>/<profile>` — dependency artifacts and staged
112    /// runtime libraries resolve from this directory.
113    pub profile_dir: PathBuf,
114    /// The final artifact Cargo reported writing for the selected target —
115    /// its own `compiler-artifact` message, not a name reconstructed under
116    /// the profile root.
117    pub artifact: PathBuf,
118}
119
120/// Selects how Rust dependencies are linked into a native application.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum RustLinkage {
123    /// Link the `WaterUI` runtime into the application archive.
124    Static,
125    /// Link the application and loadable modules against one shared `WaterUI` runtime.
126    SharedRuntime,
127}
128
129/// Configure a Cargo invocation that compiles one of `WaterUI`'s generated crates.
130///
131/// Incremental compilation is off for every one of these builds, unconditionally.
132/// `-C incremental` is part of a unit's profile, the profile feeds Cargo's `-C metadata`,
133/// and `-C metadata` is mangled into every symbol name. Two builds in the same flow that
134/// disagree about incremental therefore produce runtimes whose symbols cannot resolve
135/// against each other: a preview support app built one way and a preview module built the
136/// other share a `libwaterui_dylib.dylib` filename and roughly 33,000 mismatched symbols,
137/// and the module fails to `dlopen` on a missing generic instantiation.
138///
139/// The choice is unconditional precisely so it cannot depend on an environmental accident
140/// such as whether a machine has `sccache` installed. Little is given up: every generated
141/// backend builds into one shared target directory where Cargo already reuses each unit's
142/// compiled artifact across backends and feature variants — while an `sccache` entry,
143/// which requires incremental to be off, covers what that sharing cannot.
144pub fn configure_generated_crate_compilation(command: &mut Command) {
145    command.env("CARGO_INCREMENTAL", "0");
146}
147
148/// Dynamic Rust libraries required by a shared-runtime development build.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct RustDynamicLibraries {
151    waterui: PathBuf,
152    standard_library: PathBuf,
153    triple: Triple,
154}
155
156impl RustDynamicLibraries {
157    /// Resolve the shared `WaterUI` runtime and target Rust standard library.
158    ///
159    /// # Errors
160    /// Returns an error when either required dynamic library is absent or ambiguous.
161    pub async fn resolve(lib_dir: &Path, triple: &Triple) -> eyre::Result<Self> {
162        let file_name = dynamic_library_file_name("waterui_dylib", triple);
163        // Cargo emits a dependency's final dylib artifact in `deps/` on stable
164        // and at the profile directory root on current nightlies; accept both.
165        // `deps/` wins: a copy an earlier `stage` left at the profile root must
166        // never mask the artifact the current build produced.
167        let waterui = [
168            lib_dir.join("deps").join(&file_name),
169            lib_dir.join(&file_name),
170        ]
171        .into_iter()
172        .find(|path| path.is_file())
173        .ok_or_else(|| {
174            eyre::eyre!(
175                "Shared WaterUI runtime was not built at {}",
176                lib_dir.join("deps").join(&file_name).display()
177            )
178        })?;
179
180        // A `-Zbuild-std` build publishes its freshly compiled `libstd` into
181        // the profile's `deps/` directory via the rustc wrapper; that copy —
182        // not the toolchain's prebuilt one — is what the build linked against,
183        // so it is the one that has to ship. The prebuilt lookup below is the
184        // fallback for builds that never built `std` from source.
185        let resolution_triple = triple.clone();
186        let deps_dir = lib_dir.join("deps");
187        let staged =
188            unblock(move || resolve_rust_standard_library_in(&deps_dir, &resolution_triple)).await;
189        let standard_library = match staged {
190            Ok(path) => path,
191            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
192                let target_libdir = rust_target_libdir(triple).await?;
193                let resolution_triple = triple.clone();
194                unblock(move || {
195                    resolve_rust_standard_library_in(&target_libdir, &resolution_triple)
196                })
197                .await?
198            }
199            Err(error) => return Err(error.into()),
200        };
201
202        Ok(Self {
203            waterui,
204            standard_library,
205            triple: triple.clone(),
206        })
207    }
208
209    /// Shared `WaterUI` runtime path.
210    #[must_use]
211    pub fn waterui(&self) -> &Path {
212        &self.waterui
213    }
214
215    /// Target Rust standard-library dynamic library path.
216    #[must_use]
217    pub fn standard_library(&self) -> &Path {
218        &self.standard_library
219    }
220
221    /// Iterate over every library that must be staged with the application.
222    pub fn iter(&self) -> impl Iterator<Item = &Path> {
223        [self.waterui(), self.standard_library()].into_iter()
224    }
225
226    /// Copy all required dynamic libraries into a runtime search directory.
227    ///
228    /// Staging goes through the reflinking copy so a shared runtime that every build
229    /// output needs a copy of costs one set of extents instead of one full copy per
230    /// destination. A copy-on-write clone is also the only sharing that is safe here:
231    /// these staged libraries are rewritten in place later (`install_name_tool`), so
232    /// hard links would corrupt the Cargo artifact they were linked to.
233    ///
234    /// # Errors
235    /// Returns an error when the destination cannot be created or a library cannot be copied.
236    pub async fn stage(&self, destination: &Path) -> eyre::Result<()> {
237        smol::fs::create_dir_all(destination).await?;
238        // A resolved source can already live inside the destination — the
239        // profile-root dylib a nightly emits — so the staged-copy cleanup must
240        // leave sources alone and the copy must not rewrite a library over
241        // itself.
242        let sources: Vec<PathBuf> = self.iter().map(|path| (*path).to_path_buf()).collect();
243        Self::remove_staged_except(destination, &self.triple, &sources).await?;
244        for source in &sources {
245            let file_name = source.file_name().ok_or_else(|| {
246                eyre::eyre!(
247                    "Dynamic library path has no file name: {}",
248                    source.display()
249                )
250            })?;
251            let staged = destination.join(file_name);
252            if *source == staged {
253                continue;
254            }
255            crate::utils::copy_file(source, &staged)
256                .await
257                .wrap_err_with(|| {
258                    format!(
259                        "Failed to stage {} to {}",
260                        source.display(),
261                        staged.display()
262                    )
263                })?;
264        }
265        Ok(())
266    }
267
268    /// Remove shared-runtime libraries left by an earlier development build.
269    ///
270    /// # Errors
271    /// Returns an error when the destination cannot be read or a matching library cannot be removed.
272    pub async fn remove_staged(destination: &Path, triple: &Triple) -> eyre::Result<()> {
273        Self::remove_staged_except(destination, triple, &[]).await
274    }
275
276    /// `keep` holds library paths that must survive: when a resolved source
277    /// already lives in `destination`, deleting it would remove the very
278    /// library being staged.
279    async fn remove_staged_except(
280        destination: &Path,
281        triple: &Triple,
282        keep: &[PathBuf],
283    ) -> eyre::Result<()> {
284        if !destination.is_dir() {
285            return Ok(());
286        }
287
288        let waterui = dynamic_library_file_name("waterui_dylib", triple);
289        let (standard_library_prefix, extension) =
290            if triple.operating_system == OperatingSystem::Windows {
291                ("std-", "dll")
292            } else {
293                ("libstd-", lib_extension_for_triple(triple))
294            };
295        let mut entries = smol::fs::read_dir(destination).await?;
296        while let Some(entry) = entries.next().await {
297            let entry = entry?;
298            if keep.contains(&entry.path()) {
299                continue;
300            }
301            let file_name = entry.file_name();
302            let file_name = file_name.to_string_lossy();
303            if file_name == waterui
304                || (file_name.starts_with(standard_library_prefix)
305                    && entry.path().extension().and_then(|value| value.to_str()) == Some(extension))
306            {
307                smol::fs::remove_file(entry.path()).await?;
308            }
309        }
310        Ok(())
311    }
312}
313
314fn dynamic_library_file_name(crate_name: &str, triple: &Triple) -> String {
315    if triple.operating_system == OperatingSystem::Windows {
316        format!("{crate_name}.dll")
317    } else {
318        format!("lib{crate_name}.{}", lib_extension_for_triple(triple))
319    }
320}
321
322/// Find the dynamic standard library a directory holds for `triple`.
323///
324/// A missing directory or an empty match set is `NotFound`; several
325/// candidates is an error — the caller cannot tell which `libstd` the build
326/// actually linked.
327fn resolve_rust_standard_library_in(libdir: &Path, triple: &Triple) -> std::io::Result<PathBuf> {
328    let (prefix, extension) = if triple.operating_system == OperatingSystem::Windows {
329        ("std-", "dll")
330    } else {
331        ("libstd-", lib_extension_for_triple(triple))
332    };
333    let entries = match std::fs::read_dir(libdir) {
334        Ok(entries) => entries,
335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
336            return Err(std::io::Error::new(
337                std::io::ErrorKind::NotFound,
338                format!("{} does not exist", libdir.display()),
339            ));
340        }
341        Err(error) => return Err(error),
342    };
343    let mut matches = entries
344        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
345        .filter(|path| {
346            path.file_name()
347                .and_then(|name| name.to_str())
348                .is_some_and(|name| {
349                    name.starts_with(prefix)
350                        && path.extension().and_then(|extension| extension.to_str())
351                            == Some(extension)
352                })
353        })
354        .collect::<Vec<_>>();
355    matches.sort_unstable();
356    match matches.as_slice() {
357        [path] => Ok(path.clone()),
358        [] => Err(std::io::Error::new(
359            std::io::ErrorKind::NotFound,
360            format!(
361                "Rust target libdir {} contains no dynamic standard library for {triple}",
362                libdir.display()
363            ),
364        )),
365        _ => Err(std::io::Error::other(format!(
366            "Rust target libdir {} contains multiple dynamic standard libraries for {triple}: {}",
367            libdir.display(),
368            matches
369                .iter()
370                .map(|path| path.display().to_string())
371                .collect::<Vec<_>>()
372                .join(", ")
373        ))),
374    }
375}
376
377/// Represents a Rust build for a specific target triple.
378#[derive(Debug, Clone)]
379pub struct RustBuild {
380    path: PathBuf,
381    triple: Triple,
382    project: Option<Project>,
383    /// Explicit Cargo target directory for cross-project artifact reuse.
384    target_dir: Option<PathBuf>,
385    /// Optional path to sccache for compilation caching.
386    sccache_path: Option<PathBuf>,
387    /// Cargo features to enable.
388    features: Vec<String>,
389    /// Override the final crate type built by `cargo rustc`.
390    crate_type_override: Option<String>,
391    /// Extra rustc flags to append via `RUSTFLAGS`.
392    rustc_flags: Vec<String>,
393    /// Rustc flags that apply to the final crate only, via `cargo rustc -- <flags>`.
394    ///
395    /// `RUSTFLAGS` is hashed into every dependency unit's fingerprint, so a flag that
396    /// only matters when linking the final artifact — an `-rpath` link argument, say —
397    /// must not go through [`Self::with_rustc_flag`]: two builds sharing one target
398    /// directory that disagree about `RUSTFLAGS` invalidate each other's entire
399    /// dependency graph. Trailing `cargo rustc` arguments reach only the selected
400    /// target's own compilation and leave dependency fingerprints alone.
401    final_rustc_args: Vec<String>,
402    /// rustup toolchain name (a nightly) when this build compiles the standard
403    /// library from source via `-Zbuild-std`.
404    ///
405    /// Cargo only ever emits the `rlib` half of a source-built `std`, so a
406    /// shared-runtime build on a target whose prebuilt `libstd` is unusable —
407    /// Android's is 4 KB-aligned, which 16 KB-page devices reject — runs Cargo
408    /// under the `water` rustc wrapper, which adds the `dylib` crate type to
409    /// the `std` unit and hands the produced `.so` to every dependent.
410    build_std_toolchain: Option<String>,
411    /// Extra environment variables to set for the cargo build process.
412    envs: Vec<(String, OsString)>,
413    /// Sink compile progress is reported to while cargo runs.
414    progress: Option<BuildProgress>,
415}
416
417/// The optimization/debug-info trade-off a Cargo build selects.
418///
419/// The variants are realized on top of the workspace's declared `dev` and
420/// `release` profiles through `CARGO_PROFILE_*` overrides, so they work on
421/// user projects and generated crates alike without manifest changes.
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
423pub enum BuildProfile {
424    /// The `dev` profile as declared: unoptimized, with debug info.
425    #[default]
426    Debug,
427    /// The `dev` profile lifted to a light optimization level with full debug
428    /// info — the development default for self-drawn backends, whose
429    /// per-frame cost sits in rendering dependencies rather than in app code.
430    Optimized,
431    /// The `release` profile at full speed optimization, without debug info.
432    Release,
433    /// The `release` profile at full speed optimization, with debug info and
434    /// symbols kept so a profiler can symbolicate the recording.
435    Profiling,
436}
437
438impl BuildProfile {
439    /// Whether the build uses Cargo's `release` profile — artifacts land in
440    /// the `release/` profile directory and `cargo` gets `--release`.
441    #[must_use]
442    pub const fn is_release(self) -> bool {
443        matches!(self, Self::Release | Self::Profiling)
444    }
445
446    /// Whether the profile keeps the development-run shape: the `include_web!`
447    /// dev server may serve mounts and the artifact packages as debuggable.
448    #[must_use]
449    pub const fn is_development(self) -> bool {
450        !self.is_release()
451    }
452
453    /// `CARGO_PROFILE_*` overrides realizing this profile on the workspace's
454    /// declared `dev`/`release` profiles.
455    ///
456    /// These compose with `profile.*.package."*"` overrides a manifest may
457    /// declare: the env sets the profile's base value, so generated crates —
458    /// whose `dev` profile already lifts dependencies to `opt-level 2` — keep
459    /// that dependency optimization while the base rises to cover the root
460    /// crate and the per-unit debug-assertion switches the override table
461    /// does not mention.
462    fn development_envs(self) -> Vec<(String, OsString)> {
463        let entries: &[(&str, &str)] = match self {
464            Self::Debug => &[],
465            Self::Optimized => &[
466                ("CARGO_PROFILE_DEV_OPT_LEVEL", "1"),
467                ("CARGO_PROFILE_DEV_DEBUG", "true"),
468                ("CARGO_PROFILE_DEV_DEBUG_ASSERTIONS", "false"),
469                ("CARGO_PROFILE_DEV_OVERFLOW_CHECKS", "false"),
470            ],
471            Self::Release => &[("CARGO_PROFILE_RELEASE_OPT_LEVEL", "3")],
472            Self::Profiling => &[
473                ("CARGO_PROFILE_RELEASE_OPT_LEVEL", "3"),
474                ("CARGO_PROFILE_RELEASE_DEBUG", "true"),
475                ("CARGO_PROFILE_RELEASE_STRIP", "none"),
476            ],
477        };
478        entries
479            .iter()
480            .map(|(key, value)| ((*key).to_string(), OsString::from(*value)))
481            .collect()
482    }
483}
484
485/// Options for building Rust libraries.
486#[derive(Debug, Clone)]
487pub struct BuildOptions {
488    profile: BuildProfile,
489    output_dir: Option<std::path::PathBuf>,
490    /// Optional path to sccache for compilation caching.
491    sccache_path: Option<std::path::PathBuf>,
492    /// Optional target triple override.
493    target_triple: Option<Triple>,
494    /// Rust runtime linkage used by the final native application.
495    linkage: RustLinkage,
496    /// Whether the built app will `dlopen` `WaterUI` modules — a preview
497    /// support app — and therefore must package the shared Rust runtime
498    /// instead of linking it in, even on a platform that otherwise forces
499    /// static linkage.
500    dynamic_module_loading: bool,
501    /// Whether `include_web!` mounts are dev-server-served and skipped when
502    /// the build stages assets (Hydrolysis stages at build time).
503    dev_server: bool,
504    /// `CARGO_PROFILE_*` overrides applied to the cargo invocation.
505    cargo_envs: Vec<(String, OsString)>,
506    /// Sink compile progress is reported to while cargo runs.
507    progress: Option<BuildProgress>,
508}
509
510impl BuildOptions {
511    /// Create options for a development build that uses the shared Rust runtime.
512    ///
513    /// Development runs want wall-clock speed: `Release` and `Profiling` force
514    /// `opt-level 3` rather than the size-optimized `opt-level "z"` the
515    /// packaging profile declares, and `Optimized`/`Profiling`/`Release` all
516    /// carry `CARGO_PROFILE_*` overrides the cargo invocation applies.
517    #[must_use]
518    pub fn development(profile: BuildProfile) -> Self {
519        Self {
520            profile,
521            output_dir: None,
522            sccache_path: None,
523            target_triple: None,
524            linkage: RustLinkage::SharedRuntime,
525            dynamic_module_loading: false,
526            dev_server: false,
527            cargo_envs: profile.development_envs(),
528            progress: None,
529        }
530    }
531
532    /// Link the Rust runtime in, whatever the caller asked for.
533    ///
534    /// A platform whose loader cannot accept the toolchain's prebuilt runtime
535    /// says so here rather than at the link step, so that the target directory
536    /// and the staged libraries agree with what is actually built.
537    #[must_use]
538    pub const fn with_static_runtime(mut self) -> Self {
539        self.linkage = RustLinkage::Static;
540        self
541    }
542
543    /// Create options for a self-contained package build.
544    ///
545    /// A packaged artifact builds under the profile the workspace declares —
546    /// no `CARGO_PROFILE_*` overrides: the release profile's size tuning
547    /// (`opt-level "z"`, symbol stripping) is the shipped configuration.
548    #[must_use]
549    pub const fn packaging(profile: BuildProfile) -> Self {
550        Self {
551            profile,
552            output_dir: None,
553            sccache_path: None,
554            target_triple: None,
555            linkage: RustLinkage::Static,
556            dynamic_module_loading: false,
557            dev_server: false,
558            cargo_envs: Vec::new(),
559            progress: None,
560        }
561    }
562
563    /// Whether the build uses Cargo's `release` profile.
564    #[must_use]
565    pub const fn is_release(&self) -> bool {
566        self.profile.is_release()
567    }
568
569    /// The selected build profile.
570    #[must_use]
571    pub const fn profile(&self) -> BuildProfile {
572        self.profile
573    }
574
575    /// `CARGO_PROFILE_*` overrides the cargo invocation applies.
576    #[must_use]
577    pub fn cargo_envs(&self) -> &[(String, OsString)] {
578        &self.cargo_envs
579    }
580
581    /// Mark web mounts as dev-server-served for asset staging this build does.
582    #[must_use]
583    pub const fn with_dev_server(mut self, dev_server: bool) -> Self {
584        self.dev_server = dev_server;
585        self
586    }
587
588    /// Whether web mounts are dev-server-served and skipped during staging.
589    #[must_use]
590    pub const fn uses_dev_server(&self) -> bool {
591        self.dev_server
592    }
593
594    /// Get the output directory, if specified
595    #[must_use]
596    pub fn output_dir(&self) -> Option<&std::path::Path> {
597        self.output_dir.as_deref()
598    }
599
600    /// Set the output directory where built libraries should be copied
601    #[must_use]
602    pub fn with_output_dir(mut self, output_dir: impl Into<std::path::PathBuf>) -> Self {
603        self.output_dir = Some(output_dir.into());
604        self
605    }
606
607    /// Get the sccache path, if configured
608    #[must_use]
609    pub fn sccache_path(&self) -> Option<&std::path::Path> {
610        self.sccache_path.as_deref()
611    }
612
613    /// Set the sccache path for compilation caching.
614    ///
615    /// When set, `RUSTC_WRAPPER` will be configured to use sccache,
616    /// which can significantly improve build times by caching compiled artifacts.
617    #[must_use]
618    pub fn with_sccache(mut self, sccache_path: impl Into<std::path::PathBuf>) -> Self {
619        self.sccache_path = Some(sccache_path.into());
620        self
621    }
622
623    /// Get the explicit target triple override, if configured.
624    #[must_use]
625    pub const fn target_triple(&self) -> Option<&Triple> {
626        self.target_triple.as_ref()
627    }
628
629    /// Override the target triple used for compilation.
630    #[must_use]
631    pub fn with_target_triple(mut self, target_triple: Triple) -> Self {
632        self.target_triple = Some(target_triple);
633        self
634    }
635
636    /// Get the selected Rust runtime linkage.
637    #[must_use]
638    pub const fn linkage(&self) -> RustLinkage {
639        self.linkage
640    }
641
642    /// Mark the built app as a host for `dlopen`'d `WaterUI` modules.
643    ///
644    /// A preview support app resolves a pushed module's framework symbols
645    /// against the runtime it already has open, so the shared runtime has to
646    /// ship in the package rather than be linked into the app alone.
647    #[must_use]
648    pub const fn with_dynamic_module_loading(mut self) -> Self {
649        self.dynamic_module_loading = true;
650        self
651    }
652
653    /// Whether the built app hosts dynamically loaded `WaterUI` modules.
654    #[must_use]
655    pub const fn loads_dynamic_modules(&self) -> bool {
656        self.dynamic_module_loading
657    }
658
659    /// Attach a compile-progress sink every cargo invocation this build
660    /// performs reports to.
661    #[must_use]
662    pub fn with_progress(mut self, progress: BuildProgress) -> Self {
663        self.progress = Some(progress);
664        self
665    }
666
667    /// The compile-progress sink, when one is attached.
668    #[must_use]
669    pub const fn progress(&self) -> Option<&BuildProgress> {
670        self.progress.as_ref()
671    }
672}
673
674/// Errors that can occur during the Rust build process.
675#[derive(Debug, thiserror::Error)]
676pub enum RustBuildError {
677    /// Failed to execute cargo build.
678    #[error("Failed to execute cargo build: {0}")]
679    FailToExecuteCargoBuild(std::io::Error),
680
681    /// Cargo executed but failed to build the Rust library.
682    #[error("Failed to build Rust library: {0}")]
683    FailToBuildRustLibrary(std::io::Error),
684}
685
686/// Cargo's compile-phase progress: one event per status line cargo writes to
687/// stderr.
688///
689/// A cold build reports nothing to a captured pipe for its whole duration, so
690/// `water run` and `water build` attach a [`BuildProgress`] sink that keeps
691/// the compile visibly alive on every terminal. Events are parsed from
692/// cargo's own output, never generated by a timer.
693#[derive(Debug, Clone, PartialEq, Eq)]
694pub enum CompileEvent {
695    /// A `name vversion` status line: one crate unit moved through cargo's
696    /// pipeline. `phase` is cargo's status word — `Compiling`, `Checking`,
697    /// `Fresh`, `Downloading`, `Downloaded` or `Doc-tests`.
698    Unit {
699        /// Cargo's status word.
700        phase: &'static str,
701        /// The crate the status line names.
702        name: String,
703        /// The crate's version, when the status line carries one.
704        version: Option<String>,
705    },
706    /// `Finished ...` — cargo's closing status line.
707    Finished(String),
708    /// Any other line — index and lock status, warnings, diagnostics,
709    /// build-script output.
710    Line(String),
711}
712
713/// The sink a cargo build reports its [`CompileEvent`]s into.
714///
715/// The terminal attaches one per build; events arrive on the task draining
716/// cargo's stderr, so a sink must stay cheap.
717#[derive(Clone)]
718pub struct BuildProgress {
719    report: std::sync::Arc<dyn Fn(CompileEvent) + Send + Sync>,
720    /// Whether the sink renders every line live. When it does, a build
721    /// failure report can tail the captured output instead of re-dumping what
722    /// the user already watched scroll by.
723    shows_all_lines: bool,
724}
725
726impl std::fmt::Debug for BuildProgress {
727    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
728        formatter.write_str("BuildProgress(..)")
729    }
730}
731
732impl BuildProgress {
733    /// A sink that renders each event through `report`.
734    #[must_use]
735    pub fn new(report: impl Fn(CompileEvent) + Send + Sync + 'static) -> Self {
736        Self {
737            report: std::sync::Arc::new(report),
738            shows_all_lines: false,
739        }
740    }
741
742    /// Mark the sink as rendering every line live, including
743    /// [`CompileEvent::Line`] diagnostics.
744    #[must_use]
745    pub const fn showing_all_lines(mut self) -> Self {
746        self.shows_all_lines = true;
747        self
748    }
749
750    /// Whether the sink renders every line live.
751    #[must_use]
752    pub const fn shows_all_lines(&self) -> bool {
753        self.shows_all_lines
754    }
755
756    fn report(&self, event: CompileEvent) {
757        (self.report)(event);
758    }
759}
760
761/// Cargo status words whose line names one crate unit: `phase name vversion`.
762const CARGO_UNIT_PHASES: &[&str] = &[
763    "Compiling",
764    "Checking",
765    "Fresh",
766    "Downloading",
767    "Downloaded",
768    "Doc-tests",
769];
770
771/// Classify one line of cargo's stderr into a [`CompileEvent`].
772///
773/// Cargo emits ANSI-colored status lines whenever color is forced — by the
774/// `CARGO_TERM_COLOR` this module sets for terminal output, or by the user's
775/// own `[term] color` configuration — so the line is classified on its
776/// stripped text. Text-carrying events keep the raw line: an interactive sink
777/// renders cargo's colors, and the piped and JSON renderers strip on emit.
778fn classify_compile_line(line: &str) -> CompileEvent {
779    let raw = line.trim();
780    let stripped = console::strip_ansi_codes(raw);
781    let text = stripped.trim();
782    for phase in CARGO_UNIT_PHASES {
783        let Some(rest) = text
784            .strip_prefix(phase)
785            .and_then(|rest| rest.strip_prefix(' '))
786        else {
787            continue;
788        };
789        // A unit line names `name vversion`; `Downloaded 12 crates` and
790        // `Doc-tests foo` are status text, not a unit.
791        let Some((name, version)) = rest.split_once(" v") else {
792            return CompileEvent::Line(raw.to_owned());
793        };
794        let version = version.split([' ', '(']).next().unwrap_or_default();
795        return CompileEvent::Unit {
796            phase,
797            name: name.to_owned(),
798            version: (!version.is_empty()).then(|| version.to_owned()),
799        };
800    }
801    if text.starts_with("Finished ") {
802        return CompileEvent::Finished(raw.to_owned());
803    }
804    CompileEvent::Line(raw.to_owned())
805}
806
807/// Spawn a configured command with piped stdio, drain both streams to their
808/// ends, and report cargo's stderr status lines to `progress`.
809///
810/// The returned [`std::process::Output`] is exactly what `output()` produces:
811/// pipes are always drained and collected in full, so failure reporting and
812/// retry detection see the same captured text whether or not a sink is
813/// attached. When no sink is attached and the CLI's output passthrough is
814/// enabled, raw stderr chunks echo to the terminal as they arrive — the
815/// historical `Stdio::inherit` behavior. Stdout is collected silently: a
816/// `--message-format=json` caller parses it as a protocol stream, so it is
817/// never mirrored.
818pub(crate) async fn command_output_with_progress(
819    command: &mut Command,
820    progress: Option<BuildProgress>,
821) -> io::Result<std::process::Output> {
822    let mut child = command
823        .kill_on_drop(true)
824        .stdin(Stdio::null())
825        .stdout(Stdio::piped())
826        .stderr(Stdio::piped())
827        .spawn()?;
828    let stdout_pipe = child.stdout.take().expect("stdout is piped");
829    let stderr_pipe = child.stderr.take().expect("stderr is piped");
830
831    // Raw chunk echo reproduces `Stdio::inherit` for a build carrying no
832    // progress sink; a sink renders the parsed events itself.
833    let echo = progress.is_none() && std_output_enabled();
834    // The drains run as their own tasks: inlined into this future their read
835    // buffers alone would push it past clippy's `large_futures` threshold.
836    let stdout_task = smol::spawn(drain_pipe(stdout_pipe));
837    let stderr_task = smol::spawn(drain_cargo_stderr(stderr_pipe, progress, echo));
838    let status = child.status().await?;
839    let stdout = stdout_task.await?;
840    let stderr = stderr_task.await?;
841    Ok(std::process::Output {
842        status,
843        stdout,
844        stderr,
845    })
846}
847
848/// Drain a piped child stream to EOF, collecting every byte.
849async fn drain_pipe(mut reader: impl smol::io::AsyncRead + Unpin) -> io::Result<Vec<u8>> {
850    let mut collected = Vec::new();
851    let mut chunk = [0u8; 8192];
852    loop {
853        let read = reader.read(&mut chunk).await?;
854        if read == 0 {
855            break;
856        }
857        collected.extend_from_slice(&chunk[..read]);
858    }
859    Ok(collected)
860}
861
862/// Drain cargo's piped stderr: collect every byte, echo raw chunks when
863/// passthrough is enabled, and report each completed line's [`CompileEvent`]
864/// to `progress` as it arrives.
865async fn drain_cargo_stderr(
866    mut reader: impl smol::io::AsyncRead + Unpin,
867    progress: Option<BuildProgress>,
868    echo: bool,
869) -> io::Result<Vec<u8>> {
870    let mut collected = Vec::new();
871    let mut pending: Vec<u8> = Vec::new();
872    let mut chunk = [0u8; 8192];
873    loop {
874        let read = reader.read(&mut chunk).await?;
875        if read == 0 {
876            break;
877        }
878        collected.extend_from_slice(&chunk[..read]);
879        if echo {
880            let _ = io::stderr().write_all(&chunk[..read]);
881            let _ = io::stderr().flush();
882        }
883        if let Some(sink) = &progress {
884            pending.extend_from_slice(&chunk[..read]);
885            // A line feed is never a UTF-8 continuation byte, so scanning raw
886            // bytes for line boundaries and decoding only complete lines
887            // cannot corrupt a multibyte character straddling a chunk.
888            while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') {
889                let line: Vec<u8> = pending.drain(..=newline).collect();
890                let line = String::from_utf8_lossy(&line);
891                let line = line.trim_end();
892                if !line.trim().is_empty() {
893                    sink.report(classify_compile_line(line));
894                }
895            }
896        }
897    }
898    if let Some(sink) = &progress {
899        let tail = String::from_utf8_lossy(&pending);
900        let tail = tail.trim_end();
901        if !tail.trim().is_empty() {
902            sink.report(classify_compile_line(tail));
903        }
904    }
905    Ok(collected)
906}
907
908impl RustBuild {
909    /// Create a new rust build for the given path and target triple.
910    pub fn new(path: impl AsRef<Path>, triple: Triple) -> Self {
911        Self {
912            path: path.as_ref().to_path_buf(),
913            triple,
914            project: None,
915            target_dir: None,
916            sccache_path: None,
917            features: Vec::new(),
918            crate_type_override: None,
919            rustc_flags: Vec::new(),
920            final_rustc_args: Vec::new(),
921            build_std_toolchain: None,
922            envs: Vec::new(),
923            progress: None,
924        }
925    }
926
927    pub(crate) fn with_project(mut self, project: &Project) -> Self {
928        self.project = Some(project.clone());
929        self
930    }
931
932    /// Use an explicit Cargo target directory.
933    #[must_use]
934    pub fn with_target_dir(mut self, target_dir: impl Into<PathBuf>) -> Self {
935        self.target_dir = Some(target_dir.into());
936        self
937    }
938
939    /// Set the sccache path for compilation caching.
940    ///
941    /// When set, `RUSTC_WRAPPER` will be configured to use sccache,
942    /// which can significantly improve incremental build times.
943    #[must_use]
944    pub fn with_sccache(mut self, sccache_path: PathBuf) -> Self {
945        self.sccache_path = Some(sccache_path);
946        self
947    }
948
949    /// Add a Cargo feature to enable during the build.
950    ///
951    /// Features are passed to cargo via `--features`.
952    #[must_use]
953    pub fn with_feature(mut self, feature: impl Into<String>) -> Self {
954        self.features.push(feature.into());
955        self
956    }
957
958    /// Add multiple Cargo features to enable during the build.
959    #[must_use]
960    pub fn with_features(mut self, features: impl IntoIterator<Item = impl Into<String>>) -> Self {
961        self.features.extend(features.into_iter().map(Into::into));
962        self
963    }
964
965    /// Cargo features this build passes via `--features`.
966    #[must_use]
967    pub fn features(&self) -> &[String] {
968        &self.features
969    }
970
971    /// Add a rustc flag to the build via `RUSTFLAGS`.
972    #[must_use]
973    pub fn with_rustc_flag(mut self, flag: impl Into<String>) -> Self {
974        self.rustc_flags.push(flag.into());
975        self
976    }
977
978    /// Add a rustc flag that applies to the final crate only.
979    ///
980    /// The flag is passed as a trailing `cargo rustc` argument instead of through
981    /// `RUSTFLAGS`, so dependency unit fingerprints stay identical across builds that
982    /// differ only in how their final artifact links. See the field documentation on
983    /// `final_rustc_args` for why link arguments must take this route.
984    #[must_use]
985    pub fn with_final_rustc_arg(mut self, flag: impl Into<String>) -> Self {
986        self.final_rustc_args.push(flag.into());
987        self
988    }
989
990    /// Build the Rust standard library from source with `-Zbuild-std` on the
991    /// named toolchain (a nightly with `rust-src`), sharing one `libstd`
992    /// dylib across the graph.
993    ///
994    /// The build runs Cargo under the `water` rustc wrapper
995    /// ([`crate::rustc_wrapper`]): Cargo strips `dylib` from `std`'s crate
996    /// types under `-Zbuild-std`, and the wrapper restores it so the produced
997    /// `libstd-*.so` carries the same strict version hash as the rlib every
998    /// dependent is compiled against. The wrapper also publishes the dylib
999    /// into the profile's `deps/` directory, where
1000    /// [`RustDynamicLibraries::resolve`] finds it before the toolchain's
1001    /// prebuilt copy.
1002    #[must_use]
1003    pub fn with_build_std(mut self, toolchain: impl Into<String>) -> Self {
1004        self.build_std_toolchain = Some(toolchain.into());
1005        self
1006    }
1007
1008    /// Prefer dynamic Rust dependencies and emit loader search paths for them.
1009    #[must_use]
1010    pub fn with_preferred_dynamic_linking(self) -> Self {
1011        self.with_rustc_flag("-Cprefer-dynamic")
1012            .with_rustc_flag("-Crpath")
1013    }
1014
1015    /// Configure this build for the selected Rust runtime linkage.
1016    ///
1017    /// A shared-runtime development build enables the project's `dev` feature (which
1018    /// resolves the shared `waterui-dylib` runtime), prefers dynamic linking, and —
1019    /// when the platform's loader needs one — embeds a loader search path into the
1020    /// final artifact only. A static packaging build needs none of this.
1021    #[must_use]
1022    pub fn with_linkage(
1023        self,
1024        linkage: RustLinkage,
1025        development_feature: &str,
1026        loader_search_path: Option<&str>,
1027    ) -> Self {
1028        if linkage == RustLinkage::Static {
1029            return self;
1030        }
1031        let build = self
1032            .with_feature(development_feature)
1033            .with_preferred_dynamic_linking();
1034        match loader_search_path {
1035            Some(path) => build.with_final_rustc_arg(format!("-Clink-arg=-Wl,-rpath,{path}")),
1036            None => build,
1037        }
1038    }
1039
1040    /// Override the library crate type passed to `rustc`.
1041    #[must_use]
1042    pub fn with_crate_type_override(mut self, crate_type: impl Into<String>) -> Self {
1043        self.crate_type_override = Some(crate_type.into());
1044        self
1045    }
1046
1047    /// Add an environment variable for the cargo build process.
1048    #[must_use]
1049    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<OsString>) -> Self {
1050        self.envs.push((key.into(), value.into()));
1051        self
1052    }
1053
1054    /// Add multiple environment variables for the cargo build process.
1055    #[must_use]
1056    pub fn with_envs(mut self, envs: impl IntoIterator<Item = (String, OsString)>) -> Self {
1057        self.envs.extend(envs);
1058        self
1059    }
1060
1061    /// Attach a compile-progress sink the cargo invocation reports to.
1062    ///
1063    /// Each [`CompileEvent`] is parsed from cargo's own stderr stream, so the
1064    /// report is driven by build output rather than a timer.
1065    #[must_use]
1066    pub fn with_progress(mut self, progress: BuildProgress) -> Self {
1067        self.progress = Some(progress);
1068        self
1069    }
1070
1071    /// Get the target triple for this build.
1072    #[must_use]
1073    pub const fn triple(&self) -> &Triple {
1074        &self.triple
1075    }
1076
1077    /// Build rust library in development mode.
1078    ///
1079    /// Will produce debug symbols and less optimizations for faster builds.
1080    ///
1081    /// # Errors
1082    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
1083    /// - `RustBuildError::FailToBuildRustLibrary`: If there was an error building the Rust library.
1084    pub async fn dev_build(&self) -> Result<BuiltTarget, RustBuildError> {
1085        self.build_lib(false).await
1086    }
1087
1088    /// Build rust library in release mode.
1089    ///
1090    /// # Errors
1091    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
1092    /// - `RustBuildError::FailToBuildRustLibrary`: If there was an error building the Rust library.
1093    pub async fn release_build(&self) -> Result<BuiltTarget, RustBuildError> {
1094        self.build_lib(true).await
1095    }
1096
1097    /// Build the crate's library target.
1098    ///
1099    /// The returned [`BuiltTarget`] carries the profile directory plus the
1100    /// artifact Cargo reported. A crate emitting several library crate types
1101    /// needs [`Self::with_crate_type_override`] to say which one is wanted —
1102    /// the build fails rather than guess.
1103    ///
1104    /// # Errors
1105    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
1106    /// - `RustBuildError::FailToBuildRustLibrary`: If there was an error building the Rust library.
1107    pub async fn build_lib(&self, release: bool) -> Result<BuiltTarget, RustBuildError> {
1108        self.build_inner(release, CargoTarget::Lib, self.lib_artifact_extension())
1109            .await
1110    }
1111
1112    /// Build a dynamic library (cdylib) and return the full path to the dylib file.
1113    ///
1114    /// The path is Cargo's own `compiler-artifact` report, so the returned file
1115    /// is the one this build wrote even when another project's identically
1116    /// named crate shares the target directory.
1117    ///
1118    /// # Errors
1119    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
1120    /// - `RustBuildError::FailToBuildRustLibrary`: If the library was not found after building.
1121    pub async fn build_dylib(&self, release: bool) -> Result<PathBuf, RustBuildError> {
1122        let built = self
1123            .build_inner(
1124                release,
1125                CargoTarget::Lib,
1126                Some(lib_extension_for_triple(&self.triple)),
1127            )
1128            .await?;
1129        Ok(built.artifact)
1130    }
1131
1132    /// Builds one named binary and returns its full output path.
1133    ///
1134    /// The path is Cargo's own `compiler-artifact` report (`executable` of the
1135    /// `--bin` unit), so it is the binary this build wrote even when another
1136    /// project's identically named crate shares the target directory.
1137    ///
1138    /// # Errors
1139    ///
1140    /// Returns an error when Cargo fails or the expected binary is missing.
1141    pub async fn build_binary(
1142        &self,
1143        binary_name: &str,
1144        release: bool,
1145    ) -> Result<PathBuf, RustBuildError> {
1146        let built = self
1147            .build_inner(release, CargoTarget::Binary(binary_name), None)
1148            .await?;
1149        Ok(built.artifact)
1150    }
1151
1152    /// Compute the expected dylib output path without building.
1153    ///
1154    /// This uses `cargo metadata` to resolve the target directory to avoid assuming
1155    /// a fixed `target/` path.
1156    ///
1157    /// # Errors
1158    /// Returns an error if Cargo metadata cannot be read.
1159    pub async fn dylib_path(
1160        &self,
1161        crate_name: &str,
1162        release: bool,
1163    ) -> Result<PathBuf, RustBuildError> {
1164        let lib_dir = self.lib_output_dir(release).await?;
1165        let lib_name = crate_name.replace('-', "_");
1166        let ext = lib_extension_for_triple(&self.triple);
1167        Ok(lib_dir.join(format!("lib{lib_name}.{ext}")))
1168    }
1169
1170    /// Return target directory path
1171    async fn build_inner(
1172        &self,
1173        release: bool,
1174        cargo_target: CargoTarget<'_>,
1175        artifact_extension: Option<&'static str>,
1176    ) -> Result<BuiltTarget, RustBuildError> {
1177        let mut output = self.cargo_build_output(release, cargo_target).await?;
1178
1179        if !output.status.success() {
1180            let mut combined = combined_build_output(&output);
1181
1182            // Handle stale CMake generator caches (e.g. Unix Makefiles vs Ninja)
1183            // by cleaning crate-local CMake build dirs and retrying once.
1184            if should_retry_after_cmake_generator_mismatch(&combined)
1185                && self.clean_stale_cmake_build_dirs().await?
1186            {
1187                output = self.cargo_build_output(release, cargo_target).await?;
1188                combined = combined_build_output(&output);
1189            }
1190
1191            if !output.status.success() && should_auto_install_meson(&combined) {
1192                match ensure_meson_installed_for_build().await {
1193                    Ok(()) => {
1194                        output = self.cargo_build_output(release, cargo_target).await?;
1195                    }
1196                    Err(install_err) => {
1197                        return Err(RustBuildError::FailToBuildRustLibrary(
1198                            std::io::Error::other(format!(
1199                                "Cargo build failed and meson appears missing.\n\
1200Automatic meson installation failed: {install_err}\n\n{}",
1201                                self.failure_report(&combined)
1202                            )),
1203                        ));
1204                    }
1205                }
1206            }
1207        }
1208
1209        if !output.status.success() {
1210            let combined = combined_build_output(&output);
1211            return Err(RustBuildError::FailToBuildRustLibrary(
1212                std::io::Error::other(format!(
1213                    "Cargo build failed:\n{}",
1214                    self.failure_report(&combined)
1215                )),
1216            ));
1217        }
1218
1219        // A dependency's final `dylib`/`cdylib` artifact uplifts to an
1220        // unhashed name (`deps/libwaterui_dylib.so`), so one filename serves
1221        // every same-named package sharing this target — last writer wins.
1222        // A `fresh` unit emits nothing yet still reports that path, which can
1223        // leave a different source's bytes where `water run` expects its own
1224        // runtime. The dep-info `.d` written alongside records the producing
1225        // sources; when they are not this unit's, clean the package so the
1226        // rebuild below emits this source's artifact.
1227        let stale = stale_shared_dylib_packages(&output.stdout).await?;
1228        if !stale.is_empty() {
1229            let target_dir = self.target_directory().await?;
1230            for package in &stale {
1231                clean_cargo_package(&self.path, package, &target_dir).await?;
1232            }
1233            output = self.cargo_build_output(release, cargo_target).await?;
1234            if !output.status.success() {
1235                let combined = combined_build_output(&output);
1236                return Err(RustBuildError::FailToBuildRustLibrary(
1237                    std::io::Error::other(format!(
1238                        "Cargo build failed:\n{}",
1239                        self.failure_report(&combined)
1240                    )),
1241                ));
1242            }
1243        }
1244
1245        let artifact =
1246            reported_artifact(&output.stdout, &self.path, cargo_target, artifact_extension)?;
1247        let profile_dir = self.lib_output_dir(release).await?;
1248        Ok(BuiltTarget {
1249            profile_dir,
1250            artifact,
1251        })
1252    }
1253
1254    /// The artifact extension this build's `--crate-type` override produces,
1255    /// when one is set and the type has a known file shape.
1256    fn lib_artifact_extension(&self) -> Option<&'static str> {
1257        self.crate_type_override
1258            .as_deref()
1259            .and_then(|crate_type| crate_type_artifact_extension(crate_type, &self.triple))
1260    }
1261
1262    /// The text a build failure report embeds: the whole captured output, or
1263    /// only its tail when the attached sink already rendered every line live.
1264    fn failure_report(&self, combined: &str) -> String {
1265        if self
1266            .progress
1267            .as_ref()
1268            .is_some_and(BuildProgress::shows_all_lines)
1269        {
1270            output_tail(combined)
1271        } else {
1272            combined.to_owned()
1273        }
1274    }
1275
1276    async fn clean_stale_cmake_build_dirs(&self) -> Result<bool, RustBuildError> {
1277        let target_dir = self.target_directory().await?;
1278        let triple = self.triple.to_string();
1279
1280        let removed = unblock(move || {
1281            let mut removed = 0usize;
1282            removed +=
1283                remove_cmake_build_dirs_in(&target_dir.join(&triple).join("debug").join("build"))?;
1284            removed += remove_cmake_build_dirs_in(
1285                &target_dir.join(&triple).join("release").join("build"),
1286            )?;
1287            Ok::<usize, std::io::Error>(removed)
1288        })
1289        .await
1290        .map_err(|error| {
1291            RustBuildError::FailToBuildRustLibrary(std::io::Error::other(format!(
1292                "Failed to clean stale CMake cache: {error}"
1293            )))
1294        })?;
1295
1296        Ok(removed > 0)
1297    }
1298
1299    async fn cargo_build_output(
1300        &self,
1301        release: bool,
1302        cargo_target: CargoTarget<'_>,
1303    ) -> Result<std::process::Output, RustBuildError> {
1304        let framework = self.project.as_ref().and_then(|project| {
1305            project
1306                .manifest()
1307                .framework
1308                .as_ref()
1309                .map(|framework| (project, framework))
1310        });
1311        if let Some((project, framework)) = framework {
1312            framework
1313                .prepare_build(project, &self.path, &self.features)
1314                .await
1315                .map_err(|error| {
1316                    RustBuildError::FailToBuildRustLibrary(std::io::Error::other(error.to_string()))
1317                })?;
1318        }
1319        let crate_type_override = if cargo_target.accepts_crate_type_override() {
1320            self.crate_type_override.as_deref()
1321        } else {
1322            None
1323        };
1324        let mut cmd = Command::new("cargo");
1325        let cargo_subcommand = if crate_type_override.is_some() || !self.final_rustc_args.is_empty()
1326        {
1327            "rustc"
1328        } else {
1329            "build"
1330        };
1331        let mut cmd = cmd.arg(cargo_subcommand);
1332        if self.build_std_toolchain.is_some() {
1333            // `-Zbuild-std-features` replaces Cargo's default std feature set
1334            // — `panic-unwind,backtrace,default` (cargo's `standard_lib.rs`)
1335            // — so all three are listed back explicitly; `default` keeps each
1336            // std-workspace crate's own defaults, notably `compiler_builtins`'s
1337            // `arch` routines. `compiler-builtins-c` then links the NDK's
1338            // prebuilt compiler-rt archive — on aarch64 that provides the LSE
1339            // outline-atomics helpers (`__aarch64_ldadd4_acq_rel` & friends)
1340            // that NDK-compiled C objects reference, which otherwise stay
1341            // undefined and make `dlopen` reject the libraries.
1342            cmd = cmd.arg("-Zbuild-std=std,panic_abort");
1343            cmd =
1344                cmd.arg("-Zbuild-std-features=panic-unwind,backtrace,default,compiler-builtins-c");
1345        }
1346        let mut cmd = cmd
1347            .arg("--message-format=json-render-diagnostics")
1348            .args(cargo_target.cargo_args())
1349            .args(["--target", self.triple.to_string().as_str()])
1350            .current_dir(&self.path);
1351        if framework.is_some() {
1352            cmd = cmd.arg("--locked");
1353        }
1354
1355        if let Some(target_dir) = &self.target_dir {
1356            cmd = cmd.arg("--target-dir").arg(target_dir);
1357        }
1358
1359        // Apply extra environment variables (caller-provided values override defaults).
1360        for (key, value) in &self.envs {
1361            cmd.env(key, value);
1362        }
1363
1364        if !self.rustc_flags.is_empty() {
1365            let mut rustflags = std::env::var_os("RUSTFLAGS").unwrap_or_default();
1366            if !rustflags.is_empty() {
1367                rustflags.push(" ");
1368            }
1369            rustflags.push(self.rustc_flags.join(" "));
1370            cmd = cmd.env("RUSTFLAGS", rustflags);
1371        }
1372
1373        configure_generated_crate_compilation(cmd);
1374
1375        // Use sccache as rustc wrapper if configured
1376        if let Some(sccache_path) = &self.sccache_path {
1377            crate::toolchain::sccache::configure_compilation_cache(cmd, sccache_path).map_err(
1378                |error| {
1379                    RustBuildError::FailToBuildRustLibrary(std::io::Error::other(error.to_string()))
1380                },
1381            )?;
1382        }
1383
1384        // A `-Zbuild-std` build runs the `water` binary itself as
1385        // `RUSTC_WRAPPER`, chained in front of sccache when one is configured,
1386        // so the wrapper can add the `dylib` crate type Cargo strips from the
1387        // `std` unit and publish the produced `libstd-*.so` into `deps/`.
1388        // This must come after the sccache block above to win `RUSTC_WRAPPER`.
1389        if self.build_std_toolchain.is_some() {
1390            cmd = self.with_build_std_envs(cmd, release).await?;
1391        }
1392
1393        // Set target-scoped bindgen clang args for simulator builds.
1394        //
1395        // Using the global `BINDGEN_EXTRA_CLANG_ARGS` leaks the simulator SDK into
1396        // host-side build scripts (for example `coreaudio-sys`), which then try to
1397        // parse host frameworks against the simulator SDK and fail. Bindgen supports
1398        // target-qualified env vars, so scope the override to the actual Cargo target.
1399        if self.triple.environment == Environment::Sim
1400            && let Some(clang_args) = self.bindgen_clang_args_for_simulator().await
1401        {
1402            let bindgen_target_key = format!(
1403                "BINDGEN_EXTRA_CLANG_ARGS_{}",
1404                self.triple.to_string().replace('-', "_")
1405            );
1406            cmd = cmd.env(bindgen_target_key, clang_args);
1407        }
1408
1409        if release {
1410            cmd = cmd.arg("--release");
1411        }
1412
1413        // Add cargo features if specified
1414        if !self.features.is_empty() {
1415            cmd = cmd.args(["--features", &self.features.join(",")]);
1416        }
1417
1418        if crate_type_override.is_some() || !self.final_rustc_args.is_empty() {
1419            cmd = cmd.arg("--");
1420            if let Some(crate_type) = crate_type_override {
1421                cmd = cmd.arg("--crate-type").arg(crate_type);
1422            }
1423            cmd = cmd.args(&self.final_rustc_args);
1424        }
1425
1426        // Piped stdio strips rustc diagnostics of their colors; when the
1427        // terminal renders them — through the progress sink or the raw
1428        // passthrough echo — restore cargo's coloring unless the caller
1429        // configured it explicitly.
1430        if std_output_enabled()
1431            && std::env::var_os("CARGO_TERM_COLOR").is_none()
1432            && !self.envs.iter().any(|(key, _)| key == "CARGO_TERM_COLOR")
1433        {
1434            cmd.env("CARGO_TERM_COLOR", "always");
1435        }
1436
1437        command_output_with_progress(cmd, self.progress.clone())
1438            .await
1439            .map_err(RustBuildError::FailToExecuteCargoBuild)
1440    }
1441
1442    /// Point a `-Zbuild-std` cargo invocation at the nightly toolchain and at
1443    /// this binary as `RUSTC_WRAPPER`, chained in front of sccache when one is
1444    /// configured.
1445    async fn with_build_std_envs<'a>(
1446        &self,
1447        cmd: &'a mut Command,
1448        release: bool,
1449    ) -> Result<&'a mut Command, RustBuildError> {
1450        let Some(toolchain) = &self.build_std_toolchain else {
1451            return Ok(cmd);
1452        };
1453        let publish_dir = self.lib_output_dir(release).await?.join("deps");
1454        let cmd = cmd
1455            .env("RUSTUP_TOOLCHAIN", toolchain)
1456            .env(
1457                "RUSTC_WRAPPER",
1458                crate::toolchain::Host::current_exe()
1459                    .map_err(RustBuildError::FailToExecuteCargoBuild)?,
1460            )
1461            .env(crate::workflows::rustc_wrapper::WRAPPER_MODE_ENV, "1")
1462            .env(
1463                crate::workflows::rustc_wrapper::BUILD_STD_TARGET_ENV,
1464                self.triple.to_string(),
1465            )
1466            .env(
1467                crate::workflows::rustc_wrapper::BUILD_STD_DYLIB_DIR_ENV,
1468                publish_dir,
1469            );
1470        if let Some(sccache_path) = &self.sccache_path {
1471            cmd.env(
1472                crate::workflows::rustc_wrapper::WRAPPER_CHAIN_ENV,
1473                sccache_path,
1474            );
1475        }
1476        // A workspace wrapper replaces `RUSTC_WRAPPER` on workspace-member
1477        // units — the support app's ffi crate and the generated module crate
1478        // are exactly the link-emitting members that need the `std` dylib
1479        // extern. Without it they would link `std` statically while the deps
1480        // link dynamically: two panic runtimes in one process.
1481        cmd.env_remove("RUSTC_WORKSPACE_WRAPPER");
1482        cmd.env_remove("CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER");
1483        Ok(cmd)
1484    }
1485
1486    /// Resolve the Cargo library artifact directory for this build target and profile.
1487    ///
1488    /// # Errors
1489    /// Returns an error if Cargo metadata cannot be read for this build target.
1490    pub async fn lib_output_dir(&self, release: bool) -> Result<PathBuf, RustBuildError> {
1491        let target_directory = self.target_directory().await?;
1492        Ok(target_directory
1493            .join(self.triple.to_string())
1494            .join(if release { "release" } else { "debug" }))
1495    }
1496
1497    async fn target_directory(&self) -> Result<PathBuf, RustBuildError> {
1498        if let Some(target_dir) = &self.target_dir {
1499            return Ok(target_dir.clone());
1500        }
1501
1502        let build_path = self.path.clone();
1503        let metadata = unblock(move || {
1504            cargo_metadata::MetadataCommand::new()
1505                .no_deps()
1506                .current_dir(build_path)
1507                .exec()
1508                .map_err(|e| {
1509                    RustBuildError::FailToBuildRustLibrary(std::io::Error::new(
1510                        std::io::ErrorKind::InvalidData,
1511                        e,
1512                    ))
1513                })
1514        })
1515        .await?;
1516        Ok(metadata.target_directory.as_std_path().to_path_buf())
1517    }
1518
1519    /// Generate `BINDGEN_EXTRA_CLANG_ARGS` for simulator builds.
1520    ///
1521    /// Bindgen has issues with the `*-apple-*-sim` target triples, so we need to
1522    /// provide explicit clang arguments with a proper target and SDK path.
1523    async fn bindgen_clang_args_for_simulator(&self) -> Option<String> {
1524        let (sdk_name, target_os) = match self.triple.operating_system {
1525            OperatingSystem::IOS(_) => ("iphonesimulator", "ios"),
1526            OperatingSystem::TvOS(_) => ("appletvsimulator", "tvos"),
1527            OperatingSystem::WatchOS(_) => ("watchsimulator", "watchos"),
1528            OperatingSystem::VisionOS(_) => ("xrsimulator", "xros"),
1529            _ => return None,
1530        };
1531
1532        let arch = match self.triple.architecture {
1533            target_lexicon::Architecture::Aarch64(_) => "arm64",
1534            target_lexicon::Architecture::X86_64 => "x86_64",
1535            _ => return None,
1536        };
1537
1538        // Get SDK path using xcrun
1539        let sdk_path = run_command("xcrun", ["--sdk", sdk_name, "--show-sdk-path"])
1540            .await
1541            .ok()
1542            .map(|s| s.trim().to_string())?;
1543
1544        // Use a reasonable minimum deployment target
1545        let min_version = if matches!(target_os, "ios" | "tvos") {
1546            "17.0"
1547        } else if target_os == "watchos" {
1548            "10.0"
1549        } else {
1550            debug_assert_eq!(
1551                target_os, "xros",
1552                "bindgen simulator target_os must be one of ios/tvos/watchos/xros"
1553            );
1554            "1.0"
1555        };
1556
1557        Some(format!(
1558            "--target={arch}-apple-{target_os}{min_version}-simulator -isysroot {sdk_path}"
1559        ))
1560    }
1561}
1562
1563/// The file extension the produced artifact carries for a `--crate-type`
1564/// value — `None` for a type with no single known file shape.
1565fn crate_type_artifact_extension(crate_type: &str, triple: &Triple) -> Option<&'static str> {
1566    match crate_type {
1567        "lib" | "rlib" => Some("rlib"),
1568        "staticlib" => Some(if matches!(triple.environment, Environment::Msvc) {
1569            "lib"
1570        } else {
1571            "a"
1572        }),
1573        "cdylib" | "dylib" | "proc-macro" => Some(lib_extension_for_triple(triple)),
1574        _ => None,
1575    }
1576}
1577
1578/// The final artifact Cargo reported for the selected target: the
1579/// `compiler-artifact` message for `crate_dir`'s manifest, matched by target
1580/// kind — Cargo's own report of what it wrote, never a name reconstructed
1581/// under the profile directory.
1582///
1583/// Every generated crate builds into one shared per-user Cargo target, so
1584/// `<profile>/<name>` alone is not evidence the file came from this build.
1585/// `artifact_extension` disambiguates a library target that emitted several
1586/// crate types; without one, the build reports exactly one file or this
1587/// fails rather than guesses.
1588///
1589/// # Errors
1590/// Returns an error when no `compiler-artifact` message for the selected
1591/// target reports a matching file, or the reported file does not exist.
1592pub(crate) fn reported_artifact(
1593    stdout: &[u8],
1594    crate_dir: &Path,
1595    cargo_target: CargoTarget<'_>,
1596    artifact_extension: Option<&'static str>,
1597) -> Result<PathBuf, RustBuildError> {
1598    let manifest_path = dunce::canonicalize(crate_dir.join("Cargo.toml")).map_err(|error| {
1599        RustBuildError::FailToBuildRustLibrary(io::Error::other(format!(
1600            "failed to canonicalize {}: {error}",
1601            crate_dir.join("Cargo.toml").display()
1602        )))
1603    })?;
1604    let mut artifacts = Vec::new();
1605    for artifact in compiler_artifacts(stdout)? {
1606        if cargo_target.matches(&artifact.target)
1607            && same_manifest_path(artifact.manifest_path.as_std_path(), &manifest_path)
1608        {
1609            artifacts.push(artifact);
1610        }
1611    }
1612    reported_artifact_file(&artifacts, cargo_target, artifact_extension, &manifest_path)
1613}
1614
1615/// Every `compiler-artifact` message in a cargo `--message-format=json`
1616/// stdout stream.
1617///
1618/// Cargo's report is the only record of what a build wrote, so a line naming
1619/// itself `compiler-artifact` that does not deserialize is a hard error
1620/// carrying the line — silently dropping it degrades into a misleading "no
1621/// artifact reported" failure downstream. Messages with any other `reason`,
1622/// and lines that are not cargo messages at all, are ignored.
1623pub(crate) fn compiler_artifacts(
1624    stdout: &[u8],
1625) -> Result<Vec<cargo_metadata::Artifact>, RustBuildError> {
1626    /// The one field that classifies a cargo message line.
1627    #[derive(serde::Deserialize)]
1628    struct Reason {
1629        reason: String,
1630    }
1631
1632    let mut artifacts = Vec::new();
1633    for (index, line) in stdout.split(|byte| *byte == b'\n').enumerate() {
1634        let Ok(line) = str::from_utf8(line) else {
1635            continue;
1636        };
1637        let line = line.trim_end();
1638        if line.is_empty() {
1639            continue;
1640        }
1641        let malformed = |error: serde_json::Error| {
1642            RustBuildError::FailToBuildRustLibrary(io::Error::new(
1643                io::ErrorKind::InvalidData,
1644                format!(
1645                    "cargo emitted a malformed `compiler-artifact` message on line {}: {error}\n{line}",
1646                    index + 1
1647                ),
1648            ))
1649        };
1650        match serde_json::from_str::<Reason>(line) {
1651            Ok(Reason { reason }) if reason == "compiler-artifact" => {
1652                let artifact =
1653                    serde_json::from_str::<cargo_metadata::Artifact>(line).map_err(malformed)?;
1654                artifacts.push(artifact);
1655            }
1656            // A line that is not readable JSON cannot yield its `reason`
1657            // field; one that still names itself a `compiler-artifact`
1658            // carries an unreadable payload — the hard error, never a drop.
1659            Err(error) if line.contains("\"reason\":\"compiler-artifact\"") => {
1660                return Err(malformed(error));
1661            }
1662            Ok(_) | Err(_) => {}
1663        }
1664    }
1665    Ok(artifacts)
1666}
1667
1668/// Whether a `manifest_path` cargo reported is `expected`, the manifest of
1669/// the crate this build ran. Cargo reports the path in the spelling its own
1670/// working directory carried — a verbatim `\\?\` or an 8.3 short-name root on
1671/// Windows — so a lexical miss canonicalizes the reported path (it exists;
1672/// cargo just built from it) before deciding.
1673pub(crate) fn same_manifest_path(reported: &Path, expected: &Path) -> bool {
1674    reported == expected
1675        || dunce::canonicalize(reported).is_ok_and(|canonical| canonical == expected)
1676}
1677
1678/// Picks the single file the selected target emitted out of its collected
1679/// `compiler-artifact` messages.
1680fn reported_artifact_file(
1681    artifacts: &[cargo_metadata::Artifact],
1682    cargo_target: CargoTarget<'_>,
1683    artifact_extension: Option<&'static str>,
1684    manifest_path: &Path,
1685) -> Result<PathBuf, RustBuildError> {
1686    let what = || -> String {
1687        match cargo_target {
1688            CargoTarget::Lib => format!("the library target of {}", manifest_path.display()),
1689            CargoTarget::Binary(name) => {
1690                format!("binary `{name}` of {}", manifest_path.display())
1691            }
1692        }
1693    };
1694    let not_found = |detail: String| {
1695        RustBuildError::FailToBuildRustLibrary(io::Error::new(io::ErrorKind::NotFound, detail))
1696    };
1697
1698    let files: Vec<PathBuf> = artifacts
1699        .iter()
1700        .flat_map(|artifact| {
1701            artifact
1702                .filenames
1703                .iter()
1704                .map(|file| file.as_std_path().to_path_buf())
1705        })
1706        .collect();
1707    let artifact = match cargo_target {
1708        CargoTarget::Binary(_) => artifacts
1709            .iter()
1710            .find_map(|artifact| artifact.executable.as_ref())
1711            .map(|path| path.as_std_path().to_path_buf())
1712            .ok_or_else(|| {
1713                not_found(format!(
1714                    "Cargo reported no artifact for {} (reported files: {files:?})",
1715                    what()
1716                ))
1717            })?,
1718        CargoTarget::Lib => {
1719            let matching: Vec<&PathBuf> = artifact_extension.map_or_else(
1720                || files.iter().collect(),
1721                |extension| {
1722                    files
1723                        .iter()
1724                        .filter(|file| file.extension().is_some_and(|e| *e == *extension))
1725                        .collect()
1726                },
1727            );
1728            match matching.as_slice() {
1729                [only] => (*only).clone(),
1730                _ => {
1731                    return Err(not_found(artifact_extension.map_or_else(
1732                        || {
1733                            format!(
1734                                "Cargo reported {} artifacts for {} — select one with a crate-type override (reported files: {files:?})",
1735                                matching.len(),
1736                                what()
1737                            )
1738                        },
1739                        |extension| {
1740                            format!(
1741                                "Cargo reported no `.{extension}` artifact for {} (reported files: {files:?})",
1742                                what()
1743                            )
1744                        },
1745                    )));
1746                }
1747            }
1748        }
1749    };
1750    if !artifact.is_file() {
1751        return Err(not_found(format!(
1752            "Cargo reported {} for {} but the file does not exist",
1753            artifact.display(),
1754            what()
1755        )));
1756    }
1757    Ok(artifact)
1758}
1759
1760/// Names of dependency packages whose `fresh` dynamic-library unit reports an
1761/// artifact another source's build of the same-named package last wrote.
1762///
1763/// Dep-info is the one record that names the producing sources: the `.d`
1764/// Cargo writes beside an uplifted dylib lists the writer's inputs, while the
1765/// unit's own `manifest_path` says which source *this* graph resolved. A
1766/// dep-info that names no file under the unit's manifest root was produced by
1767/// a different source's build, and the unhashed artifact it accompanies does
1768/// not belong to this project.
1769async fn stale_shared_dylib_packages(stdout: &[u8]) -> Result<Vec<String>, RustBuildError> {
1770    let mut stale = Vec::new();
1771    for artifact in compiler_artifacts(stdout)? {
1772        if !artifact.fresh {
1773            continue;
1774        }
1775        let Some(manifest_dir) = artifact.manifest_path.as_std_path().parent() else {
1776            continue;
1777        };
1778        // Only a `dylib`/`cdylib` unit uplifts to an unhashed, shareable
1779        // filename. A proc-macro's dylib keeps its metadata hash — the hash
1780        // covers the package id, so two sources never meet — and cargo's
1781        // build-dir layout stores it where no dep-info convention below
1782        // applies.
1783        if !uplifts_dynamic_library(&artifact.target) {
1784            continue;
1785        }
1786        let manifest_root = dunce::simplified(manifest_dir);
1787        let mut package_stale = false;
1788        for filename in &artifact.filenames {
1789            let file = filename.as_std_path();
1790            if !is_dynamic_library(file) {
1791                continue;
1792            }
1793            let Some(dep_info) = dep_info_path(file, &artifact.filenames) else {
1794                return Err(RustBuildError::FailToBuildRustLibrary(io::Error::new(
1795                    io::ErrorKind::NotFound,
1796                    format!(
1797                        "Cargo reported {} fresh but no dep-info was found beside it or in its unit directory (reported files: {:?})",
1798                        file.display(),
1799                        artifact.filenames
1800                    ),
1801                )));
1802            };
1803            let contents = smol::fs::read_to_string(&dep_info).await.map_err(|error| {
1804                RustBuildError::FailToBuildRustLibrary(io::Error::other(format!(
1805                    "Cargo reported {} fresh but its dep-info {} is unreadable: {error}",
1806                    file.display(),
1807                    dep_info.display()
1808                )))
1809            })?;
1810            // A dep-info that names no prerequisite under this unit's own
1811            // manifest root was written by a different source's build; a rare
1812            // miss costs one package rebuild — never a wrong artifact.
1813            if !dep_info_prerequisites(&contents).iter().any(|source| {
1814                let source = if source.is_absolute() {
1815                    source.clone()
1816                } else {
1817                    manifest_dir.join(source)
1818                };
1819                dunce::simplified(&source).starts_with(manifest_root)
1820            }) {
1821                package_stale = true;
1822            }
1823        }
1824        if package_stale {
1825            stale.push(artifact_package_name(&artifact.package_id).to_owned());
1826        }
1827    }
1828    stale.sort_unstable();
1829    stale.dedup();
1830    Ok(stale)
1831}
1832
1833/// Whether `file` names a dynamically linked library — the artifact shape a
1834/// dependency's final target uplifts to one unhashed filename per name.
1835fn is_dynamic_library(file: &Path) -> bool {
1836    file.extension()
1837        .is_some_and(|extension| matches!(extension.to_str(), Some("so" | "dylib" | "dll")))
1838}
1839
1840/// Whether the unit's final artifact is a dynamic library cargo uplifts to
1841/// an unhashed filename: a `dylib` or `cdylib` crate type. Proc-macro
1842/// crates are dynamic libraries too, but stay hashed and are never shared.
1843fn uplifts_dynamic_library(target: &cargo_metadata::Target) -> bool {
1844    target.crate_types.iter().any(|kind| {
1845        matches!(
1846            kind,
1847            cargo_metadata::CrateType::DyLib | cargo_metadata::CrateType::CDyLib
1848        )
1849    })
1850}
1851
1852/// The dep-info `.d` cargo wrote for the unit that produced `artifact_file`,
1853/// found where each cargo layout puts it.
1854///
1855/// Measured on a `dylib` dependency and a `cdylib` root unit (cargo 1.98
1856/// stable and the 1.100 nightly build-dir layout, `--message-format=json`):
1857///
1858/// - stable writes `<profile>/deps/<name>.d` for both, beside the hashed
1859///   copy, and uplifts the root unit's as `<profile>/lib<name>.d`;
1860/// - the build-dir layout writes `<name>.d` in the unit's own
1861///   `build/<package>/<hash>/out/` directory — a directory the message names
1862///   only through the unit's other outputs (the `.rmeta`/`.rlib` a dependency
1863///   emits) — and still uplifts the root unit's as `<profile>/lib<name>.d`.
1864///
1865/// `sibling_files` are the unit's reported filenames; the first candidate
1866/// that exists wins, and no candidate means the caller reports the miss.
1867fn dep_info_path(
1868    artifact_file: &Path,
1869    sibling_files: &[cargo_metadata::camino::Utf8PathBuf],
1870) -> Option<PathBuf> {
1871    let file_stem = artifact_file.file_stem()?.to_str()?;
1872    let name = file_stem.strip_prefix("lib").unwrap_or(file_stem);
1873    let dir = artifact_file.parent()?;
1874    // Most specific first: the uplifted `lib<name>.d`, the stable `deps/`
1875    // copy, the unit directory a sibling output names, and only then a bare
1876    // `<name>.d` beside the artifact (which the hashed proc-macro layout
1877    // spells that way, and which a same-named bin would also write).
1878    let mut candidates = vec![
1879        dir.join(format!("{file_stem}.d")),
1880        dir.join("deps").join(format!("{name}.d")),
1881    ];
1882    candidates.extend(
1883        sibling_files
1884            .iter()
1885            .filter_map(|sibling| sibling.as_std_path().parent())
1886            .filter(|unit_dir| *unit_dir != dir)
1887            .map(|unit_dir| unit_dir.join(format!("{name}.d"))),
1888    );
1889    candidates.push(dir.join(format!("{name}.d")));
1890    candidates.into_iter().find(|candidate| candidate.is_file())
1891}
1892
1893/// The prerequisite paths a dep-info `.d` lists.
1894///
1895/// Cargo writes Makefile syntax: one `<target>: <space-separated
1896/// prerequisites>` rule per emitted artifact, then an empty `<path>:` rule
1897/// per prerequisite. rustc's `escape_dep_filename`
1898/// (`compiler/rustc_interface/src/passes.rs`) escapes *only* a literal space
1899/// as `\ ` — every other byte, a Windows backslash or drive-letter colon
1900/// included, is verbatim — and Cargo's own `parse_rustc_dep_info`
1901/// (`src/cargo/core/compiler/fingerprint/dep_info.rs`) reads the same
1902/// contract: split a rule at its first `": "` — `C:\` is colon-then-
1903/// backslash and a literal `": "` inside a name arrives escaped `":\ "`, so
1904/// the separator is unambiguous — then treat a token's trailing `\` as the
1905/// escaped space joining it to the next token. rustc never emits `$$` or
1906/// `\\` escapes in prerequisites, so neither is unescaped here: doing so
1907/// would corrupt the verbatim bytes a Windows path carries. A `\` at the
1908/// end of a line is make's continuation and joins the next line before
1909/// tokenizing.
1910fn dep_info_prerequisites(contents: &str) -> Vec<PathBuf> {
1911    // Join `\<newline>` continuations into one logical line per rule before
1912    // anything looks for the `": "` separator.
1913    let mut joined = String::with_capacity(contents.len());
1914    for line in contents.lines() {
1915        if let Some(head) = line.strip_suffix('\\') {
1916            joined.push_str(head);
1917            joined.push(' ');
1918        } else {
1919            joined.push_str(line);
1920            joined.push('\n');
1921        }
1922    }
1923    let mut prerequisites = Vec::new();
1924    for line in joined.lines() {
1925        let Some((_, rest)) = line.split_once(": ") else {
1926            continue;
1927        };
1928        let mut token = String::new();
1929        let mut chars = rest.chars().peekable();
1930        while let Some(c) = chars.next() {
1931            match c {
1932                '\\' if chars.peek() == Some(&' ') => {
1933                    chars.next();
1934                    token.push(' ');
1935                }
1936                c if c.is_whitespace() => {
1937                    if !token.is_empty() {
1938                        prerequisites.push(PathBuf::from(std::mem::take(&mut token)));
1939                    }
1940                }
1941                c => token.push(c),
1942            }
1943        }
1944        if !token.is_empty() {
1945            prerequisites.push(PathBuf::from(token));
1946        }
1947    }
1948    prerequisites
1949}
1950
1951/// The package name a `package_id` specifier carries — `source#name@version`,
1952/// or the source's final path segment for the older `source#version` form.
1953fn artifact_package_name(package_id: &cargo_metadata::PackageId) -> &str {
1954    let repr = package_id.repr.as_str();
1955    let (source, fragment) = repr.rsplit_once('#').unwrap_or((repr, ""));
1956    fragment.split_once('@').map_or_else(
1957        || source.rsplit('/').next().unwrap_or(repr),
1958        |(name, _)| name,
1959    )
1960}
1961
1962/// `cargo clean -p <package>` in `crate_dir`, confined to `target_dir`: drops
1963/// the package's units — including the unhashed artifact another source's
1964/// build left behind — so the next build re-emits this graph's own.
1965async fn clean_cargo_package(
1966    crate_dir: &Path,
1967    package: &str,
1968    target_dir: &Path,
1969) -> Result<(), RustBuildError> {
1970    let mut command = Command::new("cargo");
1971    command
1972        .arg("clean")
1973        .arg("-p")
1974        .arg(package)
1975        .arg("--target-dir")
1976        .arg(target_dir)
1977        .current_dir(crate_dir);
1978    configure_generated_crate_compilation(&mut command);
1979    let output = command
1980        .output()
1981        .await
1982        .map_err(RustBuildError::FailToExecuteCargoBuild)?;
1983    if !output.status.success() {
1984        return Err(RustBuildError::FailToBuildRustLibrary(io::Error::other(
1985            format!(
1986                "cargo clean -p {package} failed:\n{}",
1987                String::from_utf8_lossy(&output.stderr)
1988            ),
1989        )));
1990    }
1991    Ok(())
1992}
1993
1994fn combined_build_output(output: &std::process::Output) -> String {
1995    let stderr = String::from_utf8_lossy(&output.stderr);
1996    let stdout = String::from_utf8_lossy(&output.stdout);
1997    if stderr.is_empty() {
1998        stdout.to_string()
1999    } else {
2000        stderr.to_string()
2001    }
2002}
2003
2004/// Lines a failure report keeps when the terminal already streamed the whole
2005/// build live — the dump is truncated to this tail.
2006const FAILURE_TAIL_LINES: usize = 40;
2007
2008/// The last [`FAILURE_TAIL_LINES`] lines of `text` — what a failure report
2009/// needs when the terminal already rendered the full stream.
2010pub(crate) fn output_tail(text: &str) -> String {
2011    let lines: Vec<&str> = text.lines().collect();
2012    if lines.len() <= FAILURE_TAIL_LINES {
2013        return text.to_owned();
2014    }
2015    format!(
2016        "… {} earlier lines already streamed above …\n{}",
2017        lines.len() - FAILURE_TAIL_LINES,
2018        lines[lines.len() - FAILURE_TAIL_LINES..].join("\n")
2019    )
2020}
2021
2022fn should_auto_install_meson(build_output: &str) -> bool {
2023    let lower = build_output.to_ascii_lowercase();
2024    lower.contains("meson")
2025        && (lower.contains("not found")
2026            || lower.contains("no such file")
2027            || lower.contains("failed to execute")
2028            || lower.contains("is required"))
2029}
2030
2031fn should_retry_after_cmake_generator_mismatch(build_output: &str) -> bool {
2032    let lower = build_output.to_ascii_lowercase();
2033    lower.contains("cmake error") && lower.contains("does not match the generator used previously")
2034}
2035
2036fn remove_cmake_build_dirs_in(build_root: &Path) -> std::io::Result<usize> {
2037    if !build_root.exists() {
2038        return Ok(0);
2039    }
2040
2041    let mut removed = 0usize;
2042    for entry in std::fs::read_dir(build_root)? {
2043        let entry = entry?;
2044        let path = entry.path();
2045        if !path.is_dir() {
2046            continue;
2047        }
2048
2049        let cmake_build_dir = path.join("out").join("build");
2050        if cmake_build_dir.join("CMakeCache.txt").exists() {
2051            std::fs::remove_dir_all(cmake_build_dir)?;
2052            removed += 1;
2053        }
2054    }
2055
2056    Ok(removed)
2057}
2058
2059#[cfg(target_os = "macos")]
2060async fn ensure_meson_installed_for_build() -> Result<(), String> {
2061    use crate::toolchain::meson::Meson;
2062    use crate::toolchain::{Installation as _, Toolchain as _, ToolchainError};
2063
2064    let host = crate::toolchain::Host::current();
2065    match Meson.check(&host).await {
2066        Ok(()) => Ok(()),
2067        Err(ToolchainError::Fixable(installation)) => {
2068            installation.install(&host).await.map_err(|e| e.to_string())
2069        }
2070        Err(ToolchainError::Unfixable(e)) => Err(e.to_string()),
2071    }
2072}
2073
2074#[cfg(not(target_os = "macos"))]
2075fn ensure_meson_installed_for_build() -> impl std::future::Future<Output = Result<(), String>> {
2076    std::future::ready(Err(
2077        "automatic meson installation is only supported on macOS".to_string(),
2078    ))
2079}
2080
2081#[cfg(test)]
2082mod tests {
2083    use target_lexicon::Triple;
2084    use tempfile::tempdir;
2085
2086    use std::ffi::OsString;
2087    use std::path::PathBuf;
2088
2089    use super::{
2090        BuildOptions, BuildProfile, CargoTarget, CompileEvent, RustBuild, RustDynamicLibraries,
2091        RustLinkage, classify_compile_line, dynamic_library_file_name, lib_extension_for_triple,
2092        resolve_rust_standard_library_in,
2093    };
2094
2095    fn triple(value: &str) -> Triple {
2096        value.parse().expect("test target triple must parse")
2097    }
2098
2099    #[test]
2100    fn crate_type_override_applies_only_to_library_targets() {
2101        assert!(CargoTarget::Lib.accepts_crate_type_override());
2102        assert!(!CargoTarget::Binary("waterui-cef-helper").accepts_crate_type_override());
2103        assert_eq!(CargoTarget::Lib.cargo_args(), ["--lib"]);
2104        assert_eq!(
2105            CargoTarget::Binary("waterui-cef-helper").cargo_args(),
2106            ["--bin", "waterui-cef-helper"]
2107        );
2108    }
2109
2110    #[test]
2111    fn build_std_envs_wire_the_wrapper_and_clear_workspace_wrappers() {
2112        use std::ffi::OsStr;
2113
2114        let dir = tempdir().expect("target dir");
2115        let toolchain = "nightly-2026-09-09-aarch64-apple-darwin";
2116        let target_dir = dir.path().join("target");
2117        let build = RustBuild::new(dir.path(), triple("aarch64-linux-android"))
2118            .with_build_std(toolchain)
2119            .with_target_dir(target_dir.clone())
2120            .with_sccache(std::path::PathBuf::from("/fake/sccache"));
2121        let mut cmd = smol::process::Command::new("cargo");
2122        smol::block_on(build.with_build_std_envs(&mut cmd, false)).expect("build-std envs apply");
2123
2124        let env = |key: &str| -> Option<Option<OsString>> {
2125            cmd.get_envs()
2126                .find(|(name, _)| *name == OsStr::new(key))
2127                .map(|(_, value)| value.map(ToOwned::to_owned))
2128        };
2129        assert_eq!(
2130            env("RUSTUP_TOOLCHAIN"),
2131            Some(Some(OsString::from(toolchain)))
2132        );
2133        assert_eq!(
2134            env("RUSTC_WRAPPER"),
2135            Some(Some(
2136                crate::toolchain::Host::current_exe()
2137                    .expect("the test binary path")
2138                    .into_os_string()
2139            )),
2140            "the wrapper must name this binary"
2141        );
2142        assert_eq!(
2143            env(crate::workflows::rustc_wrapper::WRAPPER_MODE_ENV),
2144            Some(Some(OsString::from("1")))
2145        );
2146        assert_eq!(
2147            env(crate::workflows::rustc_wrapper::BUILD_STD_TARGET_ENV),
2148            Some(Some(OsString::from("aarch64-linux-android")))
2149        );
2150        let expected_dylib_dir = target_dir
2151            .join("aarch64-linux-android")
2152            .join("debug")
2153            .join("deps");
2154        assert_eq!(
2155            env(crate::workflows::rustc_wrapper::BUILD_STD_DYLIB_DIR_ENV),
2156            Some(Some(expected_dylib_dir.into_os_string()))
2157        );
2158        assert_eq!(
2159            env(crate::workflows::rustc_wrapper::WRAPPER_CHAIN_ENV),
2160            Some(Some(OsString::from("/fake/sccache"))),
2161            "a configured sccache chains behind the shim"
2162        );
2163        // A workspace wrapper would replace RUSTC_WRAPPER on exactly the
2164        // link-emitting member units, so both spellings must be removed.
2165        assert_eq!(env("RUSTC_WORKSPACE_WRAPPER"), Some(None));
2166        assert_eq!(env("CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER"), Some(None));
2167    }
2168
2169    #[test]
2170    fn apple_platform_dylibs_use_macho_extension() {
2171        assert_eq!(
2172            lib_extension_for_triple(&triple("aarch64-apple-darwin")),
2173            "dylib"
2174        );
2175        assert_eq!(
2176            lib_extension_for_triple(&triple("aarch64-apple-ios-sim")),
2177            "dylib"
2178        );
2179        assert_eq!(
2180            lib_extension_for_triple(&triple("aarch64-apple-ios")),
2181            "dylib"
2182        );
2183    }
2184
2185    #[test]
2186    fn non_apple_platform_dylibs_keep_platform_extensions() {
2187        assert_eq!(
2188            lib_extension_for_triple(&triple("aarch64-linux-android")),
2189            "so"
2190        );
2191        assert_eq!(
2192            lib_extension_for_triple(&triple("x86_64-unknown-linux-gnu")),
2193            "so"
2194        );
2195        assert_eq!(
2196            lib_extension_for_triple(&triple("x86_64-pc-windows-msvc")),
2197            "dll"
2198        );
2199    }
2200
2201    #[test]
2202    fn development_and_packaging_have_distinct_linkage() {
2203        assert_eq!(
2204            BuildOptions::development(BuildProfile::Debug).linkage(),
2205            RustLinkage::SharedRuntime
2206        );
2207        assert_eq!(
2208            BuildOptions::packaging(BuildProfile::Debug).linkage(),
2209            RustLinkage::Static
2210        );
2211        assert!(BuildOptions::development(BuildProfile::Release).is_release());
2212        assert!(BuildOptions::packaging(BuildProfile::Release).is_release());
2213    }
2214
2215    #[test]
2216    fn build_profile_release_variants_select_the_release_profile() {
2217        assert!(BuildProfile::Release.is_release());
2218        assert!(BuildProfile::Profiling.is_release());
2219        assert!(!BuildProfile::Debug.is_release());
2220        assert!(!BuildProfile::Optimized.is_release());
2221    }
2222
2223    #[test]
2224    fn development_profile_envs_realize_the_selected_trade_off() {
2225        let optimized = BuildOptions::development(BuildProfile::Optimized);
2226        let envs = optimized.cargo_envs();
2227        assert!(
2228            envs.contains(&(
2229                "CARGO_PROFILE_DEV_OPT_LEVEL".to_string(),
2230                OsString::from("1")
2231            )),
2232            "optimized development lifts the dev opt-level: {envs:?}"
2233        );
2234        assert!(
2235            envs.contains(&(
2236                "CARGO_PROFILE_DEV_DEBUG_ASSERTIONS".to_string(),
2237                OsString::from("false")
2238            )),
2239            "optimized development drops dep debug assertions: {envs:?}"
2240        );
2241        assert!(
2242            envs.contains(&(
2243                "CARGO_PROFILE_DEV_DEBUG".to_string(),
2244                OsString::from("true")
2245            )),
2246            "optimized development keeps full debug info: {envs:?}"
2247        );
2248
2249        let profiling = BuildOptions::development(BuildProfile::Profiling);
2250        let envs = profiling.cargo_envs();
2251        for key in [
2252            "CARGO_PROFILE_RELEASE_OPT_LEVEL",
2253            "CARGO_PROFILE_RELEASE_DEBUG",
2254            "CARGO_PROFILE_RELEASE_STRIP",
2255        ] {
2256            assert!(
2257                envs.iter().any(|(env_key, _)| env_key == key),
2258                "profiling keeps debug info and symbols: missing {key} in {envs:?}"
2259            );
2260        }
2261
2262        assert!(
2263            BuildOptions::development(BuildProfile::Debug)
2264                .cargo_envs()
2265                .is_empty(),
2266            "plain debug runs the declared dev profile"
2267        );
2268    }
2269
2270    #[test]
2271    fn packaging_never_overrides_the_declared_profile() {
2272        for profile in [
2273            BuildProfile::Debug,
2274            BuildProfile::Optimized,
2275            BuildProfile::Release,
2276            BuildProfile::Profiling,
2277        ] {
2278            assert!(
2279                BuildOptions::packaging(profile).cargo_envs().is_empty(),
2280                "packaging {profile:?} must ship the declared profile"
2281            );
2282        }
2283    }
2284
2285    #[test]
2286    fn resolves_target_standard_library_without_guessing_hash() {
2287        let directory = tempdir().expect("temporary target libdir");
2288        let android_triple = triple("aarch64-linux-android");
2289        let expected = directory.path().join("libstd-1234567890abcdef.so");
2290        std::fs::write(&expected, []).expect("write test std library");
2291        std::fs::write(directory.path().join("libcore.rlib"), []).expect("write unrelated library");
2292
2293        assert_eq!(
2294            resolve_rust_standard_library_in(directory.path(), &android_triple)
2295                .expect("resolve dynamic std"),
2296            expected
2297        );
2298        assert_eq!(
2299            dynamic_library_file_name("waterui_dylib", &android_triple),
2300            "libwaterui_dylib.so"
2301        );
2302        assert_eq!(
2303            dynamic_library_file_name("waterui_dylib", &triple("x86_64-pc-windows-msvc")),
2304            "waterui_dylib.dll"
2305        );
2306    }
2307
2308    #[test]
2309    fn compile_progress_classifies_cargo_unit_lines() {
2310        assert_eq!(
2311            classify_compile_line("   Compiling serde v1.0.228"),
2312            CompileEvent::Unit {
2313                phase: "Compiling",
2314                name: "serde".to_string(),
2315                version: Some("1.0.228".to_string()),
2316            }
2317        );
2318        assert_eq!(
2319            classify_compile_line("   Compiling waterui-app v0.1.0 (/tmp/app)"),
2320            CompileEvent::Unit {
2321                phase: "Compiling",
2322                name: "waterui-app".to_string(),
2323                version: Some("0.1.0".to_string()),
2324            }
2325        );
2326        assert_eq!(
2327            classify_compile_line("    Checking libc v0.2.171"),
2328            CompileEvent::Unit {
2329                phase: "Checking",
2330                name: "libc".to_string(),
2331                version: Some("0.2.171".to_string()),
2332            }
2333        );
2334    }
2335
2336    #[test]
2337    fn compile_progress_keeps_non_unit_lines_verbatim() {
2338        assert_eq!(
2339            classify_compile_line("   Compiling 12 crates"),
2340            CompileEvent::Line("Compiling 12 crates".to_string())
2341        );
2342        assert_eq!(
2343            classify_compile_line("     Downloaded 300 crates (5.2 MB) in 1.23s"),
2344            CompileEvent::Line("Downloaded 300 crates (5.2 MB) in 1.23s".to_string())
2345        );
2346        assert_eq!(
2347            classify_compile_line(
2348                "    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.23s"
2349            ),
2350            CompileEvent::Finished(
2351                "Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.23s".to_string()
2352            )
2353        );
2354        assert_eq!(
2355            classify_compile_line("warning: unused import"),
2356            CompileEvent::Line("warning: unused import".to_string())
2357        );
2358    }
2359
2360    #[test]
2361    fn compile_progress_classifies_through_ansi_color() {
2362        // A user-forced `[term] color = "always"` or the CARGO_TERM_COLOR the
2363        // CLI sets for terminals wraps cargo's status words in escapes.
2364        let colored = "\u{1b}[0m\u{1b}[1m\u{1b}[32m   Compiling\u{1b}[0m serde v1.0.228";
2365        assert_eq!(
2366            classify_compile_line(colored),
2367            CompileEvent::Unit {
2368                phase: "Compiling",
2369                name: "serde".to_string(),
2370                version: Some("1.0.228".to_string()),
2371            }
2372        );
2373        let colored_finished =
2374            "\u{1b}[0m\u{1b}[1m\u{1b}[32m    Finished\u{1b}[0m `dev` profile in 1.23s";
2375        assert_eq!(
2376            classify_compile_line(colored_finished),
2377            CompileEvent::Finished(colored_finished.trim().to_string())
2378        );
2379    }
2380
2381    /// Two projects named `demo` in different directories generate crates
2382    /// whose package names differ by the project-root tag, so one shared
2383    /// Cargo target gives each its own uplifted artifact — and the build
2384    /// resolves it from Cargo's `compiler-artifact` report rather than a
2385    /// bare `<profile>/<name>` guess.
2386    #[test]
2387    fn same_named_projects_resolve_their_own_artifacts_in_one_shared_target() {
2388        use crate::project_model::project_types::{CrateName, generated_crate_name};
2389
2390        smol::block_on(async {
2391            let temporary = tempdir().expect("tempdir");
2392            let shared_target = temporary.path().join("shared-target");
2393            let demo = CrateName::try_from("demo").expect("crate name");
2394            let mut artifacts = Vec::new();
2395            for (directory, marker) in [("first", "first"), ("second", "second")] {
2396                let project_root = temporary.path().join(directory);
2397                let crate_dir = project_root.join("hydrolysis");
2398                std::fs::create_dir_all(crate_dir.join("src")).expect("crate dir");
2399                let package = generated_crate_name(&demo, "hydrolysis", &project_root);
2400                std::fs::write(
2401                    crate_dir.join("Cargo.toml"),
2402                    format!(
2403                        "[package]\nname = \"{package}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"
2404                    ),
2405                )
2406                .expect("manifest");
2407                std::fs::write(
2408                    crate_dir.join("src/main.rs"),
2409                    format!("fn main() {{ println!(\"{marker}\"); }}\n"),
2410                )
2411                .expect("main.rs");
2412
2413                let artifact = super::RustBuild::new(&crate_dir, Triple::host())
2414                    .with_target_dir(&shared_target)
2415                    .build_binary(package.as_str(), false)
2416                    .await
2417                    .expect("the generated crate builds");
2418                assert!(artifact.is_file(), "the reported artifact exists");
2419                artifacts.push(artifact);
2420            }
2421
2422            assert_ne!(
2423                artifacts[0], artifacts[1],
2424                "each same-named project resolves its own artifact"
2425            );
2426            for (artifact, marker) in artifacts.iter().zip(["first", "second"]) {
2427                let ran = std::process::Command::new(artifact)
2428                    .output()
2429                    .expect("the resolved artifact executes");
2430                assert_eq!(
2431                    String::from_utf8_lossy(&ran.stdout).trim(),
2432                    marker,
2433                    "the artifact is this project's binary, not the sibling's"
2434                );
2435            }
2436        });
2437    }
2438
2439    /// `reported_artifact` matches on the artifact's manifest path — the
2440    /// identity Cargo assigns the unit — and returns the file the message
2441    /// reports even when that path is the hash-suffixed `deps/` copy, so a
2442    /// sibling package's artifact in the same stream is never picked up.
2443    #[test]
2444    fn reported_artifact_selects_the_matching_manifests_file() {
2445        let temporary = tempdir().expect("tempdir");
2446        let crate_dir = temporary.path().join("demo-hydrolysis-deadbeef");
2447        std::fs::create_dir_all(&crate_dir).expect("crate dir");
2448        std::fs::write(crate_dir.join("Cargo.toml"), "[package]\n").expect("manifest");
2449        let manifest =
2450            dunce::canonicalize(crate_dir.join("Cargo.toml")).expect("canonical manifest");
2451        let reported = crate_dir.join("target/debug/deps/demo_hydrolysis_deadbeef-abc123.rlib");
2452        std::fs::create_dir_all(reported.parent().expect("deps dir")).expect("deps dir");
2453        std::fs::write(&reported, []).expect("reported artifact");
2454
2455        // The messages are serialized, never formatted: a `Path` must land in
2456        // the JSON as an escaped string, which `display()` cannot do on
2457        // Windows where paths carry backslashes.
2458        let artifact_json = |manifest: &std::path::Path, file: &std::path::Path, name: &str| {
2459            serde_json::json!({
2460                "reason": "compiler-artifact",
2461                "package_id": format!("path+file:///x#{name}@0.1.0"),
2462                "manifest_path": manifest,
2463                "target": {
2464                    "kind": ["lib"],
2465                    "crate_types": ["lib"],
2466                    "name": name,
2467                    "src_path": manifest.parent().expect("manifest dir").join("src/lib.rs"),
2468                    "edition": "2021",
2469                    "doc": true,
2470                    "doctest": true,
2471                    "test": true,
2472                },
2473                "profile": {
2474                    "opt_level": "0",
2475                    "debuginfo": 0,
2476                    "debug_assertions": true,
2477                    "overflow_checks": true,
2478                    "test": false,
2479                },
2480                "features": [],
2481                "filenames": [file],
2482                "executable": null,
2483                "fresh": true,
2484            })
2485            .to_string()
2486        };
2487
2488        let other_manifest = temporary.path().join("other").join("Cargo.toml");
2489        let other_file = temporary.path().join("other.rlib");
2490        let stdout = format!(
2491            "{}\n{}\n",
2492            artifact_json(&other_manifest, &other_file, "other"),
2493            artifact_json(&manifest, &reported, "demo_hydrolysis_deadbeef"),
2494        );
2495        let resolved = super::reported_artifact(
2496            stdout.as_bytes(),
2497            &crate_dir,
2498            CargoTarget::Lib,
2499            Some("rlib"),
2500        )
2501        .expect("the matching manifest's artifact resolves");
2502        assert_eq!(resolved, reported);
2503
2504        let foreign_only = artifact_json(&other_manifest, &other_file, "other");
2505        assert!(
2506            super::reported_artifact(
2507                foreign_only.as_bytes(),
2508                &crate_dir,
2509                CargoTarget::Lib,
2510                Some("rlib"),
2511            )
2512            .is_err(),
2513            "an artifact for another manifest is never selected"
2514        );
2515    }
2516
2517    /// A dependency's uplifted dylib is unhashed, so a `fresh` report does not
2518    /// prove the file is this source's — the dep-info beside it records the
2519    /// producing sources, and only a dep-info naming this unit's own manifest
2520    /// root clears it.
2521    #[test]
2522    fn stale_shared_dylib_packages_flags_a_foreign_written_artifact() {
2523        smol::block_on(async {
2524            let temporary = tempdir().expect("tempdir");
2525            let deps = temporary.path().join("debug/deps");
2526            std::fs::create_dir_all(&deps).expect("deps dir");
2527            let dylib = deps.join("libwaterui_dylib.so");
2528            std::fs::write(&dylib, []).expect("dylib");
2529
2530            // The manifest root carries a space so the dep-info fixture
2531            // exercises the `\ ` escape end to end: the written prerequisite
2532            // must still resolve to this root.
2533            let ours = temporary.path().join("our project");
2534            std::fs::create_dir_all(ours.join("src")).expect("our manifest dir");
2535            let manifest = ours.join("Cargo.toml");
2536            std::fs::write(&manifest, "").expect("manifest");
2537            let own_source = ours.join("src/lib.rs");
2538            std::fs::write(&own_source, "").expect("own source");
2539
2540            let artifact = |fresh: bool| {
2541                serde_json::json!({
2542                    "reason": "compiler-artifact",
2543                    "package_id": "path+file:///x#waterui-dylib@0.1.0",
2544                    "manifest_path": manifest,
2545                    "target": {
2546                        "kind": ["lib"],
2547                        "crate_types": ["dylib"],
2548                        "name": "waterui_dylib",
2549                        "src_path": own_source,
2550                        "edition": "2021",
2551                        "doc": true,
2552                        "doctest": true,
2553                        "test": true,
2554                    },
2555                    "profile": {
2556                        "opt_level": "0",
2557                        "debuginfo": 0,
2558                        "debug_assertions": true,
2559                        "overflow_checks": true,
2560                        "test": false,
2561                    },
2562                    "features": [],
2563                    "filenames": [dylib],
2564                    "executable": null,
2565                    "fresh": fresh,
2566                })
2567                .to_string()
2568            };
2569            let dep_info = deps.join("waterui_dylib.d");
2570
2571            // Dep-info rides in rustc's Makefile spelling: a literal space in
2572            // a path is `\ ` and every other byte is verbatim, so the fixture
2573            // writes real tempdir paths through the same escaping.
2574            let foreign = temporary.path().join("foreign");
2575            std::fs::create_dir_all(foreign.join("src")).expect("foreign source dir");
2576            let foreign_source = foreign.join("src/lib.rs");
2577            std::fs::write(&foreign_source, "").expect("foreign source");
2578            let dep_escape =
2579                |path: &std::path::Path| path.display().to_string().replace(' ', "\\ ");
2580            let write_dep_info = |source: &std::path::Path| {
2581                std::fs::write(
2582                    &dep_info,
2583                    format!("{}: {}\n", dep_escape(&dylib), dep_escape(source)),
2584                )
2585                .expect("dep-info");
2586            };
2587
2588            // A `fresh` unit whose dep-info names another source's checkout.
2589            write_dep_info(&foreign_source);
2590            let stale = super::stale_shared_dylib_packages(artifact(true).as_bytes())
2591                .await
2592                .expect("scan");
2593            assert_eq!(stale, ["waterui-dylib"]);
2594
2595            // The same file written by this unit's own source is trusted.
2596            write_dep_info(&own_source);
2597            let stale = super::stale_shared_dylib_packages(artifact(true).as_bytes())
2598                .await
2599                .expect("scan");
2600            assert!(stale.is_empty(), "our own artifact is never stale");
2601
2602            // A unit cargo just emitted needs no dep-info check at all.
2603            write_dep_info(&foreign_source);
2604            let stale = super::stale_shared_dylib_packages(artifact(false).as_bytes())
2605                .await
2606                .expect("scan");
2607            assert!(stale.is_empty(), "a non-fresh unit wrote the file itself");
2608        });
2609    }
2610
2611    /// Cargo's build-dir layout (nightly 1.100) writes a unit's dep-info in
2612    /// `build/<package>/<hash>/out/` beside its other outputs instead of
2613    /// `<profile>/deps/`; the unit's `.rmeta` names that directory. A fresh
2614    /// proc-macro unit — hashed, never uplifted, and on that layout without
2615    /// any dep-info the `deps/` convention could find — takes no part.
2616    #[test]
2617    fn stale_check_reads_build_dir_dep_info_and_skips_proc_macros() {
2618        smol::block_on(async {
2619            let temporary = tempdir().expect("tempdir");
2620            let profile = temporary.path().join("debug");
2621            let unit_dir = profile.join("build/waterui-dylib/0123456789abcdef/out");
2622            std::fs::create_dir_all(&unit_dir).expect("unit dir");
2623            let dylib = profile.join("libwaterui_dylib.so");
2624            std::fs::write(&dylib, []).expect("dylib");
2625            let rmeta = unit_dir.join("libwaterui_dylib.rmeta");
2626            std::fs::write(&rmeta, []).expect("rmeta");
2627
2628            let ours = temporary.path().join("ours");
2629            std::fs::create_dir_all(ours.join("src")).expect("our manifest dir");
2630            let manifest = ours.join("Cargo.toml");
2631            std::fs::write(&manifest, "").expect("manifest");
2632            let foreign = temporary.path().join("foreign/src/lib.rs");
2633            std::fs::create_dir_all(foreign.parent().expect("parent")).expect("foreign dir");
2634            std::fs::write(&foreign, []).expect("foreign source");
2635            std::fs::write(
2636                unit_dir.join("waterui_dylib.d"),
2637                format!("{}: {}\n", dylib.display(), foreign.display()),
2638            )
2639            .expect("dep-info");
2640
2641            let unit = |name: &str, crate_type: &str, filenames: Vec<&std::path::Path>| {
2642                serde_json::json!({
2643                    "reason": "compiler-artifact",
2644                    "package_id": format!("path+file:///x#{name}@0.1.0"),
2645                    "manifest_path": manifest,
2646                    "target": {
2647                        "kind": [if crate_type == "proc-macro" { "proc-macro" } else { "lib" }],
2648                        "crate_types": [crate_type],
2649                        "name": name.replace('-', "_"),
2650                        "src_path": ours.join("src/lib.rs"),
2651                        "edition": "2021",
2652                        "doc": true,
2653                        "doctest": true,
2654                        "test": true,
2655                    },
2656                    "profile": {
2657                        "opt_level": "0",
2658                        "debuginfo": 0,
2659                        "debug_assertions": true,
2660                        "overflow_checks": true,
2661                        "test": false,
2662                    },
2663                    "features": [],
2664                    "filenames": filenames,
2665                    "executable": null,
2666                    "fresh": true,
2667                })
2668                .to_string()
2669            };
2670            // The proc-macro's dylib exists nowhere on disk and has no
2671            // dep-info; only the dylib unit is examined, and its dep-info is
2672            // found through the `.rmeta` sibling's directory.
2673            let macro_dylib = unit_dir.join("libthiserror_impl-0123456789abcdef.so");
2674            let stdout = format!(
2675                "{}\n{}\n",
2676                unit("thiserror-impl", "proc-macro", vec![&macro_dylib]),
2677                unit("waterui-dylib", "dylib", vec![&dylib, &rmeta]),
2678            );
2679            let stale = super::stale_shared_dylib_packages(stdout.as_bytes())
2680                .await
2681                .expect("scan");
2682            assert_eq!(stale, ["waterui-dylib"]);
2683
2684            // Without any dep-info the check fails loudly rather than
2685            // trusting the shared artifact.
2686            std::fs::remove_file(unit_dir.join("waterui_dylib.d")).expect("remove dep-info");
2687            let error = super::stale_shared_dylib_packages(stdout.as_bytes())
2688                .await
2689                .expect_err("a fresh dylib without dep-info is an error");
2690            assert!(
2691                error.to_string().contains("no dep-info was found"),
2692                "{error}"
2693            );
2694        });
2695    }
2696
2697    /// Dep-info prerequisites arrive in Makefile spelling: `\ ` escapes a
2698    /// literal space, a `\` at end of line continues the rule, and a Windows
2699    /// drive-letter colon is data — only the first `": "` separates the
2700    /// target. rustc escapes nothing else, so `$$` and `\\` stay verbatim.
2701    #[test]
2702    fn dep_info_prerequisites_unescape_spaces_and_join_continued_rules() {
2703        let contents = concat!(
2704            "C:\\out\\app.dll: C:\\work\\my\\ app\\src\\lib.rs \\\n",
2705            "    C:\\work\\my\\ app\\build.rs C:\\work\\cost$$.rs\n",
2706            "\n",
2707            "C:\\work\\my\\ app\\src\\lib.rs:\n",
2708        );
2709        assert_eq!(
2710            super::dep_info_prerequisites(contents),
2711            vec![
2712                PathBuf::from("C:\\work\\my app\\src\\lib.rs"),
2713                PathBuf::from("C:\\work\\my app\\build.rs"),
2714                PathBuf::from("C:\\work\\cost$$.rs"),
2715            ]
2716        );
2717    }
2718
2719    #[test]
2720    fn static_packaging_removes_only_staged_android_runtime_libraries() {
2721        smol::block_on(async {
2722            let directory = tempdir().expect("temporary Android runtime directory");
2723            let android_triple = triple("aarch64-linux-android");
2724            for file_name in [
2725                "libwaterui_dylib.so",
2726                "libstd-old.so",
2727                "libwaterui_app.so",
2728                "libc++_shared.so",
2729            ] {
2730                std::fs::write(directory.path().join(file_name), [])
2731                    .expect("write staged runtime test file");
2732            }
2733
2734            RustDynamicLibraries::remove_staged(directory.path(), &android_triple)
2735                .await
2736                .expect("remove shared Rust runtime libraries");
2737
2738            assert!(!directory.path().join("libwaterui_dylib.so").exists());
2739            assert!(!directory.path().join("libstd-old.so").exists());
2740            assert!(directory.path().join("libwaterui_app.so").exists());
2741            assert!(directory.path().join("libc++_shared.so").exists());
2742        });
2743    }
2744}