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