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 nanoid::nanoid;
11use tokio::sync::OnceCell;
12
13use crate::HostTargetType;
14use crate::progress::ProgressTracker;
15
16/// Build parameters for [`build_crate_memoized`].
17#[derive(PartialEq, Eq, Hash, Clone)]
18pub struct BuildParams {
19    /// The working directory for the build, where the `cargo build` command will be run. Crate root.
20    /// [`Self::new`] canonicalizes this path.
21    src: PathBuf,
22    /// `--bin` binary name parameter.
23    bin: Option<String>,
24    /// `--example` parameter.
25    example: Option<String>,
26    /// `--profile` parameter.
27    profile: Option<String>,
28    rustflags: Option<String>,
29    target_dir: Option<PathBuf>,
30    no_default_features: bool,
31    /// `--target <linux>` if cross-compiling for linux ([`HostTargetType::Linux`]).
32    target_type: HostTargetType,
33    /// `--features` flags, will be comma-delimited.
34    features: Option<Vec<String>>,
35}
36impl BuildParams {
37    /// Creates a new `BuildParams` and canonicalizes the `src` path.
38    #[expect(clippy::too_many_arguments, reason = "internal code")]
39    pub fn new(
40        src: impl AsRef<Path>,
41        bin: Option<String>,
42        example: Option<String>,
43        profile: Option<String>,
44        rustflags: Option<String>,
45        target_dir: Option<PathBuf>,
46        no_default_features: bool,
47        target_type: HostTargetType,
48        features: Option<Vec<String>>,
49    ) -> Self {
50        // `fs::canonicalize` prepends windows paths with the `r"\\?\"`
51        // https://stackoverflow.com/questions/21194530/what-does-mean-when-prepended-to-a-file-path
52        // However, this breaks the `include!(concat!(env!("OUT_DIR"), "/my/forward/slash/path.rs"))`
53        // Rust codegen pattern on windows. To help mitigate this happening in third party crates, we
54        // instead use `dunce::canonicalize` which is the same as `fs::canonicalize` but avoids the
55        // `\\?\` prefix when possible.
56        let src = dunce::canonicalize(src).expect("Failed to canonicalize path for build.");
57
58        BuildParams {
59            src,
60            bin,
61            example,
62            profile,
63            rustflags,
64            target_dir,
65            no_default_features,
66            target_type,
67            features,
68        }
69    }
70}
71
72/// Information about a built crate. See [`build_crate`].
73pub struct BuildOutput {
74    /// A unique but meaningless id.
75    pub unique_id: String,
76    /// The binary contents as a byte array.
77    pub bin_data: Vec<u8>,
78    /// The path to the binary file. [`Self::bin_data`] has a copy of the content.
79    pub bin_path: PathBuf,
80}
81
82/// Build memoization cache.
83static BUILDS: OnceLock<MemoMap<BuildParams, OnceCell<BuildOutput>>> = OnceLock::new();
84
85pub async fn build_crate_memoized(params: BuildParams) -> Result<&'static BuildOutput, BuildError> {
86    BUILDS
87        .get_or_init(MemoMap::new)
88        .get_or_insert(&params, Default::default)
89        .get_or_try_init(move || {
90            ProgressTracker::rich_leaf("build", move |set_msg| async move {
91                tokio::task::spawn_blocking(move || {
92                    let mut command = Command::new("cargo");
93                    command.args(["build"]);
94
95                    if let Some(profile) = params.profile.as_ref() {
96                        command.args(["--profile", profile]);
97                    }
98
99                    if let Some(bin) = params.bin.as_ref() {
100                        command.args(["--bin", bin]);
101                    }
102
103                    if let Some(example) = params.example.as_ref() {
104                        command.args(["--example", example]);
105                    }
106
107                    match params.target_type {
108                        HostTargetType::Local => {}
109                        HostTargetType::Linux => {
110                            command.args(["--target", "x86_64-unknown-linux-musl"]);
111                        }
112                    }
113
114                    if params.no_default_features {
115                        command.arg("--no-default-features");
116                    }
117
118                    if let Some(features) = params.features {
119                        command.args(["--features", &features.join(",")]);
120                    }
121
122                    command.arg("--message-format=json-diagnostic-rendered-ansi");
123
124                    if let Some(rustflags) = params.rustflags.as_ref() {
125                        command.env("RUSTFLAGS", rustflags);
126                    }
127
128                    if let Some(target_dir) = params.target_dir.as_ref() {
129                        command.env("CARGO_TARGET_DIR", target_dir);
130                    }
131
132                    let mut spawned = command
133                        .current_dir(&params.src)
134                        .stdout(Stdio::piped())
135                        .stderr(Stdio::piped())
136                        .stdin(Stdio::null())
137                        .spawn()
138                        .unwrap();
139
140                    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
141                    let stderr_reader = std::io::BufReader::new(spawned.stderr.take().unwrap());
142
143                    let stderr_worker = std::thread::spawn(move || {
144                        let mut stderr_lines = Vec::new();
145                        for line in stderr_reader.lines() {
146                            let Ok(line) = line else {
147                                break;
148                            };
149                            set_msg(line.clone());
150                            stderr_lines.push(line);
151                        }
152                        stderr_lines
153                    });
154
155                    let mut diagnostics = Vec::new();
156                    let mut text_lines = Vec::new();
157                    for message in cargo_metadata::Message::parse_stream(reader) {
158                        match message.unwrap() {
159                            cargo_metadata::Message::CompilerArtifact(artifact) => {
160                                let is_output = if params.example.is_some() {
161                                    artifact.target.kind.contains(&"example".to_string())
162                                } else {
163                                    artifact.target.kind.contains(&"bin".to_string())
164                                };
165
166                                if is_output {
167                                    let path = artifact.executable.unwrap();
168                                    let path_buf: PathBuf = path.clone().into();
169                                    let path = path.into_string();
170                                    let data = std::fs::read(path).unwrap();
171                                    assert!(spawned.wait().unwrap().success());
172                                    return Ok(BuildOutput {
173                                        unique_id: nanoid!(8),
174                                        bin_data: data,
175                                        bin_path: path_buf,
176                                    });
177                                }
178                            }
179                            cargo_metadata::Message::CompilerMessage(msg) => {
180                                ProgressTracker::println(msg.message.rendered.as_deref().unwrap());
181                                diagnostics.push(msg.message);
182                            }
183                            cargo_metadata::Message::TextLine(line) => {
184                                ProgressTracker::println(&line);
185                                text_lines.push(line);
186                            }
187                            cargo_metadata::Message::BuildFinished(_) => {}
188                            cargo_metadata::Message::BuildScriptExecuted(_) => {}
189                            msg => panic!("Unexpected message type: {:?}", msg),
190                        }
191                    }
192
193                    let exit_status = spawned.wait().unwrap();
194                    if exit_status.success() {
195                        Err(BuildError::NoBinaryEmitted)
196                    } else {
197                        let stderr_lines = stderr_worker
198                            .join()
199                            .expect("Stderr worker unexpectedly panicked.");
200                        Err(BuildError::FailedToBuildCrate {
201                            exit_status,
202                            diagnostics,
203                            text_lines,
204                            stderr_lines,
205                        })
206                    }
207                })
208                .await
209                .map_err(|_| BuildError::TokioJoinError)?
210            })
211        })
212        .await
213}
214
215#[derive(Clone, Debug)]
216pub enum BuildError {
217    FailedToBuildCrate {
218        exit_status: ExitStatus,
219        diagnostics: Vec<Diagnostic>,
220        text_lines: Vec<String>,
221        stderr_lines: Vec<String>,
222    },
223    TokioJoinError,
224    NoBinaryEmitted,
225}
226
227impl Display for BuildError {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            Self::FailedToBuildCrate {
231                exit_status,
232                diagnostics,
233                text_lines,
234                stderr_lines,
235            } => {
236                writeln!(f, "Failed to build crate ({})", exit_status)?;
237                writeln!(f, "Diagnostics ({}):", diagnostics.len())?;
238                for diagnostic in diagnostics {
239                    write!(f, "{}", diagnostic)?;
240                }
241                writeln!(f, "Text output ({} lines):", text_lines.len())?;
242                for line in text_lines {
243                    writeln!(f, "{}", line)?;
244                }
245                writeln!(f, "Stderr output ({} lines):", stderr_lines.len())?;
246                for line in stderr_lines {
247                    writeln!(f, "{}", line)?;
248                }
249            }
250            Self::TokioJoinError => {
251                write!(f, "Failed to spawn tokio blocking task.")?;
252            }
253            Self::NoBinaryEmitted => {
254                write!(f, "`cargo build` succeeded but no binary was emitted.")?;
255            }
256        }
257        Ok(())
258    }
259}
260
261impl Error for BuildError {}