Skip to main content

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