hydro_deploy/rust_crate/
build.rs

1use std::error::Error;
2use std::fmt::Display;
3use std::io::BufRead;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitStatus, Stdio};
6use std::sync::OnceLock;
7
8use cargo_metadata::diagnostic::Diagnostic;
9use memo_map::MemoMap;
10use tokio::sync::OnceCell;
11
12use crate::HostTargetType;
13use crate::progress::ProgressTracker;
14
15/// Build parameters for [`build_crate_memoized`].
16#[derive(PartialEq, Eq, Hash, Clone)]
17pub struct BuildParams {
18    /// The working directory for the build, where the `cargo build` command will be run. Crate root.
19    /// [`Self::new`] canonicalizes this path.
20    src: PathBuf,
21    /// `--bin` binary name parameter.
22    bin: Option<String>,
23    /// `--example` parameter.
24    example: Option<String>,
25    /// `--profile` parameter.
26    profile: Option<String>,
27    rustflags: Option<String>,
28    target_dir: Option<PathBuf>,
29    // Environment variables available during build
30    build_env: Vec<(String, String)>,
31    no_default_features: bool,
32    /// `--target <linux>` if cross-compiling for linux ([`HostTargetType::Linux`]).
33    target_type: HostTargetType,
34    /// `--features` flags, will be comma-delimited.
35    features: Option<Vec<String>>,
36    /// `--config` flag
37    config: Vec<String>,
38}
39impl BuildParams {
40    /// Creates a new `BuildParams` and canonicalizes the `src` path.
41    #[expect(clippy::too_many_arguments, reason = "internal code")]
42    pub fn new(
43        src: impl AsRef<Path>,
44        bin: Option<String>,
45        example: Option<String>,
46        profile: Option<String>,
47        rustflags: Option<String>,
48        target_dir: Option<PathBuf>,
49        build_env: Vec<(String, String)>,
50        no_default_features: bool,
51        target_type: HostTargetType,
52        features: Option<Vec<String>>,
53        config: Vec<String>,
54    ) -> Self {
55        // `fs::canonicalize` prepends windows paths with the `r"\\?\"`
56        // https://stackoverflow.com/questions/21194530/what-does-mean-when-prepended-to-a-file-path
57        // However, this breaks the `include!(concat!(env!("OUT_DIR"), "/my/forward/slash/path.rs"))`
58        // Rust codegen pattern on windows. To help mitigate this happening in third party crates, we
59        // instead use `dunce::canonicalize` which is the same as `fs::canonicalize` but avoids the
60        // `\\?\` prefix when possible.
61        let src = dunce::canonicalize(src).expect("Failed to canonicalize path for build.");
62
63        BuildParams {
64            src,
65            bin,
66            example,
67            profile,
68            rustflags,
69            target_dir,
70            build_env,
71            no_default_features,
72            target_type,
73            features,
74            config,
75        }
76    }
77}
78
79/// Information about a built crate. See [`build_crate_memoized`].
80pub struct BuildOutput {
81    /// The binary contents as a byte array.
82    pub bin_data: Vec<u8>,
83    /// The path to the binary file. [`Self::bin_data`] has a copy of the content.
84    pub bin_path: PathBuf,
85    /// Shared library path, containing any necessary dylibs.
86    pub shared_library_path: Option<PathBuf>,
87}
88impl BuildOutput {
89    /// A unique ID for the binary, based its contents.
90    pub fn unique_id(&self) -> impl use<> + Display {
91        blake3::hash(&self.bin_data).to_hex()
92    }
93}
94
95/// Build memoization cache.
96static BUILDS: OnceLock<MemoMap<BuildParams, OnceCell<BuildOutput>>> = OnceLock::new();
97
98pub async fn build_crate_memoized(params: BuildParams) -> Result<&'static BuildOutput, BuildError> {
99    BUILDS
100        .get_or_init(MemoMap::new)
101        .get_or_insert(&params, Default::default)
102        .get_or_try_init(move || {
103            ProgressTracker::rich_leaf("build", move |set_msg| async move {
104                tokio::task::spawn_blocking(move || {
105                    let mut command = Command::new("cargo");
106                    command.args(["build", "--locked"]);
107
108                    if let Some(profile) = params.profile.as_ref() {
109                        command.args(["--profile", profile]);
110                    }
111
112                    if let Some(bin) = params.bin.as_ref() {
113                        command.args(["--bin", bin]);
114                    }
115
116                    if let Some(example) = params.example.as_ref() {
117                        command.args(["--example", example]);
118                    }
119
120                    match params.target_type {
121                        HostTargetType::Local => {}
122                        HostTargetType::Linux(crate::LinuxCompileType::Glibc) => {
123                            command.args(["--target", "x86_64-unknown-linux-gnu"]);
124                        }
125                        HostTargetType::Linux(crate::LinuxCompileType::Musl) => {
126                            command.args(["--target", "x86_64-unknown-linux-musl"]);
127                        }
128                    }
129
130                    if params.no_default_features {
131                        command.arg("--no-default-features");
132                    }
133
134                    if let Some(features) = params.features {
135                        command.args(["--features", &features.join(",")]);
136                    }
137
138                    for config in &params.config {
139                        command.args(["--config", config]);
140                    }
141
142                    command.arg("--message-format=json-diagnostic-rendered-ansi");
143
144                    if let Some(target_dir) = params.target_dir.as_ref() {
145                        command.args(["--target-dir", target_dir.to_str().unwrap()]);
146                    }
147
148                    let is_dylib = if let Some(rustflags) = params.rustflags.as_ref() {
149                        command.env("RUSTFLAGS", rustflags);
150                        false
151                    } else if params.target_type == HostTargetType::Local
152                        && !cfg!(target_os = "windows")
153                    {
154                        // When compiling for local, prefer dynamic linking to reduce binary size
155                        // Windows is currently not supported due to https://github.com/bevyengine/bevy/pull/2016
156                        command.env(
157                            "RUSTFLAGS",
158                            std::env::var("RUSTFLAGS").unwrap_or_default() + " -C prefer-dynamic",
159                        );
160                        true
161                    } else {
162                        false
163                    };
164
165                    for (k, v) in params.build_env {
166                        command.env(k, v);
167                    }
168
169                    let mut spawned = command
170                        .current_dir(&params.src)
171                        .stdout(Stdio::piped())
172                        .stderr(Stdio::piped())
173                        .stdin(Stdio::null())
174                        .spawn()
175                        .unwrap();
176
177                    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
178                    let stderr_reader = std::io::BufReader::new(spawned.stderr.take().unwrap());
179
180                    let stderr_worker = std::thread::spawn(move || {
181                        let mut stderr_lines = Vec::new();
182                        for line in stderr_reader.lines() {
183                            let Ok(line) = line else {
184                                break;
185                            };
186                            set_msg(line.clone());
187                            stderr_lines.push(line);
188                        }
189                        stderr_lines
190                    });
191
192                    let mut diagnostics = Vec::new();
193                    let mut text_lines = Vec::new();
194                    for message in cargo_metadata::Message::parse_stream(reader) {
195                        match message.unwrap() {
196                            cargo_metadata::Message::CompilerArtifact(artifact) => {
197                                let is_output = if params.example.is_some() {
198                                    artifact.target.kind.contains(&"example".to_string())
199                                } else {
200                                    artifact.target.kind.contains(&"bin".to_string())
201                                };
202
203                                if is_output {
204                                    let path = artifact.executable.unwrap();
205                                    let path_buf: PathBuf = path.clone().into();
206                                    let path = path.into_string();
207                                    let data = std::fs::read(path).unwrap();
208                                    assert!(spawned.wait().unwrap().success());
209                                    return Ok(BuildOutput {
210                                        bin_data: data,
211                                        bin_path: path_buf,
212                                        shared_library_path: if is_dylib {
213                                            Some(
214                                                params
215                                                    .target_dir
216                                                    .as_ref()
217                                                    .unwrap_or(&params.src.join("target"))
218                                                    .join("debug")
219                                                    .join("deps"),
220                                            )
221                                        } else {
222                                            None
223                                        },
224                                    });
225                                }
226                            }
227                            cargo_metadata::Message::CompilerMessage(mut msg) => {
228                                // Update the path displayed to enable clicking in IDE.
229                                // TODO(mingwei): deduplicate code with hydro_lang sim/graph.rs
230                                if let Some(rendered) = msg.message.rendered.as_mut() {
231                                    let file_names = msg
232                                        .message
233                                        .spans
234                                        .iter()
235                                        .map(|s| &s.file_name)
236                                        .collect::<std::collections::BTreeSet<_>>();
237                                    for file_name in file_names {
238                                        *rendered = rendered.replace(
239                                            file_name,
240                                            &format!(
241                                                "(full path) {}/{file_name}",
242                                                params.src.display(),
243                                            ),
244                                        )
245                                    }
246                                }
247                                ProgressTracker::println(msg.message.to_string());
248                                diagnostics.push(msg.message);
249                            }
250                            cargo_metadata::Message::TextLine(line) => {
251                                ProgressTracker::println(&line);
252                                text_lines.push(line);
253                            }
254                            cargo_metadata::Message::BuildFinished(_) => {}
255                            cargo_metadata::Message::BuildScriptExecuted(_) => {}
256                            msg => panic!("Unexpected message type: {:?}", msg),
257                        }
258                    }
259
260                    let exit_status = spawned.wait().unwrap();
261                    if exit_status.success() {
262                        Err(BuildError::NoBinaryEmitted)
263                    } else {
264                        let stderr_lines = stderr_worker
265                            .join()
266                            .expect("Stderr worker unexpectedly panicked.");
267                        Err(BuildError::FailedToBuildCrate {
268                            exit_status,
269                            diagnostics,
270                            text_lines,
271                            stderr_lines,
272                        })
273                    }
274                })
275                .await
276                .map_err(|_| BuildError::TokioJoinError)?
277            })
278        })
279        .await
280}
281
282#[derive(Clone, Debug)]
283pub enum BuildError {
284    FailedToBuildCrate {
285        exit_status: ExitStatus,
286        diagnostics: Vec<Diagnostic>,
287        text_lines: Vec<String>,
288        stderr_lines: Vec<String>,
289    },
290    TokioJoinError,
291    NoBinaryEmitted,
292}
293
294impl Display for BuildError {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        match self {
297            Self::FailedToBuildCrate {
298                exit_status,
299                diagnostics,
300                text_lines,
301                stderr_lines,
302            } => {
303                writeln!(f, "Failed to build crate ({})", exit_status)?;
304                writeln!(f, "Diagnostics ({}):", diagnostics.len())?;
305                for diagnostic in diagnostics {
306                    write!(f, "{}", diagnostic)?;
307                }
308                writeln!(f, "Text output ({} lines):", text_lines.len())?;
309                for line in text_lines {
310                    writeln!(f, "{}", line)?;
311                }
312                writeln!(f, "Stderr output ({} lines):", stderr_lines.len())?;
313                for line in stderr_lines {
314                    writeln!(f, "{}", line)?;
315                }
316            }
317            Self::TokioJoinError => {
318                write!(f, "Failed to spawn tokio blocking task.")?;
319            }
320            Self::NoBinaryEmitted => {
321                write!(f, "`cargo build` succeeded but no binary was emitted.")?;
322            }
323        }
324        Ok(())
325    }
326}
327
328impl Error for BuildError {}