1use std::process::Stdio;
2
3use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
4use miette::{IntoDiagnostic, Result, miette};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use tempfile::TempDir;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command;
10use tokio::sync::mpsc::UnboundedSender;
11
12fn default_parallel() -> bool {
13 true
14}
15
16#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
18pub struct CargoBuilderConfig {
19 #[serde(default)]
24 pub targets: Vec<String>,
25
26 pub manifest_path: Option<std::path::PathBuf>,
31
32 #[serde(default)]
44 pub bins: Vec<String>,
45
46 #[serde(default)]
57 pub use_cross: bool,
58
59 #[serde(default = "default_parallel")]
87 pub parallel: bool,
88}
89
90impl Default for CargoBuilderConfig {
91 fn default() -> Self {
92 Self {
93 targets: Vec::new(),
94 manifest_path: None,
95 bins: Vec::new(),
96 parallel: default_parallel(),
97 use_cross: false,
98 }
99 }
100}
101
102pub struct CargoBuilder;
105
106impl Builder for CargoBuilder {
107 type ConfigType = CargoBuilderConfig;
108
109 async fn build(
110 &self,
111 config: Self::ConfigType,
112 abbaye_version: &str,
113 log: LogSender,
114 ) -> Result<Vec<ArtifactPath>> {
115 let crate_version = read_crate_version(config.manifest_path.as_deref()).await?;
116
117 if config.targets.is_empty() {
118 let host = get_host_target().await?;
120 let line_tx = line_bridge(log, LogEvent::Line);
121 run_cargo_build(
122 &config,
123 None,
124 &host,
125 &crate_version,
126 abbaye_version,
127 line_tx,
128 None,
129 )
130 .await
131 } else {
132 let mut join_set = tokio::task::JoinSet::new();
136
137 for target in &config.targets {
138 let config = config.clone();
139 let crate_version = crate_version.clone();
140 let abbaye_version = abbaye_version.to_owned();
141 let target = target.clone();
142 let log = log.clone();
143
144 join_set.spawn(async move {
145 let _ = log.send(LogEvent::ChildStart {
147 id: target.clone(),
148 label: target.clone(),
149 });
150
151 let target_id = target.clone();
154 let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine {
155 id: target_id.clone(),
156 line: l,
157 });
158
159 let result = if config.parallel && !config.use_cross {
160 let tmpdir = TempDir::new().into_diagnostic()?;
163 let r = run_cargo_build(
164 &config,
165 Some(target.as_str()),
166 &target,
167 &crate_version,
168 &abbaye_version,
169 line_tx,
170 Some(tmpdir.path()),
171 )
172 .await;
173 match r {
176 Ok(artifacts) => relocate_artifacts(artifacts, tmpdir.path()).await,
177 Err(e) => Err(e),
178 }
179 } else {
180 run_cargo_build(
184 &config,
185 Some(target.as_str()),
186 &target,
187 &crate_version,
188 &abbaye_version,
189 line_tx,
190 None,
191 )
192 .await
193 };
194
195 let _ = log.send(LogEvent::ChildFinish {
196 id: target.clone(),
197 success: result.is_ok(),
198 summary: match &result {
199 Ok(artifacts) => format!("{} artifact(s)", artifacts.len()),
200 Err(e) => e.to_string(),
201 },
202 });
203
204 result
205 });
206 }
207
208 let mut all_artifacts = Vec::new();
209 while let Some(res) = join_set.join_next().await {
210 all_artifacts.extend(res.into_diagnostic()??);
211 }
212 Ok(all_artifacts)
213 }
214 }
215}
216
217fn line_bridge(
222 log: LogSender,
223 f: impl Fn(String) -> LogEvent + Send + 'static,
224) -> UnboundedSender<String> {
225 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
226 tokio::spawn(async move {
227 while let Some(line) = rx.recv().await {
228 let _ = log.send(f(line));
229 }
230 });
231 tx
232}
233
234#[derive(Deserialize)]
237struct CargoMessage {
238 reason: String,
239 package_id: Option<String>,
243 target: Option<CargoMessageTarget>,
244 filenames: Option<Vec<String>>,
245}
246
247#[derive(Deserialize)]
248struct CargoMessageTarget {
249 name: String,
250 #[serde(default)]
252 kind: Vec<String>,
253}
254
255async fn run_cargo_build(
262 config: &CargoBuilderConfig,
263 target: Option<&str>,
264 triple: &str,
265 version: &str,
266 abbaye_version: &str,
267 line_tx: UnboundedSender<String>,
268 target_dir: Option<&std::path::Path>,
269) -> Result<Vec<ArtifactPath>> {
270 let tool = if config.use_cross { "cross" } else { "cargo" };
271 let mut cmd = Command::new(tool);
272 cmd.args(["build", "--release", "--message-format=json"]);
273 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
274
275 if let Some(t) = target {
276 cmd.args(["--target", t]);
277 }
278
279 if let Some(manifest) = &config.manifest_path {
280 cmd.arg("--manifest-path").arg(manifest);
281 }
282
283 if let Some(dir) = target_dir {
284 cmd.arg("--target-dir").arg(dir);
285 }
286
287 let mut child = cmd
288 .stdout(Stdio::piped())
289 .stderr(Stdio::piped())
290 .spawn()
291 .into_diagnostic()?;
292
293 let stderr = child.stderr.take().expect("stderr was piped");
296 tokio::spawn(async move {
297 let mut stderr_lines = BufReader::new(stderr).lines();
298 while let Ok(Some(line)) = stderr_lines.next_line().await {
299 let _ = line_tx.send(line);
300 }
301 });
302
303 let stdout = child.stdout.take().expect("stdout was piped");
304 let mut lines = BufReader::new(stdout).lines();
305
306 let mut artifacts = Vec::new();
307
308 while let Some(line) = lines.next_line().await.into_diagnostic()? {
309 let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
310 continue;
311 };
312
313 if msg.reason != "compiler-artifact" {
314 continue;
315 }
316
317 if !msg
322 .package_id
323 .as_deref()
324 .is_some_and(|id| id.contains("path+file://"))
325 {
326 continue;
327 }
328
329 if msg
331 .target
332 .as_ref()
333 .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"))
334 {
335 continue;
336 }
337
338 if !config.bins.is_empty() {
340 let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
341 if !config.bins.iter().any(|b| b == target_name) {
342 continue;
343 }
344 }
345
346 for filename in msg.filenames.unwrap_or_default() {
347 let path = std::path::PathBuf::from(&filename);
348
349 let ext = path
351 .extension()
352 .map(|e| e.to_string_lossy())
353 .unwrap_or_default();
354 if ext == "rlib" || ext == "rmeta" || ext == "d" {
355 continue;
356 }
357
358 if !path.exists() {
359 continue;
360 }
361
362 let stem = path
365 .file_stem()
366 .map(|s| s.to_string_lossy().into_owned())
367 .unwrap_or_default();
368 let dot_ext = path
369 .extension()
370 .map(|e| format!(".{}", e.to_string_lossy()))
371 .unwrap_or_default();
372 let name = format!("{stem}-{version}-{triple}{dot_ext}");
373
374 artifacts.push(ArtifactPath {
375 path,
376 name,
377 hash: None,
378 category: None,
379 group_name: None,
380 group_comment: None,
381 });
382 }
383 }
384
385 let status = child.wait().await.into_diagnostic()?;
386
387 if !status.success() {
388 return Err(miette!(
389 "{tool} build --release failed with exit status: {status}"
390 ));
391 }
392
393 Ok(artifacts)
394}
395
396async fn relocate_artifacts(
405 artifacts: Vec<ArtifactPath>,
406 tmp_root: &std::path::Path,
407) -> Result<Vec<ArtifactPath>> {
408 let mut relocated = Vec::with_capacity(artifacts.len());
409 for artifact in artifacts {
410 let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?;
411 let stable = std::path::PathBuf::from("target").join(relative);
412 if let Some(parent) = stable.parent() {
413 tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
414 }
415 tokio::fs::copy(&artifact.path, &stable)
416 .await
417 .into_diagnostic()?;
418 relocated.push(ArtifactPath {
419 path: stable,
420 name: artifact.name,
421 hash: artifact.hash,
422 category: artifact.category,
423 group_name: None,
424 group_comment: None,
425 });
426 }
427 Ok(relocated)
428}
429
430async fn get_host_target() -> Result<String> {
433 let output = Command::new("rustc")
434 .args(["-vV"])
435 .output()
436 .await
437 .into_diagnostic()?;
438
439 if !output.status.success() {
440 return Err(miette!("rustc -vV failed"));
441 }
442
443 let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
444 stdout
445 .lines()
446 .find(|l| l.starts_with("host:"))
447 .and_then(|l| l.split_whitespace().nth(1))
448 .map(str::to_owned)
449 .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
450}
451
452async fn read_crate_version(manifest_path: Option<&std::path::Path>) -> Result<String> {
455 let path = manifest_path.unwrap_or(std::path::Path::new("Cargo.toml"));
456 let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
457
458 #[derive(Deserialize)]
459 struct Manifest {
460 package: Option<Package>,
461 }
462 #[derive(Deserialize)]
463 struct Package {
464 version: Option<String>,
465 }
466
467 let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
468 manifest
469 .package
470 .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
471 .version
472 .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use std::path::Path;
479
480 #[test]
483 fn deserialize_compiler_artifact_message() {
484 let json = r#"{
485 "reason": "compiler-artifact",
486 "package_id": "path+file:///home/user/project#abbaye@0.10.0",
487 "target": { "name": "abbaye", "kind": ["bin"] },
488 "filenames": ["/home/user/project/target/release/abbaye"]
489 }"#;
490 let msg: CargoMessage = serde_json::from_str(json).unwrap();
491 assert_eq!(msg.reason, "compiler-artifact");
492 assert!(msg.package_id.unwrap().contains("path+file://"));
493 let target = msg.target.unwrap();
494 assert_eq!(target.name, "abbaye");
495 assert_eq!(target.kind, vec!["bin"]);
496 assert_eq!(
497 msg.filenames.unwrap(),
498 vec!["/home/user/project/target/release/abbaye"]
499 );
500 }
501
502 #[test]
503 fn deserialize_build_script_message_correctly_skipped() {
504 let json = r#"{
505 "reason": "compiler-artifact",
506 "package_id": "path+file:///home/user/project#abbaye@0.10.0",
507 "target": { "name": "build-script-build", "kind": ["custom-build"] },
508 "filenames": ["/home/user/project/target/release/build-script-build"]
509 }"#;
510 let msg: CargoMessage = serde_json::from_str(json).unwrap();
511 let is_custom_build = msg
512 .target
513 .as_ref()
514 .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"));
515 assert!(is_custom_build, "custom-build target should be identified");
516 }
517
518 #[test]
519 fn deserialize_external_dependency_skipped() {
520 let json = r#"{
521 "reason": "compiler-artifact",
522 "package_id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0",
523 "target": { "name": "serde", "kind": ["lib"] },
524 "filenames": ["/home/user/project/target/release/libserde.rlib"]
525 }"#;
526 let msg: CargoMessage = serde_json::from_str(json).unwrap();
527 let is_local = msg
528 .package_id
529 .as_deref()
530 .is_some_and(|id| id.contains("path+file://"));
531 assert!(
532 !is_local,
533 "external dependency should NOT be identified as local"
534 );
535 }
536
537 #[test]
538 fn deserialize_multiple_filenames_for_bin() {
539 let json = r#"{
540 "reason": "compiler-artifact",
541 "package_id": "path+file:///home/user/project#my-app@0.1.0",
542 "target": { "name": "my-app", "kind": ["bin"] },
543 "filenames": [
544 "/home/user/project/target/release/my-app",
545 "/home/user/project/target/release/my-app.d"
546 ]
547 }"#;
548 let msg: CargoMessage = serde_json::from_str(json).unwrap();
549 let filenames = msg.filenames.unwrap();
550 assert_eq!(filenames.len(), 2);
551 assert!(filenames[0].ends_with("my-app"));
552 assert!(filenames[1].ends_with("my-app.d"));
553 }
554
555 #[test]
556 fn deserialize_cdylib_artifact() {
557 let json = r#"{
558 "reason": "compiler-artifact",
559 "package_id": "path+file:///home/user/project#libfoo@0.1.0",
560 "target": { "name": "libfoo", "kind": ["cdylib"] },
561 "filenames": ["/home/user/project/target/release/liblibfoo.so"]
562 }"#;
563 let msg: CargoMessage = serde_json::from_str(json).unwrap();
564 let filename = msg.filenames.unwrap().into_iter().next().unwrap();
565 let path = Path::new(&filename);
566 let ext = path
567 .extension()
568 .map(|e| e.to_string_lossy())
569 .unwrap_or_default();
570 assert!(
572 !matches!(ext.as_ref(), "rlib" | "rmeta" | "d"),
573 "cdylib .so file should not be skipped"
574 );
575 }
576
577 #[test]
580 fn artifact_name_includes_version_and_triple() {
581 let path = Path::new("/target/release/abbaye");
582 let stem = path
583 .file_stem()
584 .map(|s| s.to_string_lossy().into_owned())
585 .unwrap_or_default();
586 let dot_ext = path
587 .extension()
588 .map(|e| format!(".{}", e.to_string_lossy()))
589 .unwrap_or_default();
590 let version = "0.10.0";
591 let triple = "x86_64-unknown-linux-musl";
592 let name = format!("{stem}-{version}-{triple}{dot_ext}");
593 assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl");
594 }
595
596 #[test]
597 fn artifact_name_with_exe_extension() {
598 let path = Path::new("/target/release/abbaye.exe");
599 let stem = path
600 .file_stem()
601 .map(|s| s.to_string_lossy().into_owned())
602 .unwrap_or_default();
603 let dot_ext = path
604 .extension()
605 .map(|e| format!(".{}", e.to_string_lossy()))
606 .unwrap_or_default();
607 let name = format!("{stem}-0.10.0-x86_64-pc-windows-msvc{dot_ext}");
608 assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc.exe");
609 }
610
611 #[tokio::test]
614 async fn test_relocate_artifacts_copies_to_target() {
615 let tmp = tempfile::tempdir().unwrap();
616 let tmp_root = tmp.path().join("cross-tmp");
617 let triple_dir = tmp_root.join("x86_64-unknown-linux-musl").join("release");
618 tokio::fs::create_dir_all(&triple_dir).await.unwrap();
619 let binary_path = triple_dir.join("abbaye");
620 tokio::fs::write(&binary_path, b"binary content")
621 .await
622 .unwrap();
623
624 let artifacts = vec![ArtifactPath {
625 path: binary_path,
626 name: "abbaye-0.10.0-x86_64-unknown-linux-musl".to_owned(),
627 hash: None,
628 category: None,
629 group_name: None,
630 group_comment: None,
631 }];
632
633 let relocated = relocate_artifacts(artifacts, &tmp_root).await.unwrap();
634 assert_eq!(relocated.len(), 1);
635 let expected = Path::new("target")
636 .join("x86_64-unknown-linux-musl")
637 .join("release")
638 .join("abbaye");
639 assert_eq!(relocated[0].path, expected);
640 assert!(expected.exists(), "binary should exist at canonical path");
641 let content = tokio::fs::read_to_string(&expected).await.unwrap();
642 assert_eq!(content, "binary content");
643 }
644
645 #[tokio::test]
648 async fn test_get_host_target_returns_triple() {
649 let triple = get_host_target().await.unwrap();
650 assert!(!triple.is_empty(), "host target triple should not be empty");
651 assert!(
653 triple.contains('-'),
654 "triple should be dash-separated: {triple}"
655 );
656 }
657
658 #[tokio::test]
661 async fn test_read_crate_version_from_toml() {
662 let tmp = tempfile::tempdir().unwrap();
663 let toml_path = tmp.path().join("Cargo.toml");
664 tokio::fs::write(
665 &toml_path,
666 "[package]\nname = \"test\"\nversion = \"0.5.0\"\n",
667 )
668 .await
669 .unwrap();
670
671 let version = read_crate_version(Some(&toml_path)).await.unwrap();
672 assert_eq!(version, "0.5.0");
673 }
674
675 #[tokio::test]
676 async fn test_read_crate_version_returns_error_on_missing() {
677 let tmp = tempfile::tempdir().unwrap();
678 let toml_path = tmp.path().join("Cargo.toml");
679 tokio::fs::write(&toml_path, "[package]\nname = \"no-version\"\n")
680 .await
681 .unwrap();
682
683 let result = read_crate_version(Some(&toml_path)).await;
684 assert!(
685 result.is_err(),
686 "should error when version field is missing"
687 );
688 }
689
690 #[test]
693 fn use_cross_disables_parallel_isolation() {
694 let uses_isolation = |parallel: bool, use_cross: bool| -> bool { parallel && !use_cross };
700
701 assert!(
702 !uses_isolation(true, true),
703 "parallel=true + use_cross=true should NOT use isolation"
704 );
705 assert!(
706 !uses_isolation(false, true),
707 "parallel=false + use_cross=true should NOT use isolation"
708 );
709 assert!(
710 uses_isolation(true, false),
711 "parallel=true + use_cross=false SHOULD use isolation"
712 );
713 assert!(
714 !uses_isolation(false, false),
715 "parallel=false + use_cross=false should NOT use isolation"
716 );
717 }
718
719 #[test]
722 fn skips_rlib_and_rmeta_and_dot_d_files() {
723 for ext in ["rlib", "rmeta", "d"] {
724 let filename = format!("/target/release/libfoo.{ext}");
725 let path = Path::new(&filename);
726 let ext_str = path
727 .extension()
728 .map(|e| e.to_string_lossy())
729 .unwrap_or_default();
730 assert!(
731 ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d",
732 "{ext} should match skip condition"
733 );
734 }
735 }
736
737 #[test]
738 fn keeps_executable_and_cdylib_files() {
739 for ext in ["", "exe", "so", "dylib", "dll"] {
740 let filename = if ext.is_empty() {
741 "/target/release/my-bin".to_owned()
742 } else {
743 format!("/target/release/my-bin.{ext}")
744 };
745 let path = Path::new(&filename);
746 let ext_str = path
747 .extension()
748 .map(|e| e.to_string_lossy())
749 .unwrap_or_default();
750 let is_skippable = ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d";
751 assert!(!is_skippable, "{ext} should NOT be skipped");
752 }
753 }
754
755 #[test]
758 fn default_parallel_is_true() {
759 assert!(default_parallel(), "parallel should default to true");
760 }
761
762 #[test]
763 fn use_cross_defaults_to_false() {
764 let config = CargoBuilderConfig::default();
765 assert!(!config.use_cross, "use_cross should default to false");
766 }
767}
768
769#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
771pub struct CargoDocBuilderConfig {
772 pub manifest_path: Option<std::path::PathBuf>,
777
778 #[serde(default)]
780 pub no_deps: bool,
781}
782
783pub struct CargoDocBuilder;
785
786impl Builder for CargoDocBuilder {
787 type ConfigType = CargoDocBuilderConfig;
788
789 async fn build(
790 &self,
791 config: Self::ConfigType,
792 abbaye_version: &str,
793 log: LogSender,
794 ) -> Result<Vec<ArtifactPath>> {
795 let mut cmd = Command::new("cargo");
796 cmd.arg("doc");
797 cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
798
799 if config.no_deps {
800 cmd.arg("--no-deps");
801 }
802
803 if let Some(manifest) = &config.manifest_path {
804 cmd.arg("--manifest-path").arg(manifest);
805 }
806
807 let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?;
808
809 let stderr = child.stderr.take().expect("stderr was piped");
810 tokio::spawn(async move {
811 let mut stderr_lines = BufReader::new(stderr).lines();
812 while let Ok(Some(line)) = stderr_lines.next_line().await {
813 let _ = log.send(LogEvent::Line(line));
814 }
815 });
816
817 let status = child.wait().await.into_diagnostic()?;
818
819 if !status.success() {
820 return Err(miette!("cargo doc failed with exit status: {status}"));
821 }
822
823 let doc_dir = config
824 .manifest_path
825 .as_deref()
826 .and_then(|p| p.parent())
827 .unwrap_or_else(|| std::path::Path::new("."))
828 .join("target/doc");
829
830 if !doc_dir.exists() {
831 return Err(miette!("doc directory not found at {}", doc_dir.display()));
832 }
833
834 Ok(vec![ArtifactPath {
835 path: doc_dir,
836 name: "doc".to_owned(),
837 hash: None,
838 category: None,
839 group_name: None,
840 group_comment: None,
841 }])
842 }
843}