1use std::{
2 path::{Path, PathBuf},
3 process::Stdio,
4};
5
6use miette::{IntoDiagnostic, Result, miette};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use tempfile::TempDir;
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::process::Command;
12use tokio::sync::mpsc::UnboundedSender;
13
14use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
15
16fn default_parallel() -> bool {
17 true
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
22pub struct CargoBuilderConfig {
23 #[serde(default)]
28 pub targets: Vec<String>,
29
30 pub manifest_path: Option<PathBuf>,
35
36 #[serde(default)]
48 pub bins: Vec<String>,
49
50 #[serde(default = "default_parallel")]
78 pub parallel: bool,
79}
80
81impl Default for CargoBuilderConfig {
82 fn default() -> Self {
83 Self {
84 targets: Vec::new(),
85 manifest_path: None,
86 bins: Vec::new(),
87 parallel: default_parallel(),
88 }
89 }
90}
91
92pub struct CargoBuilder;
94
95impl Builder for CargoBuilder {
96 type ConfigType = CargoBuilderConfig;
97
98 async fn build(
99 &self,
100 config: Self::ConfigType,
101 abbaye_version: &str,
102 log: LogSender,
103 ) -> Result<Vec<ArtifactPath>> {
104 let crate_version = read_crate_version(config.manifest_path.as_deref()).await?;
105
106 if config.targets.is_empty() {
107 let host = get_host_target().await?;
109 let line_tx = line_bridge(log, LogEvent::Line);
110 run_cargo_build(
111 &config,
112 None,
113 &host,
114 &crate_version,
115 abbaye_version,
116 line_tx,
117 None,
118 )
119 .await
120 } else {
121 let mut join_set = tokio::task::JoinSet::new();
125
126 for target in &config.targets {
127 let config = config.clone();
128 let crate_version = crate_version.clone();
129 let abbaye_version = abbaye_version.to_owned();
130 let target = target.clone();
131 let log = log.clone();
132
133 join_set.spawn(async move {
134 let _ = log.send(LogEvent::ChildStart {
136 id: target.clone(),
137 label: target.clone(),
138 });
139
140 let target_id = target.clone();
143 let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine {
144 id: target_id.clone(),
145 line: l,
146 });
147
148 let result = if config.parallel {
149 let tmpdir = TempDir::new().into_diagnostic()?;
152 let r = run_cargo_build(
153 &config,
154 Some(target.as_str()),
155 &target,
156 &crate_version,
157 &abbaye_version,
158 line_tx,
159 Some(tmpdir.path()),
160 )
161 .await;
162 match r {
165 Ok(artifacts) => relocate_artifacts(artifacts, tmpdir.path()).await,
166 Err(e) => Err(e),
167 }
168 } else {
169 run_cargo_build(
173 &config,
174 Some(target.as_str()),
175 &target,
176 &crate_version,
177 &abbaye_version,
178 line_tx,
179 None,
180 )
181 .await
182 };
183
184 let _ = log.send(LogEvent::ChildFinish {
185 id: target.clone(),
186 success: result.is_ok(),
187 summary: match &result {
188 Ok(artifacts) => format!("{} artifact(s)", artifacts.len()),
189 Err(e) => e.to_string(),
190 },
191 });
192
193 result
194 });
195 }
196
197 let mut all_artifacts = Vec::new();
198 while let Some(res) = join_set.join_next().await {
199 all_artifacts.extend(res.into_diagnostic()??);
200 }
201 Ok(all_artifacts)
202 }
203 }
204}
205
206fn line_bridge(
211 log: LogSender,
212 f: impl Fn(String) -> LogEvent + Send + 'static,
213) -> UnboundedSender<String> {
214 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
215 tokio::spawn(async move {
216 while let Some(line) = rx.recv().await {
217 let _ = log.send(f(line));
218 }
219 });
220 tx
221}
222
223#[derive(Deserialize)]
226struct CargoMessage {
227 reason: String,
228 package_id: Option<String>,
232 target: Option<CargoMessageTarget>,
233 filenames: Option<Vec<String>>,
234}
235
236#[derive(Deserialize)]
237struct CargoMessageTarget {
238 name: String,
239}
240
241async fn run_cargo_build(
248 config: &CargoBuilderConfig,
249 target: Option<&str>,
250 triple: &str,
251 version: &str,
252 abbaye_version: &str,
253 line_tx: UnboundedSender<String>,
254 target_dir: Option<&Path>,
255) -> Result<Vec<ArtifactPath>> {
256 let mut cmd = Command::new("cargo");
257 cmd.args(["build", "--release", "--message-format=json"]);
258 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
259
260 if let Some(t) = target {
261 cmd.args(["--target", t]);
262 }
263
264 if let Some(manifest) = &config.manifest_path {
265 cmd.arg("--manifest-path").arg(manifest);
266 }
267
268 if let Some(dir) = target_dir {
269 cmd.arg("--target-dir").arg(dir);
270 }
271
272 let mut child = cmd
273 .stdout(Stdio::piped())
274 .stderr(Stdio::piped())
275 .spawn()
276 .into_diagnostic()?;
277
278 let stderr = child.stderr.take().expect("stderr was piped");
281 tokio::spawn(async move {
282 let mut stderr_lines = BufReader::new(stderr).lines();
283 while let Ok(Some(line)) = stderr_lines.next_line().await {
284 let _ = line_tx.send(line);
285 }
286 });
287
288 let stdout = child.stdout.take().expect("stdout was piped");
289 let mut lines = BufReader::new(stdout).lines();
290
291 let mut artifacts = Vec::new();
292
293 while let Some(line) = lines.next_line().await.into_diagnostic()? {
294 let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
295 continue;
296 };
297
298 if msg.reason != "compiler-artifact" {
299 continue;
300 }
301
302 if !msg
307 .package_id
308 .as_deref()
309 .is_some_and(|id| id.contains("path+file://"))
310 {
311 continue;
312 }
313
314 if !config.bins.is_empty() {
316 let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
317 if !config.bins.iter().any(|b| b == target_name) {
318 continue;
319 }
320 }
321
322 for filename in msg.filenames.unwrap_or_default() {
323 let path = PathBuf::from(&filename);
324
325 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
327 if matches!(ext, "rlib" | "rmeta" | "d") {
328 continue;
329 }
330
331 if !path.exists() {
332 continue;
333 }
334
335 let stem = path
338 .file_stem()
339 .map(|s| s.to_string_lossy().into_owned())
340 .unwrap_or_default();
341 let dot_ext = path
342 .extension()
343 .map(|e| format!(".{}", e.to_string_lossy()))
344 .unwrap_or_default();
345 let name = format!("{stem}-{version}-{triple}{dot_ext}");
346
347 artifacts.push(ArtifactPath {
348 path,
349 name,
350 hash: None,
351 });
352 }
353 }
354
355 let status = child.wait().await.into_diagnostic()?;
356
357 if !status.success() {
358 return Err(miette!(
359 "cargo build --release failed with exit status: {status}"
360 ));
361 }
362
363 Ok(artifacts)
364}
365
366async fn relocate_artifacts(
375 artifacts: Vec<ArtifactPath>,
376 tmp_root: &Path,
377) -> Result<Vec<ArtifactPath>> {
378 let mut relocated = Vec::with_capacity(artifacts.len());
379 for artifact in artifacts {
380 let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?;
381 let stable = PathBuf::from("target").join(relative);
382 if let Some(parent) = stable.parent() {
383 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
384 }
385 tokio::fs::copy(&artifact.path, &stable)
386 .await
387 .into_diagnostic()?;
388 relocated.push(ArtifactPath {
389 path: stable,
390 name: artifact.name,
391 hash: artifact.hash,
392 });
393 }
394 Ok(relocated)
395}
396
397async fn get_host_target() -> Result<String> {
400 let output = Command::new("rustc")
401 .args(["-vV"])
402 .output()
403 .await
404 .into_diagnostic()?;
405
406 if !output.status.success() {
407 return Err(miette!("rustc -vV failed"));
408 }
409
410 let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
411 stdout
412 .lines()
413 .find(|l| l.starts_with("host:"))
414 .and_then(|l| l.split_whitespace().nth(1))
415 .map(str::to_owned)
416 .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
417}
418
419async fn read_crate_version(manifest_path: Option<&Path>) -> Result<String> {
422 let path = manifest_path.unwrap_or(Path::new("Cargo.toml"));
423 let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
424
425 #[derive(Deserialize)]
426 struct Manifest {
427 package: Option<Package>,
428 }
429 #[derive(Deserialize)]
430 struct Package {
431 version: Option<String>,
432 }
433
434 let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
435 manifest
436 .package
437 .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
438 .version
439 .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
440}
441
442#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
444pub struct CargoDocBuilderConfig {
445 pub manifest_path: Option<PathBuf>,
450
451 #[serde(default)]
453 pub no_deps: bool,
454}
455
456pub struct CargoDocBuilder;
458
459impl Builder for CargoDocBuilder {
460 type ConfigType = CargoDocBuilderConfig;
461
462 async fn build(
463 &self,
464 config: Self::ConfigType,
465 abbaye_version: &str,
466 log: LogSender,
467 ) -> Result<Vec<ArtifactPath>> {
468 let mut cmd = Command::new("cargo");
469 cmd.arg("doc");
470 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
471
472 if config.no_deps {
473 cmd.arg("--no-deps");
474 }
475
476 if let Some(manifest) = &config.manifest_path {
477 cmd.arg("--manifest-path").arg(manifest);
478 }
479
480 let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?;
481
482 let stderr = child.stderr.take().expect("stderr was piped");
483 tokio::spawn(async move {
484 let mut stderr_lines = BufReader::new(stderr).lines();
485 while let Ok(Some(line)) = stderr_lines.next_line().await {
486 let _ = log.send(LogEvent::Line(line));
487 }
488 });
489
490 let status = child.wait().await.into_diagnostic()?;
491
492 if !status.success() {
493 return Err(miette!("cargo doc failed with exit status: {status}"));
494 }
495
496 let doc_dir = config
499 .manifest_path
500 .as_deref()
501 .and_then(|p| p.parent())
502 .unwrap_or_else(|| std::path::Path::new("."))
503 .join("target/doc");
504
505 if !doc_dir.exists() {
506 return Err(miette!("doc directory not found at {}", doc_dir.display()));
507 }
508
509 Ok(vec![ArtifactPath {
513 path: doc_dir,
514 name: "doc".to_owned(),
515 hash: None,
516 }])
517 }
518}