Skip to main content

waterui_cli/workflows/
build.rs

1//! Build system
2
3use std::{
4    ffi::OsString,
5    path::{Path, PathBuf},
6};
7
8use eyre::bail;
9use futures_util::StreamExt as _;
10use smol::{process::Command, unblock};
11use target_lexicon::{Environment, OperatingSystem, Triple};
12
13use crate::project::Project;
14use crate::utils::{command, run_command};
15
16/// Get the dynamic library extension for a target triple.
17#[must_use]
18pub const fn lib_extension_for_triple(triple: &Triple) -> &'static str {
19    match triple.operating_system {
20        OperatingSystem::Darwin(_)
21        | OperatingSystem::MacOSX { .. }
22        | OperatingSystem::IOS(_)
23        | OperatingSystem::TvOS(_)
24        | OperatingSystem::WatchOS(_)
25        | OperatingSystem::VisionOS(_) => "dylib",
26        OperatingSystem::Windows => "dll",
27        // Linux, Android, and most other Unix-like targets use .so.
28        _ => "so",
29    }
30}
31
32/// Resolve the Rust standard-library directory for a target triple.
33///
34/// # Errors
35/// Returns an error if rustc cannot resolve an existing target library directory.
36pub async fn rust_target_libdir(triple: &Triple) -> eyre::Result<PathBuf> {
37    let target = triple.to_string();
38    let output = run_command(
39        "rustc",
40        ["--print", "target-libdir", "--target", target.as_str()],
41    )
42    .await?;
43    let libdir = output.trim();
44    if libdir.is_empty() {
45        bail!("`rustc --print target-libdir --target {target}` returned an empty path");
46    }
47    let path = PathBuf::from(libdir);
48    if !path.is_dir() {
49        bail!(
50            "Rust target libdir does not exist for dynamic linking: {}",
51            path.display()
52        );
53    }
54    Ok(path)
55}
56
57/// The Cargo target a build selects.
58///
59/// A crate-type override only has meaning for the library target, so carrying the
60/// target kind in the type keeps `cargo rustc -- --crate-type` from ever reaching a
61/// binary build.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum CargoTarget<'a> {
64    /// The crate's library target.
65    Lib,
66    /// One named binary target.
67    Binary(&'a str),
68}
69
70impl<'a> CargoTarget<'a> {
71    fn cargo_args(self) -> Vec<&'a str> {
72        match self {
73            Self::Lib => vec!["--lib"],
74            Self::Binary(name) => vec!["--bin", name],
75        }
76    }
77
78    const fn accepts_crate_type_override(self) -> bool {
79        matches!(self, Self::Lib)
80    }
81}
82
83/// Selects how Rust dependencies are linked into a native application.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum RustLinkage {
86    /// Link the `WaterUI` runtime into the application archive.
87    Static,
88    /// Link the application and loadable modules against one shared `WaterUI` runtime.
89    SharedRuntime,
90}
91
92/// Configure a Cargo invocation that compiles one of `WaterUI`'s generated crates.
93///
94/// Incremental compilation is off for every one of these builds, unconditionally.
95/// `-C incremental` is part of a unit's profile, the profile feeds Cargo's `-C metadata`,
96/// and `-C metadata` is mangled into every symbol name. Two builds in the same flow that
97/// disagree about incremental therefore produce runtimes whose symbols cannot resolve
98/// against each other: a preview support app built one way and a preview module built the
99/// other share a `libwaterui_dylib.dylib` filename and roughly 33,000 mismatched symbols,
100/// and the module fails to `dlopen` on a missing generic instantiation.
101///
102/// The choice is unconditional precisely so it cannot depend on an environmental accident
103/// such as whether a machine has `sccache` installed. Little is given up: every generated
104/// backend builds into one shared target directory where Cargo already reuses each unit's
105/// compiled artifact across backends and feature variants — while an `sccache` entry,
106/// which requires incremental to be off, covers what that sharing cannot.
107pub fn configure_generated_crate_compilation(command: &mut Command) {
108    command.env("CARGO_INCREMENTAL", "0");
109}
110
111/// Dynamic Rust libraries required by a shared-runtime development build.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct RustDynamicLibraries {
114    waterui: PathBuf,
115    standard_library: PathBuf,
116    triple: Triple,
117}
118
119impl RustDynamicLibraries {
120    /// Resolve the shared `WaterUI` runtime and target Rust standard library.
121    ///
122    /// # Errors
123    /// Returns an error when either required dynamic library is absent or ambiguous.
124    pub async fn resolve(lib_dir: &Path, triple: &Triple) -> eyre::Result<Self> {
125        let file_name = dynamic_library_file_name("waterui_dylib", triple);
126        // Cargo emits a dependency's final dylib artifact in `deps/` on stable
127        // and at the profile directory root on current nightlies; accept both.
128        let waterui = [
129            lib_dir.join(&file_name),
130            lib_dir.join("deps").join(&file_name),
131        ]
132        .into_iter()
133        .find(|path| path.is_file())
134        .ok_or_else(|| {
135            eyre::eyre!(
136                "Shared WaterUI runtime was not built at {}",
137                lib_dir.join("deps").join(&file_name).display()
138            )
139        })?;
140
141        let target_libdir = rust_target_libdir(triple).await?;
142        let resolution_triple = triple.clone();
143        let standard_library =
144            unblock(move || resolve_rust_standard_library_in(&target_libdir, &resolution_triple))
145                .await?;
146
147        Ok(Self {
148            waterui,
149            standard_library,
150            triple: triple.clone(),
151        })
152    }
153
154    /// Shared `WaterUI` runtime path.
155    #[must_use]
156    pub fn waterui(&self) -> &Path {
157        &self.waterui
158    }
159
160    /// Target Rust standard-library dynamic library path.
161    #[must_use]
162    pub fn standard_library(&self) -> &Path {
163        &self.standard_library
164    }
165
166    /// Iterate over every library that must be staged with the application.
167    pub fn iter(&self) -> impl Iterator<Item = &Path> {
168        [self.waterui(), self.standard_library()].into_iter()
169    }
170
171    /// Copy all required dynamic libraries into a runtime search directory.
172    ///
173    /// Staging goes through the reflinking copy so a shared runtime that every build
174    /// output needs a copy of costs one set of extents instead of one full copy per
175    /// destination. A copy-on-write clone is also the only sharing that is safe here:
176    /// these staged libraries are rewritten in place later (`install_name_tool`), so
177    /// hard links would corrupt the Cargo artifact they were linked to.
178    ///
179    /// # Errors
180    /// Returns an error when the destination cannot be created or a library cannot be copied.
181    pub async fn stage(&self, destination: &Path) -> eyre::Result<()> {
182        smol::fs::create_dir_all(destination).await?;
183        Self::remove_staged(destination, &self.triple).await?;
184        for source in self.iter() {
185            let file_name = source.file_name().ok_or_else(|| {
186                eyre::eyre!(
187                    "Dynamic library path has no file name: {}",
188                    source.display()
189                )
190            })?;
191            crate::utils::copy_file(source, destination.join(file_name)).await?;
192        }
193        Ok(())
194    }
195
196    /// Remove shared-runtime libraries left by an earlier development build.
197    ///
198    /// # Errors
199    /// Returns an error when the destination cannot be read or a matching library cannot be removed.
200    pub async fn remove_staged(destination: &Path, triple: &Triple) -> eyre::Result<()> {
201        if !destination.is_dir() {
202            return Ok(());
203        }
204
205        let waterui = dynamic_library_file_name("waterui_dylib", triple);
206        let (standard_library_prefix, extension) =
207            if triple.operating_system == OperatingSystem::Windows {
208                ("std-", "dll")
209            } else {
210                ("libstd-", lib_extension_for_triple(triple))
211            };
212        let mut entries = smol::fs::read_dir(destination).await?;
213        while let Some(entry) = entries.next().await {
214            let entry = entry?;
215            let file_name = entry.file_name();
216            let file_name = file_name.to_string_lossy();
217            if file_name == waterui
218                || (file_name.starts_with(standard_library_prefix)
219                    && entry.path().extension().and_then(|value| value.to_str()) == Some(extension))
220            {
221                smol::fs::remove_file(entry.path()).await?;
222            }
223        }
224        Ok(())
225    }
226}
227
228fn dynamic_library_file_name(crate_name: &str, triple: &Triple) -> String {
229    if triple.operating_system == OperatingSystem::Windows {
230        format!("{crate_name}.dll")
231    } else {
232        format!("lib{crate_name}.{}", lib_extension_for_triple(triple))
233    }
234}
235
236fn resolve_rust_standard_library_in(libdir: &Path, triple: &Triple) -> eyre::Result<PathBuf> {
237    let (prefix, extension) = if triple.operating_system == OperatingSystem::Windows {
238        ("std-", "dll")
239    } else {
240        ("libstd-", lib_extension_for_triple(triple))
241    };
242    let entries = std::fs::read_dir(libdir)?
243        .map(|entry| entry.map(|entry| entry.path()))
244        .collect::<std::io::Result<Vec<_>>>()?;
245    let mut matches = entries
246        .into_iter()
247        .filter(|path| {
248            path.file_name()
249                .and_then(|name| name.to_str())
250                .is_some_and(|name| {
251                    name.starts_with(prefix)
252                        && path.extension().and_then(|extension| extension.to_str())
253                            == Some(extension)
254                })
255        })
256        .collect::<Vec<_>>();
257    matches.sort_unstable();
258    match matches.as_slice() {
259        [path] => Ok(path.clone()),
260        [] => {
261            bail!(
262                "Rust target libdir {} contains no dynamic standard library for {triple}",
263                libdir.display()
264            );
265        }
266        _ => {
267            bail!(
268                "Rust target libdir {} contains multiple dynamic standard libraries for {triple}: {}",
269                libdir.display(),
270                matches
271                    .iter()
272                    .map(|path| path.display().to_string())
273                    .collect::<Vec<_>>()
274                    .join(", ")
275            );
276        }
277    }
278}
279
280/// Represents a Rust build for a specific target triple.
281#[derive(Debug, Clone)]
282pub struct RustBuild {
283    path: PathBuf,
284    triple: Triple,
285    project: Option<Project>,
286    /// Explicit Cargo target directory for cross-project artifact reuse.
287    target_dir: Option<PathBuf>,
288    /// Optional path to sccache for compilation caching.
289    sccache_path: Option<PathBuf>,
290    /// Cargo features to enable.
291    features: Vec<String>,
292    /// Override the final crate type built by `cargo rustc`.
293    crate_type_override: Option<String>,
294    /// Extra rustc flags to append via `RUSTFLAGS`.
295    rustc_flags: Vec<String>,
296    /// Rustc flags that apply to the final crate only, via `cargo rustc -- <flags>`.
297    ///
298    /// `RUSTFLAGS` is hashed into every dependency unit's fingerprint, so a flag that
299    /// only matters when linking the final artifact — an `-rpath` link argument, say —
300    /// must not go through [`Self::with_rustc_flag`]: two builds sharing one target
301    /// directory that disagree about `RUSTFLAGS` invalidate each other's entire
302    /// dependency graph. Trailing `cargo rustc` arguments reach only the selected
303    /// target's own compilation and leave dependency fingerprints alone.
304    final_rustc_args: Vec<String>,
305    /// Extra environment variables to set for the cargo build process.
306    envs: Vec<(String, OsString)>,
307}
308
309/// The optimization/debug-info trade-off a Cargo build selects.
310///
311/// The variants are realized on top of the workspace's declared `dev` and
312/// `release` profiles through `CARGO_PROFILE_*` overrides, so they work on
313/// user projects and generated crates alike without manifest changes.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
315pub enum BuildProfile {
316    /// The `dev` profile as declared: unoptimized, with debug info.
317    #[default]
318    Debug,
319    /// The `dev` profile lifted to a light optimization level with full debug
320    /// info — the `water run` default for self-drawn backends, whose
321    /// per-frame cost sits in rendering dependencies rather than in app code.
322    Optimized,
323    /// The `release` profile at full speed optimization, without debug info.
324    Release,
325    /// The `release` profile at full speed optimization, with debug info and
326    /// symbols kept so a profiler can symbolicate the recording.
327    Profiling,
328}
329
330impl BuildProfile {
331    /// Whether the build uses Cargo's `release` profile — artifacts land in
332    /// the `release/` profile directory and `cargo` gets `--release`.
333    #[must_use]
334    pub const fn is_release(self) -> bool {
335        matches!(self, Self::Release | Self::Profiling)
336    }
337
338    /// Whether the profile keeps the development-run shape: the `include_web!`
339    /// dev server may serve mounts and the artifact packages as debuggable.
340    #[must_use]
341    pub const fn is_development(self) -> bool {
342        !self.is_release()
343    }
344
345    /// `CARGO_PROFILE_*` overrides realizing this profile on the workspace's
346    /// declared `dev`/`release` profiles.
347    ///
348    /// These compose with `profile.*.package."*"` overrides a manifest may
349    /// declare: the env sets the profile's base value, so generated crates —
350    /// whose `dev` profile already lifts dependencies to `opt-level 2` — keep
351    /// that dependency optimization while the base rises to cover the root
352    /// crate and the per-unit debug-assertion switches the override table
353    /// does not mention.
354    fn development_envs(self) -> Vec<(String, OsString)> {
355        let entries: &[(&str, &str)] = match self {
356            Self::Debug => &[],
357            Self::Optimized => &[
358                ("CARGO_PROFILE_DEV_OPT_LEVEL", "1"),
359                ("CARGO_PROFILE_DEV_DEBUG", "true"),
360                ("CARGO_PROFILE_DEV_DEBUG_ASSERTIONS", "false"),
361                ("CARGO_PROFILE_DEV_OVERFLOW_CHECKS", "false"),
362            ],
363            Self::Release => &[("CARGO_PROFILE_RELEASE_OPT_LEVEL", "3")],
364            Self::Profiling => &[
365                ("CARGO_PROFILE_RELEASE_OPT_LEVEL", "3"),
366                ("CARGO_PROFILE_RELEASE_DEBUG", "true"),
367                ("CARGO_PROFILE_RELEASE_STRIP", "none"),
368            ],
369        };
370        entries
371            .iter()
372            .map(|(key, value)| ((*key).to_string(), OsString::from(*value)))
373            .collect()
374    }
375}
376
377/// Options for building Rust libraries.
378#[derive(Debug, Clone)]
379pub struct BuildOptions {
380    profile: BuildProfile,
381    output_dir: Option<std::path::PathBuf>,
382    /// Optional path to sccache for compilation caching.
383    sccache_path: Option<std::path::PathBuf>,
384    /// Optional target triple override.
385    target_triple: Option<Triple>,
386    /// Rust runtime linkage used by the final native application.
387    linkage: RustLinkage,
388    /// Whether `include_web!` mounts are dev-server-served and skipped when
389    /// the build stages assets (Hydrolysis stages at build time).
390    dev_server: bool,
391    /// `CARGO_PROFILE_*` overrides applied to the cargo invocation.
392    cargo_envs: Vec<(String, OsString)>,
393}
394
395impl BuildOptions {
396    /// Create options for a development build that uses the shared Rust runtime.
397    ///
398    /// Development runs want wall-clock speed: `Release` and `Profiling` force
399    /// `opt-level 3` rather than the size-optimized `opt-level "z"` the
400    /// packaging profile declares, and `Optimized`/`Profiling`/`Release` all
401    /// carry `CARGO_PROFILE_*` overrides the cargo invocation applies.
402    #[must_use]
403    pub fn development(profile: BuildProfile) -> Self {
404        Self {
405            profile,
406            output_dir: None,
407            sccache_path: None,
408            target_triple: None,
409            linkage: RustLinkage::SharedRuntime,
410            dev_server: false,
411            cargo_envs: profile.development_envs(),
412        }
413    }
414
415    /// Link the Rust runtime in, whatever the caller asked for.
416    ///
417    /// A platform whose loader cannot accept the toolchain's prebuilt runtime
418    /// says so here rather than at the link step, so that the target directory
419    /// and the staged libraries agree with what is actually built.
420    #[must_use]
421    pub const fn with_static_runtime(mut self) -> Self {
422        self.linkage = RustLinkage::Static;
423        self
424    }
425
426    /// Create options for a self-contained package build.
427    ///
428    /// A packaged artifact builds under the profile the workspace declares —
429    /// no `CARGO_PROFILE_*` overrides: the release profile's size tuning
430    /// (`opt-level "z"`, symbol stripping) is the shipped configuration.
431    #[must_use]
432    pub const fn packaging(profile: BuildProfile) -> Self {
433        Self {
434            profile,
435            output_dir: None,
436            sccache_path: None,
437            target_triple: None,
438            linkage: RustLinkage::Static,
439            dev_server: false,
440            cargo_envs: Vec::new(),
441        }
442    }
443
444    /// Whether the build uses Cargo's `release` profile.
445    #[must_use]
446    pub const fn is_release(&self) -> bool {
447        self.profile.is_release()
448    }
449
450    /// The selected build profile.
451    #[must_use]
452    pub const fn profile(&self) -> BuildProfile {
453        self.profile
454    }
455
456    /// `CARGO_PROFILE_*` overrides the cargo invocation applies.
457    #[must_use]
458    pub fn cargo_envs(&self) -> &[(String, OsString)] {
459        &self.cargo_envs
460    }
461
462    /// Mark web mounts as dev-server-served for asset staging this build does.
463    #[must_use]
464    pub const fn with_dev_server(mut self, dev_server: bool) -> Self {
465        self.dev_server = dev_server;
466        self
467    }
468
469    /// Whether web mounts are dev-server-served and skipped during staging.
470    #[must_use]
471    pub const fn uses_dev_server(&self) -> bool {
472        self.dev_server
473    }
474
475    /// Get the output directory, if specified
476    #[must_use]
477    pub fn output_dir(&self) -> Option<&std::path::Path> {
478        self.output_dir.as_deref()
479    }
480
481    /// Set the output directory where built libraries should be copied
482    #[must_use]
483    pub fn with_output_dir(mut self, output_dir: impl Into<std::path::PathBuf>) -> Self {
484        self.output_dir = Some(output_dir.into());
485        self
486    }
487
488    /// Get the sccache path, if configured
489    #[must_use]
490    pub fn sccache_path(&self) -> Option<&std::path::Path> {
491        self.sccache_path.as_deref()
492    }
493
494    /// Set the sccache path for compilation caching.
495    ///
496    /// When set, `RUSTC_WRAPPER` will be configured to use sccache,
497    /// which can significantly improve build times by caching compiled artifacts.
498    #[must_use]
499    pub fn with_sccache(mut self, sccache_path: impl Into<std::path::PathBuf>) -> Self {
500        self.sccache_path = Some(sccache_path.into());
501        self
502    }
503
504    /// Get the explicit target triple override, if configured.
505    #[must_use]
506    pub const fn target_triple(&self) -> Option<&Triple> {
507        self.target_triple.as_ref()
508    }
509
510    /// Override the target triple used for compilation.
511    #[must_use]
512    pub fn with_target_triple(mut self, target_triple: Triple) -> Self {
513        self.target_triple = Some(target_triple);
514        self
515    }
516
517    /// Get the selected Rust runtime linkage.
518    #[must_use]
519    pub const fn linkage(&self) -> RustLinkage {
520        self.linkage
521    }
522}
523
524/// Errors that can occur during the Rust build process.
525#[derive(Debug, thiserror::Error)]
526pub enum RustBuildError {
527    /// Failed to execute cargo build.
528    #[error("Failed to execute cargo build: {0}")]
529    FailToExecuteCargoBuild(std::io::Error),
530
531    /// Cargo executed but failed to build the Rust library.
532    #[error("Failed to build Rust library: {0}")]
533    FailToBuildRustLibrary(std::io::Error),
534}
535
536impl RustBuild {
537    /// Create a new rust build for the given path and target triple.
538    pub fn new(path: impl AsRef<Path>, triple: Triple) -> Self {
539        Self {
540            path: path.as_ref().to_path_buf(),
541            triple,
542            project: None,
543            target_dir: None,
544            sccache_path: None,
545            features: Vec::new(),
546            crate_type_override: None,
547            rustc_flags: Vec::new(),
548            final_rustc_args: Vec::new(),
549            envs: Vec::new(),
550        }
551    }
552
553    pub(crate) fn with_project(mut self, project: &Project) -> Self {
554        self.project = Some(project.clone());
555        self
556    }
557
558    /// Use an explicit Cargo target directory.
559    #[must_use]
560    pub fn with_target_dir(mut self, target_dir: impl Into<PathBuf>) -> Self {
561        self.target_dir = Some(target_dir.into());
562        self
563    }
564
565    /// Set the sccache path for compilation caching.
566    ///
567    /// When set, `RUSTC_WRAPPER` will be configured to use sccache,
568    /// which can significantly improve incremental build times.
569    #[must_use]
570    pub fn with_sccache(mut self, sccache_path: PathBuf) -> Self {
571        self.sccache_path = Some(sccache_path);
572        self
573    }
574
575    /// Add a Cargo feature to enable during the build.
576    ///
577    /// Features are passed to cargo via `--features`.
578    #[must_use]
579    pub fn with_feature(mut self, feature: impl Into<String>) -> Self {
580        self.features.push(feature.into());
581        self
582    }
583
584    /// Add multiple Cargo features to enable during the build.
585    #[must_use]
586    pub fn with_features(mut self, features: impl IntoIterator<Item = impl Into<String>>) -> Self {
587        self.features.extend(features.into_iter().map(Into::into));
588        self
589    }
590
591    /// Cargo features this build passes via `--features`.
592    #[must_use]
593    pub fn features(&self) -> &[String] {
594        &self.features
595    }
596
597    /// Add a rustc flag to the build via `RUSTFLAGS`.
598    #[must_use]
599    pub fn with_rustc_flag(mut self, flag: impl Into<String>) -> Self {
600        self.rustc_flags.push(flag.into());
601        self
602    }
603
604    /// Add a rustc flag that applies to the final crate only.
605    ///
606    /// The flag is passed as a trailing `cargo rustc` argument instead of through
607    /// `RUSTFLAGS`, so dependency unit fingerprints stay identical across builds that
608    /// differ only in how their final artifact links. See the field documentation on
609    /// `final_rustc_args` for why link arguments must take this route.
610    #[must_use]
611    pub fn with_final_rustc_arg(mut self, flag: impl Into<String>) -> Self {
612        self.final_rustc_args.push(flag.into());
613        self
614    }
615
616    /// Prefer dynamic Rust dependencies and emit loader search paths for them.
617    #[must_use]
618    pub fn with_preferred_dynamic_linking(self) -> Self {
619        self.with_rustc_flag("-Cprefer-dynamic")
620            .with_rustc_flag("-Crpath")
621    }
622
623    /// Configure this build for the selected Rust runtime linkage.
624    ///
625    /// A shared-runtime development build enables the project's `dev` feature (which
626    /// resolves the shared `waterui-dylib` runtime), prefers dynamic linking, and —
627    /// when the platform's loader needs one — embeds a loader search path into the
628    /// final artifact only. A static packaging build needs none of this.
629    #[must_use]
630    pub fn with_linkage(
631        self,
632        linkage: RustLinkage,
633        development_feature: &str,
634        loader_search_path: Option<&str>,
635    ) -> Self {
636        if linkage == RustLinkage::Static {
637            return self;
638        }
639        let build = self
640            .with_feature(development_feature)
641            .with_preferred_dynamic_linking();
642        match loader_search_path {
643            Some(path) => build.with_final_rustc_arg(format!("-Clink-arg=-Wl,-rpath,{path}")),
644            None => build,
645        }
646    }
647
648    /// Override the library crate type passed to `rustc`.
649    #[must_use]
650    pub fn with_crate_type_override(mut self, crate_type: impl Into<String>) -> Self {
651        self.crate_type_override = Some(crate_type.into());
652        self
653    }
654
655    /// Add an environment variable for the cargo build process.
656    #[must_use]
657    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<OsString>) -> Self {
658        self.envs.push((key.into(), value.into()));
659        self
660    }
661
662    /// Add multiple environment variables for the cargo build process.
663    #[must_use]
664    pub fn with_envs(mut self, envs: impl IntoIterator<Item = (String, OsString)>) -> Self {
665        self.envs.extend(envs);
666        self
667    }
668
669    /// Get the target triple for this build.
670    #[must_use]
671    pub const fn triple(&self) -> &Triple {
672        &self.triple
673    }
674
675    /// Build rust library in development mode.
676    ///
677    /// Will produce debug symbols and less optimizations for faster builds.
678    ///
679    /// Return the path to the built library.
680    ///
681    /// # Errors
682    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
683    /// - `RustBuildError::FailToBuildRustLibrary`: If there was an error building the Rust library.
684    pub async fn dev_build(&self) -> Result<PathBuf, RustBuildError> {
685        self.build_lib(false).await
686    }
687
688    /// Build rust library in release mode.
689    ///
690    /// Return the directory path containing the built library.
691    ///
692    /// # Errors
693    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
694    /// - `RustBuildError::FailToBuildRustLibrary`: If there was an error building the Rust library.
695    pub async fn release_build(&self) -> Result<PathBuf, RustBuildError> {
696        self.build_lib(true).await
697    }
698
699    /// Build a library with the specified crate type.
700    ///
701    /// Return the directory path containing the built library.
702    ///
703    /// # Errors
704    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
705    /// - `RustBuildError::FailToBuildRustLibrary`: If there was an error building the Rust library.
706    pub async fn build_lib(&self, release: bool) -> Result<PathBuf, RustBuildError> {
707        self.build_inner(release, CargoTarget::Lib).await
708    }
709
710    /// Build a dynamic library (cdylib) and return the full path to the dylib file.
711    ///
712    /// This is a convenience method that builds the library and computes the full
713    /// path to the resulting dylib file based on the crate name and target triple.
714    ///
715    /// # Errors
716    /// - `RustBuildError::FailToExecuteCargoBuild`: If there was an error executing the cargo build command.
717    /// - `RustBuildError::FailToBuildRustLibrary`: If the library was not found after building.
718    pub async fn build_dylib(
719        &self,
720        crate_name: &str,
721        release: bool,
722    ) -> Result<PathBuf, RustBuildError> {
723        let lib_dir = self.build_inner(release, CargoTarget::Lib).await?;
724
725        let lib_name = crate_name.replace('-', "_");
726        let ext = lib_extension_for_triple(&self.triple);
727        let dylib_path = lib_dir.join(format!("lib{lib_name}.{ext}"));
728
729        if !dylib_path.exists() {
730            return Err(RustBuildError::FailToBuildRustLibrary(std::io::Error::new(
731                std::io::ErrorKind::NotFound,
732                format!(
733                    "Dynamic library not found at {} after cargo build",
734                    dylib_path.display()
735                ),
736            )));
737        }
738
739        Ok(dylib_path)
740    }
741
742    /// Builds one named binary and returns its full output path.
743    ///
744    /// # Errors
745    ///
746    /// Returns an error when Cargo fails or the expected binary is missing.
747    pub async fn build_binary(
748        &self,
749        binary_name: &str,
750        release: bool,
751    ) -> Result<PathBuf, RustBuildError> {
752        let output_dir = self
753            .build_inner(release, CargoTarget::Binary(binary_name))
754            .await?;
755        let binary_file_name = if self.triple.operating_system == OperatingSystem::Windows {
756            format!("{binary_name}.exe")
757        } else {
758            binary_name.to_string()
759        };
760        let binary_path = output_dir.join(binary_file_name);
761        if !binary_path.is_file() {
762            return Err(RustBuildError::FailToBuildRustLibrary(std::io::Error::new(
763                std::io::ErrorKind::NotFound,
764                format!(
765                    "Binary not found at {} after cargo build",
766                    binary_path.display()
767                ),
768            )));
769        }
770        Ok(binary_path)
771    }
772
773    /// Compute the expected dylib output path without building.
774    ///
775    /// This uses `cargo metadata` to resolve the target directory to avoid assuming
776    /// a fixed `target/` path.
777    ///
778    /// # Errors
779    /// Returns an error if Cargo metadata cannot be read.
780    pub async fn dylib_path(
781        &self,
782        crate_name: &str,
783        release: bool,
784    ) -> Result<PathBuf, RustBuildError> {
785        let lib_dir = self.lib_output_dir(release).await?;
786        let lib_name = crate_name.replace('-', "_");
787        let ext = lib_extension_for_triple(&self.triple);
788        Ok(lib_dir.join(format!("lib{lib_name}.{ext}")))
789    }
790
791    /// Return target directory path
792    async fn build_inner(
793        &self,
794        release: bool,
795        cargo_target: CargoTarget<'_>,
796    ) -> Result<PathBuf, RustBuildError> {
797        let mut output = self.cargo_build_output(release, cargo_target).await?;
798
799        if !output.status.success() {
800            let mut combined = combined_build_output(&output);
801
802            // Handle stale CMake generator caches (e.g. Unix Makefiles vs Ninja)
803            // by cleaning crate-local CMake build dirs and retrying once.
804            if should_retry_after_cmake_generator_mismatch(&combined)
805                && self.clean_stale_cmake_build_dirs().await?
806            {
807                output = self.cargo_build_output(release, cargo_target).await?;
808                combined = combined_build_output(&output);
809            }
810
811            if !output.status.success() && should_auto_install_meson(&combined) {
812                match ensure_meson_installed_for_build().await {
813                    Ok(()) => {
814                        output = self.cargo_build_output(release, cargo_target).await?;
815                    }
816                    Err(install_err) => {
817                        return Err(RustBuildError::FailToBuildRustLibrary(
818                            std::io::Error::other(format!(
819                                "Cargo build failed and meson appears missing.\n\
820Automatic meson installation failed: {install_err}\n\n{combined}"
821                            )),
822                        ));
823                    }
824                }
825            }
826        }
827
828        if !output.status.success() {
829            let combined = combined_build_output(&output);
830            return Err(RustBuildError::FailToBuildRustLibrary(
831                std::io::Error::other(format!("Cargo build failed:\n{combined}")),
832            ));
833        }
834
835        self.lib_output_dir(release).await
836    }
837
838    async fn clean_stale_cmake_build_dirs(&self) -> Result<bool, RustBuildError> {
839        let target_dir = self.target_directory().await?;
840        let triple = self.triple.to_string();
841
842        let removed = unblock(move || {
843            let mut removed = 0usize;
844            removed +=
845                remove_cmake_build_dirs_in(&target_dir.join(&triple).join("debug").join("build"))?;
846            removed += remove_cmake_build_dirs_in(
847                &target_dir.join(&triple).join("release").join("build"),
848            )?;
849            Ok::<usize, std::io::Error>(removed)
850        })
851        .await
852        .map_err(|error| {
853            RustBuildError::FailToBuildRustLibrary(std::io::Error::other(format!(
854                "Failed to clean stale CMake cache: {error}"
855            )))
856        })?;
857
858        Ok(removed > 0)
859    }
860
861    async fn cargo_build_output(
862        &self,
863        release: bool,
864        cargo_target: CargoTarget<'_>,
865    ) -> Result<std::process::Output, RustBuildError> {
866        let framework = self.project.as_ref().and_then(|project| {
867            project
868                .manifest()
869                .framework
870                .as_ref()
871                .map(|framework| (project, framework))
872        });
873        if let Some((project, framework)) = framework {
874            framework
875                .prepare_build(project, &self.path, &self.features)
876                .await
877                .map_err(|error| {
878                    RustBuildError::FailToBuildRustLibrary(std::io::Error::other(error.to_string()))
879                })?;
880        }
881        let crate_type_override = if cargo_target.accepts_crate_type_override() {
882            self.crate_type_override.as_deref()
883        } else {
884            None
885        };
886        let mut cmd = Command::new("cargo");
887        let cargo_subcommand = if crate_type_override.is_some() || !self.final_rustc_args.is_empty()
888        {
889            "rustc"
890        } else {
891            "build"
892        };
893        let mut cmd = command(&mut cmd)
894            .arg(cargo_subcommand)
895            .args(cargo_target.cargo_args())
896            .args(["--target", self.triple.to_string().as_str()])
897            .current_dir(&self.path);
898        if framework.is_some() {
899            cmd = cmd.arg("--locked");
900        }
901
902        if let Some(target_dir) = &self.target_dir {
903            cmd = cmd.arg("--target-dir").arg(target_dir);
904        }
905
906        // Apply extra environment variables (caller-provided values override defaults).
907        for (key, value) in &self.envs {
908            cmd.env(key, value);
909        }
910
911        if !self.rustc_flags.is_empty() {
912            let mut rustflags = std::env::var_os("RUSTFLAGS").unwrap_or_default();
913            if !rustflags.is_empty() {
914                rustflags.push(" ");
915            }
916            rustflags.push(self.rustc_flags.join(" "));
917            cmd = cmd.env("RUSTFLAGS", rustflags);
918        }
919
920        configure_generated_crate_compilation(cmd);
921
922        // Use sccache as rustc wrapper if configured
923        if let Some(sccache_path) = &self.sccache_path {
924            crate::toolchain::sccache::configure_compilation_cache(cmd, sccache_path);
925        }
926
927        // Set target-scoped bindgen clang args for simulator builds.
928        //
929        // Using the global `BINDGEN_EXTRA_CLANG_ARGS` leaks the simulator SDK into
930        // host-side build scripts (for example `coreaudio-sys`), which then try to
931        // parse host frameworks against the simulator SDK and fail. Bindgen supports
932        // target-qualified env vars, so scope the override to the actual Cargo target.
933        if self.triple.environment == Environment::Sim
934            && let Some(clang_args) = self.bindgen_clang_args_for_simulator().await
935        {
936            let bindgen_target_key = format!(
937                "BINDGEN_EXTRA_CLANG_ARGS_{}",
938                self.triple.to_string().replace('-', "_")
939            );
940            cmd = cmd.env(bindgen_target_key, clang_args);
941        }
942
943        if release {
944            cmd = cmd.arg("--release");
945        }
946
947        // Add cargo features if specified
948        if !self.features.is_empty() {
949            cmd = cmd.args(["--features", &self.features.join(",")]);
950        }
951
952        if crate_type_override.is_some() || !self.final_rustc_args.is_empty() {
953            cmd = cmd.arg("--");
954            if let Some(crate_type) = crate_type_override {
955                cmd = cmd.arg("--crate-type").arg(crate_type);
956            }
957            cmd = cmd.args(&self.final_rustc_args);
958        }
959
960        let output = cmd
961            .output()
962            .await
963            .map_err(RustBuildError::FailToExecuteCargoBuild)?;
964        Ok(output)
965    }
966
967    /// Resolve the Cargo library artifact directory for this build target and profile.
968    ///
969    /// # Errors
970    /// Returns an error if Cargo metadata cannot be read for this build target.
971    pub async fn lib_output_dir(&self, release: bool) -> Result<PathBuf, RustBuildError> {
972        let target_directory = self.target_directory().await?;
973        Ok(target_directory
974            .join(self.triple.to_string())
975            .join(if release { "release" } else { "debug" }))
976    }
977
978    async fn target_directory(&self) -> Result<PathBuf, RustBuildError> {
979        if let Some(target_dir) = &self.target_dir {
980            return Ok(target_dir.clone());
981        }
982
983        let build_path = self.path.clone();
984        let metadata = unblock(move || {
985            cargo_metadata::MetadataCommand::new()
986                .no_deps()
987                .current_dir(build_path)
988                .exec()
989                .map_err(|e| {
990                    RustBuildError::FailToBuildRustLibrary(std::io::Error::new(
991                        std::io::ErrorKind::InvalidData,
992                        e,
993                    ))
994                })
995        })
996        .await?;
997        Ok(metadata.target_directory.as_std_path().to_path_buf())
998    }
999
1000    /// Generate `BINDGEN_EXTRA_CLANG_ARGS` for simulator builds.
1001    ///
1002    /// Bindgen has issues with the `*-apple-*-sim` target triples, so we need to
1003    /// provide explicit clang arguments with a proper target and SDK path.
1004    async fn bindgen_clang_args_for_simulator(&self) -> Option<String> {
1005        let (sdk_name, target_os) = match self.triple.operating_system {
1006            OperatingSystem::IOS(_) => ("iphonesimulator", "ios"),
1007            OperatingSystem::TvOS(_) => ("appletvsimulator", "tvos"),
1008            OperatingSystem::WatchOS(_) => ("watchsimulator", "watchos"),
1009            OperatingSystem::VisionOS(_) => ("xrsimulator", "xros"),
1010            _ => return None,
1011        };
1012
1013        let arch = match self.triple.architecture {
1014            target_lexicon::Architecture::Aarch64(_) => "arm64",
1015            target_lexicon::Architecture::X86_64 => "x86_64",
1016            _ => return None,
1017        };
1018
1019        // Get SDK path using xcrun
1020        let sdk_path = run_command("xcrun", ["--sdk", sdk_name, "--show-sdk-path"])
1021            .await
1022            .ok()
1023            .map(|s| s.trim().to_string())?;
1024
1025        // Use a reasonable minimum deployment target
1026        let min_version = if matches!(target_os, "ios" | "tvos") {
1027            "17.0"
1028        } else if target_os == "watchos" {
1029            "10.0"
1030        } else {
1031            debug_assert_eq!(
1032                target_os, "xros",
1033                "bindgen simulator target_os must be one of ios/tvos/watchos/xros"
1034            );
1035            "1.0"
1036        };
1037
1038        Some(format!(
1039            "--target={arch}-apple-{target_os}{min_version}-simulator -isysroot {sdk_path}"
1040        ))
1041    }
1042}
1043
1044fn combined_build_output(output: &std::process::Output) -> String {
1045    let stderr = String::from_utf8_lossy(&output.stderr);
1046    let stdout = String::from_utf8_lossy(&output.stdout);
1047    if stderr.is_empty() {
1048        stdout.to_string()
1049    } else {
1050        stderr.to_string()
1051    }
1052}
1053
1054fn should_auto_install_meson(build_output: &str) -> bool {
1055    let lower = build_output.to_ascii_lowercase();
1056    lower.contains("meson")
1057        && (lower.contains("not found")
1058            || lower.contains("no such file")
1059            || lower.contains("failed to execute")
1060            || lower.contains("is required"))
1061}
1062
1063fn should_retry_after_cmake_generator_mismatch(build_output: &str) -> bool {
1064    let lower = build_output.to_ascii_lowercase();
1065    lower.contains("cmake error") && lower.contains("does not match the generator used previously")
1066}
1067
1068fn remove_cmake_build_dirs_in(build_root: &Path) -> std::io::Result<usize> {
1069    if !build_root.exists() {
1070        return Ok(0);
1071    }
1072
1073    let mut removed = 0usize;
1074    for entry in std::fs::read_dir(build_root)? {
1075        let entry = entry?;
1076        let path = entry.path();
1077        if !path.is_dir() {
1078            continue;
1079        }
1080
1081        let cmake_build_dir = path.join("out").join("build");
1082        if cmake_build_dir.join("CMakeCache.txt").exists() {
1083            std::fs::remove_dir_all(cmake_build_dir)?;
1084            removed += 1;
1085        }
1086    }
1087
1088    Ok(removed)
1089}
1090
1091#[cfg(target_os = "macos")]
1092async fn ensure_meson_installed_for_build() -> Result<(), String> {
1093    use crate::toolchain::meson::Meson;
1094    use crate::toolchain::{Installation as _, Toolchain as _, ToolchainError};
1095
1096    let host = crate::toolchain::Host::current();
1097    match Meson.check(&host).await {
1098        Ok(()) => Ok(()),
1099        Err(ToolchainError::Fixable(installation)) => {
1100            installation.install(&host).await.map_err(|e| e.to_string())
1101        }
1102        Err(ToolchainError::Unfixable(e)) => Err(e.to_string()),
1103    }
1104}
1105
1106#[cfg(not(target_os = "macos"))]
1107fn ensure_meson_installed_for_build() -> impl std::future::Future<Output = Result<(), String>> {
1108    std::future::ready(Err(
1109        "automatic meson installation is only supported on macOS".to_string(),
1110    ))
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use target_lexicon::Triple;
1116    use tempfile::tempdir;
1117
1118    use std::ffi::OsString;
1119
1120    use super::{
1121        BuildOptions, BuildProfile, CargoTarget, RustDynamicLibraries, RustLinkage,
1122        dynamic_library_file_name, lib_extension_for_triple, resolve_rust_standard_library_in,
1123    };
1124
1125    fn triple(value: &str) -> Triple {
1126        value.parse().expect("test target triple must parse")
1127    }
1128
1129    #[test]
1130    fn crate_type_override_applies_only_to_library_targets() {
1131        assert!(CargoTarget::Lib.accepts_crate_type_override());
1132        assert!(!CargoTarget::Binary("waterui-cef-helper").accepts_crate_type_override());
1133        assert_eq!(CargoTarget::Lib.cargo_args(), ["--lib"]);
1134        assert_eq!(
1135            CargoTarget::Binary("waterui-cef-helper").cargo_args(),
1136            ["--bin", "waterui-cef-helper"]
1137        );
1138    }
1139
1140    #[test]
1141    fn apple_platform_dylibs_use_macho_extension() {
1142        assert_eq!(
1143            lib_extension_for_triple(&triple("aarch64-apple-darwin")),
1144            "dylib"
1145        );
1146        assert_eq!(
1147            lib_extension_for_triple(&triple("aarch64-apple-ios-sim")),
1148            "dylib"
1149        );
1150        assert_eq!(
1151            lib_extension_for_triple(&triple("aarch64-apple-ios")),
1152            "dylib"
1153        );
1154    }
1155
1156    #[test]
1157    fn non_apple_platform_dylibs_keep_platform_extensions() {
1158        assert_eq!(
1159            lib_extension_for_triple(&triple("aarch64-linux-android")),
1160            "so"
1161        );
1162        assert_eq!(
1163            lib_extension_for_triple(&triple("x86_64-unknown-linux-gnu")),
1164            "so"
1165        );
1166        assert_eq!(
1167            lib_extension_for_triple(&triple("x86_64-pc-windows-msvc")),
1168            "dll"
1169        );
1170    }
1171
1172    #[test]
1173    fn development_and_packaging_have_distinct_linkage() {
1174        assert_eq!(
1175            BuildOptions::development(BuildProfile::Debug).linkage(),
1176            RustLinkage::SharedRuntime
1177        );
1178        assert_eq!(
1179            BuildOptions::packaging(BuildProfile::Debug).linkage(),
1180            RustLinkage::Static
1181        );
1182        assert!(BuildOptions::development(BuildProfile::Release).is_release());
1183        assert!(BuildOptions::packaging(BuildProfile::Release).is_release());
1184    }
1185
1186    #[test]
1187    fn build_profile_release_variants_select_the_release_profile() {
1188        assert!(BuildProfile::Release.is_release());
1189        assert!(BuildProfile::Profiling.is_release());
1190        assert!(!BuildProfile::Debug.is_release());
1191        assert!(!BuildProfile::Optimized.is_release());
1192    }
1193
1194    #[test]
1195    fn development_profile_envs_realize_the_selected_trade_off() {
1196        let optimized = BuildOptions::development(BuildProfile::Optimized);
1197        let envs = optimized.cargo_envs();
1198        assert!(
1199            envs.contains(&(
1200                "CARGO_PROFILE_DEV_OPT_LEVEL".to_string(),
1201                OsString::from("1")
1202            )),
1203            "optimized development lifts the dev opt-level: {envs:?}"
1204        );
1205        assert!(
1206            envs.contains(&(
1207                "CARGO_PROFILE_DEV_DEBUG_ASSERTIONS".to_string(),
1208                OsString::from("false")
1209            )),
1210            "optimized development drops dep debug assertions: {envs:?}"
1211        );
1212        assert!(
1213            envs.contains(&(
1214                "CARGO_PROFILE_DEV_DEBUG".to_string(),
1215                OsString::from("true")
1216            )),
1217            "optimized development keeps full debug info: {envs:?}"
1218        );
1219
1220        let profiling = BuildOptions::development(BuildProfile::Profiling);
1221        let envs = profiling.cargo_envs();
1222        for key in [
1223            "CARGO_PROFILE_RELEASE_OPT_LEVEL",
1224            "CARGO_PROFILE_RELEASE_DEBUG",
1225            "CARGO_PROFILE_RELEASE_STRIP",
1226        ] {
1227            assert!(
1228                envs.iter().any(|(env_key, _)| env_key == key),
1229                "profiling keeps debug info and symbols: missing {key} in {envs:?}"
1230            );
1231        }
1232
1233        assert!(
1234            BuildOptions::development(BuildProfile::Debug)
1235                .cargo_envs()
1236                .is_empty(),
1237            "plain debug runs the declared dev profile"
1238        );
1239    }
1240
1241    #[test]
1242    fn packaging_never_overrides_the_declared_profile() {
1243        for profile in [
1244            BuildProfile::Debug,
1245            BuildProfile::Optimized,
1246            BuildProfile::Release,
1247            BuildProfile::Profiling,
1248        ] {
1249            assert!(
1250                BuildOptions::packaging(profile).cargo_envs().is_empty(),
1251                "packaging {profile:?} must ship the declared profile"
1252            );
1253        }
1254    }
1255
1256    #[test]
1257    fn resolves_target_standard_library_without_guessing_hash() {
1258        let directory = tempdir().expect("temporary target libdir");
1259        let android_triple = triple("aarch64-linux-android");
1260        let expected = directory.path().join("libstd-1234567890abcdef.so");
1261        std::fs::write(&expected, []).expect("write test std library");
1262        std::fs::write(directory.path().join("libcore.rlib"), []).expect("write unrelated library");
1263
1264        assert_eq!(
1265            resolve_rust_standard_library_in(directory.path(), &android_triple)
1266                .expect("resolve dynamic std"),
1267            expected
1268        );
1269        assert_eq!(
1270            dynamic_library_file_name("waterui_dylib", &android_triple),
1271            "libwaterui_dylib.so"
1272        );
1273        assert_eq!(
1274            dynamic_library_file_name("waterui_dylib", &triple("x86_64-pc-windows-msvc")),
1275            "waterui_dylib.dll"
1276        );
1277    }
1278
1279    #[test]
1280    fn static_packaging_removes_only_staged_android_runtime_libraries() {
1281        smol::block_on(async {
1282            let directory = tempdir().expect("temporary Android runtime directory");
1283            let android_triple = triple("aarch64-linux-android");
1284            for file_name in [
1285                "libwaterui_dylib.so",
1286                "libstd-old.so",
1287                "libwaterui_app.so",
1288                "libc++_shared.so",
1289            ] {
1290                std::fs::write(directory.path().join(file_name), [])
1291                    .expect("write staged runtime test file");
1292            }
1293
1294            RustDynamicLibraries::remove_staged(directory.path(), &android_triple)
1295                .await
1296                .expect("remove shared Rust runtime libraries");
1297
1298            assert!(!directory.path().join("libwaterui_dylib.so").exists());
1299            assert!(!directory.path().join("libstd-old.so").exists());
1300            assert!(directory.path().join("libwaterui_app.so").exists());
1301            assert!(directory.path().join("libc++_shared.so").exists());
1302        });
1303    }
1304}