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